package setup import ( "crypto/subtle" "errors" "log/slog" "net/http" "strings" "sync" "unicode" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" ) // SetupUnlockCookieName is the signed cookie that carries a granted unlock of // the first-run wizard (D-044). It is NOT the session cookie — no user exists // yet (SPEC-005 §4) — and it is deliberately separate so it can be cleared // when setup completes. const SetupUnlockCookieName = "myday_setup" // ErrNoGateCSRF is returned when the gate is built without the pre-session CSRF // helper — a required code needs a signing key to bind the unlock cookie, so // this fails closed. var ErrNoGateCSRF = errors.New("setup: the gate needs the pre-session CSRF signer") // Gate is the first-run access gate. Under D-045 the gate is the **config // toggle `setup`**: while `setup: true` the wizard is enabled. The setup CODE // (`setup_code`) is an OPTIONAL second factor — when set, the wizard requires // it; when empty, the toggle alone admits. // // When a code IS set the gate is the D-044 mechanism: a **signed unlock cookie** // (`myday_setup`) that carries a keyed, one-way FINGERPRINT of the code (never // the code itself). The code is SINGLE-USE — the first successful unlock retires // it — while the browser keeps its unlock cookie, so the operator can keep // working and a second visitor cannot re-use the printed value. // // The gate also renders the three first-run pages so the door stays free of the // setup package's HTML: // // - CodeForm — `setup: true` + a required code, not yet unlocked; // - Landing — `setup: true`, `GET /` points at the wizard; // - Locked — `setup: false` and no admin: enable setup and restart. // // Spec: docs/specs/spec-005-setup-wizard.md §3/§4. type Gate struct { csrf *httpx.PreSessionCSRF logger *slog.Logger // required is immutable after construction: true when `setup_code` is set. required bool mu sync.Mutex display string // the code as shown in the banner (the configured value) norm string // the normalised form compared on entry ("" once consumed) fp string // the keyed fingerprint; stable across consumption } // NewGate builds the gate from the configured code (D-044/D-045). An // empty/blank code means NO code is required — the `setup` toggle alone admits // and the wizard is served directly. A non-empty code is required. func NewGate(code string, csrf *httpx.PreSessionCSRF, logger *slog.Logger) (*Gate, error) { if csrf == nil { return nil, ErrNoGateCSRF } if logger == nil { logger = slog.Default() } display := strings.TrimSpace(code) norm := normalizeCode(display) if norm == "" { // D-045: no code — the config toggle is the gate. return &Gate{csrf: csrf, logger: logger}, nil } return &Gate{ csrf: csrf, logger: logger, required: true, display: display, norm: norm, fp: csrf.Signer().Fingerprint(fingerprintDomain + norm), }, nil } // fingerprintDomain separates the setup-code fingerprint from every other use // of the signer's key (the pre-session CSRF, the ceremony state). const fingerprintDomain = "myday.setup.unlock.v1:" // Required reports whether a setup code is required to reach the wizard // (D-045: `setup_code` is set). When false the `setup` toggle alone admits. func (g *Gate) Required() bool { return g.required } // Code returns the setup code to PRINT in the startup banner, or "" when no // code is required (D-045) — there is nothing to print in that case. func (g *Gate) Code() string { g.mu.Lock() defer g.mu.Unlock() return g.display } // Accept reports whether a submitted code is the live setup code and, when it // is, RETIRES it (single-use). The comparison is constant-time and ignores // formatting (case, spaces, dashes), so a hand-typed value works. It always // returns false when no code is required. func (g *Gate) Accept(code string) bool { g.mu.Lock() defer g.mu.Unlock() if g.norm == "" { return false // not required, already consumed, or cleared — never re-usable } if subtle.ConstantTimeCompare([]byte(normalizeCode(code)), []byte(g.norm)) != 1 { return false } g.norm = "" // single-use: the code is spent the moment it works return true } // Grant mints the signed cookie value that records the unlock. It is valid only // while the gate holds the same code fingerprint, so it dies with a restart or // with setup completion. func (g *Gate) Grant() string { g.mu.Lock() defer g.mu.Unlock() if g.fp == "" { return "" } return g.csrf.Signer().Sign(g.fp) } // Unlocked reports whether the request carries a valid unlock grant (D-044). // It is always false when no code is required — that case is admitted by the // toggle alone, which the door decides (SPEC-003 §7). func (g *Gate) Unlocked(r *http.Request) bool { g.mu.Lock() fp := g.fp g.mu.Unlock() if fp == "" { return false } c, err := r.Cookie(SetupUnlockCookieName) if err != nil || c.Value == "" { return false } v, ok := g.csrf.Signer().Unsign(c.Value) return ok && subtle.ConstantTimeCompare([]byte(v), []byte(fp)) == 1 } // SetUnlockCookie writes the granted-unlock cookie. Its `Secure` flag is // resolved per request by the door (D-043), like every other cookie here. func (g *Gate) SetUnlockCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: SetupUnlockCookieName, Value: g.Grant(), Path: "/", HttpOnly: true, Secure: httpx.SecureFromRequest(r), SameSite: http.SameSiteLaxMode, }) } // Clear retires the gate — the fingerprint goes, so every outstanding unlock // grant stops verifying. Called when the wizard completes (SPEC-005 §5). func (g *Gate) Clear() { g.mu.Lock() g.norm, g.fp = "", "" g.mu.Unlock() } // ── the first-run pages (D-044 · D-045) ───────────────────────────────────── // CodeForm renders the setup-code entry page — `setup: true` with a required // `setup_code`, before the code is entered. The form is anonymous, so it // carries the signed pre-session double-submit token (A15) exactly like every // other pre-session form (SPEC-003 §6). func (g *Gate) CodeForm(w http.ResponseWriter, r *http.Request) error { return g.codeForm(w, r, http.StatusOK, "") } // codeFormError re-renders the page with a generic notice. The message is // deliberately the same for a wrong and an absent code (no oracle, A3). func (g *Gate) codeFormError(w http.ResponseWriter, r *http.Request) error { return g.codeForm(w, r, http.StatusForbidden, "That setup code is not correct.") } func (g *Gate) codeForm(w http.ResponseWriter, r *http.Request, status int, flash string) error { token, err := g.csrf.Ensure(w, r) if err != nil { return err } return renderHTML(w, status, codeFormPage(token, flash)) } // Landing renders the friendly page `GET /` shows while `setup: true` (D-045): // a pointer to the wizard at `/setup`. func (g *Gate) Landing(w http.ResponseWriter, r *http.Request) error { return renderHTML(w, http.StatusOK, landingPage()) } // Locked renders the page `GET /` and `GET /setup` show when `setup: false` and // no admin exists (D-045): the operator must enable the toggle and restart. // There is no form — this state is deliberately not claimable. func (g *Gate) Locked(w http.ResponseWriter, r *http.Request) error { return renderHTML(w, http.StatusOK, lockedPage()) } // ── code normalisation ────────────────────────────────────────────────────── // normalizeCode lowercases a code and drops every non-alphanumeric character, // so `9F3A-1B2C` and `9f3a1b2c` are the same code on entry. func normalizeCode(code string) string { var sb strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(code)) { if unicode.IsLetter(r) || unicode.IsDigit(r) { sb.WriteRune(r) } } return sb.String() }