D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:
- setup: true -> /setup serves the wizard (the code form first when
`setup_code` is set, via the existing D-044 gate);
GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
setup" boilerplate; every other route -> 404; the
wizard is not claimable.
- completion -> writes `setup: false` and creates the admin.
`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). The peer/tunnel gate is retired: `httpx.
SetupRequestAllowed` and `httpx.IsLoopbackIP` are deleted; the XFF/XFP
handling is kept for the client IP and the Secure/__Host- cookies + HSTS
(A6/A11/D-043). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.
New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.
Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
219 lines
7.9 KiB
Go
219 lines
7.9 KiB
Go
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()
|
|
}
|