package setup import ( "crypto/rand" "crypto/subtle" "encoding/hex" "errors" "fmt" "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" // CodeBytes is the setup code's entropy: 16 bytes == 128 bits (D-044). const CodeBytes = 16 // ErrNoGateCSRF is returned when the gate is built without the pre-session CSRF // helper — the 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 D-044 first-run gate: the SETUP CODE plus the signed unlock // cookie the boilerplate mints when the correct code is entered. // // It closes first-visitor-wins (D-025) where a tunnel cannot reach — the // LXC/Proxmox + Zoraxy topology. `Unlocked` answers the door's "may this // request see the wizard?" question (gate b); the direct tunnel (gate a) is // decided by the door itself (SPEC-003 §7). // // The code is SINGLE-USE: the first successful unlock retires it. The unlock // survives the retirement because the cookie carries a KEYED, ONE-WAY // FINGERPRINT of the code (`Signer.Fingerprint`), not the code — so an operator // who unlocked the wizard can keep working in that browser while a second // visitor cannot re-use the printed code. // // Spec: docs/specs/spec-005-setup-wizard.md §3/§4. type Gate struct { csrf *httpx.PreSessionCSRF logger *slog.Logger mu sync.Mutex display string // the code as shown in the banner (grouped for readability) 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). An empty/blank code // generates a fresh 128-bit one. It returns the gate, whose Display/Code // the caller prints in the startup banner. 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 == "" { generated, err := generateCode() if err != nil { return nil, err } display, norm = generated, normalizeCode(generated) } return &Gate{ csrf: csrf, logger: logger, 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:" // Code returns the setup code to PRINT in the startup banner (D-044). It is the // configured code verbatim, or the generated grouped form. 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. func (g *Gate) Accept(code string) bool { g.mu.Lock() defer g.mu.Unlock() if g.norm == "" { return false // 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, // gate b). It is independent of the tunnel — the door ORs the two (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 boilerplate (D-044) ─────────────────────────────────────── // Boilerplate renders the friendly first-run page — "MyDay is not set up yet" — // with the setup-code entry form. It is what `GET /` and `GET /setup` show in // setup mode instead of a bare 404, so the domain never looks "down". // // 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) Boilerplate(w http.ResponseWriter, r *http.Request) error { return g.boilerplate(w, r, http.StatusOK, "") } // boilerplateError 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) boilerplateError(w http.ResponseWriter, r *http.Request) error { return g.boilerplate(w, r, http.StatusForbidden, "That setup code is not correct.") } func (g *Gate) boilerplate(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, boilerplatePage(token, flash)) } // ── code generation & normalisation ───────────────────────────────────────── // generateCode returns a fresh 128-bit code, formatted as groups of four hex // characters (e.g. `9f3a-…`) so it is easy to read from the log and to type. func generateCode() (string, error) { b := make([]byte, CodeBytes) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("setup: setup code entropy: %w", err) } raw := hex.EncodeToString(b) var sb strings.Builder for i := 0; i < len(raw); i += 4 { if i > 0 { sb.WriteByte('-') } sb.WriteString(raw[i : i+4]) } return sb.String(), nil } // 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() }