NextMyDay/internal/core/setup/gate.go
Claus Lohmar 6d421b61fd feat(setup): gate first run with a printed setup code and a first-run page
The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so
the SSH tunnel is impractical: it lands on the container gateway, not
loopback. D-025 is re-revised and D-044 adds the general gate.

- core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at
  first run, or pinned by config `setup_code`) plus 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 (retired on the first success) and
  the comparison ignores case/separators.
- The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected
  (A15), rate-limited (the /setup A3 class) and audited; a wrong or absent
  code gets the same generic 403 page.
- The door (server.Deps.SetupGate): in setup mode the wizard is served to
  EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /`
  and an un-unlocked `GET /setup` render a friendly first-run page with the
  code form — never a bare 404 — and every other route stays 404.
- cmd/myday prints an unmistakable startup banner: setup mode, the wizard
  URL, the code, where to read it (journalctl / pct exec 11020) and the
  restart that adopts the persisted secrets.
- httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`.

Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock
(code → wizard; wrong/absent → generic, 429 under the limit), single-use, the
CSRF requirement, access-log redaction, and that a non-/setup route is still
404. Gate green (build · vet · test -race); live-smoked against the binary.
2026-10-15 06:33:13 +00:00

221 lines
7.4 KiB
Go

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()
}