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.
221 lines
7.4 KiB
Go
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()
|
|
}
|