NextMyDay/cmd/myday/main_test.go
Claus Lohmar 8268757773 fix(security): sign the setup ceremony with startup-generated keys
REVIEW-001 (M0) M1s: the /setup wizard's pre-session double-submit ceremony
(A15) was signed with csrf_key/cookie_secret left at the shipped CHANGE_ME
placeholder until `finish` persisted generated values — a publicly-known
signing key for the whole bootstrap.

Generate strong ephemeral values at startup in setup mode, mirroring the
TOTP key (R-6/D-1): `finish` skips a non-placeholder, so the wizard persists
the very values the process already uses.

Also add cmd/myday's first test — REVIEW-001 M2c: the D-1 invariant (the
startup TOTP key is the persisted key) was covered only by a manual smoke. It
generates the key, enrolls a secret, persists via the wizard, reopens the DB
with the persisted key and asserts the secret still unseals.
2026-10-14 11:30:09 +00:00

166 lines
5.5 KiB
Go

package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users"
)
// stubEnroller satisfies setup.Enroller — this test never runs a ceremony.
type stubEnroller struct{}
func (stubEnroller) TOTPBegin(http.ResponseWriter, *http.Request, string) error { return nil }
func (stubEnroller) TOTPVerify(http.ResponseWriter, *http.Request, string) error { return nil }
// setupYAML is a fresh-instance config: setup mode, every secret a placeholder.
const setupYAML = `setup: true
server:
listen: "127.0.0.1:8080"
base_url: "https://myday.example.com"
app:
locale: "en-GB"
database:
path: "DBPATH"
secrets:
cookie_secret: "CHANGE_ME_32_BYTES_RANDOM"
csrf_key: "CHANGE_ME_32_BYTES_RANDOM"
totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM"
`
// TestTOTPKeySurvivesSetupAndRestart pins the D-1 invariant (M2c): the TOTP key
// the process GENERATES AT STARTUP is the key the wizard persists, so a restart
// adopts the same key and the secrets enrolled during setup still unseal. A
// regression here silently destroys every future TOTP secret.
func TestTOTPKeySurvivesSetupAndRestart(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
cfgPath := filepath.Join(dir, config.FileName)
dbPath := filepath.Join(dir, "myday.db")
body := strings.Replace(setupYAML, "DBPATH", dbPath, 1)
if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
// Startup (D-1 + M1s): every placeholder is replaced with a strong value
// the PROCESS will use — the wizard's ceremony must not run on a shipped
// placeholder key.
key, generated, err := prepareTOTPKey(cfg)
if err != nil {
t.Fatalf("prepareTOTPKey: %v", err)
}
if !generated || config.SecretIsPlaceholder(key) || len(key) != auth.KeySize {
t.Fatalf("the TOTP key was not generated (generated=%v len=%d)", generated, len(key))
}
cfg.Secrets.TOTPEncryptionKey = key // run() adopts it in setup mode
if _, err := prepareSigningKeys(cfg); err != nil {
t.Fatalf("prepareSigningKeys: %v", err)
}
// First run: the wizard enrolls the admin and seals its TOTP secret with
// the startup key.
db, err := store.Open(cfg.Database.Path)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
if err := db.Migrate(); err != nil {
t.Fatalf("Migrate: %v", err)
}
aud := audit.New(db.DB)
us := users.New(db.DB, aud)
if _, err := us.Create(ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil {
t.Fatalf("create admin: %v", err)
}
sealer, err := auth.NewAEADSealer([]byte(key))
if err != nil {
t.Fatalf("NewAEADSealer: %v", err)
}
ids := auth.New(db.DB, aud, auth.WithSealer(sealer)).Identities()
idID, _, err := ids.Mint(ctx, 1)
if err != nil {
t.Fatalf("Mint: %v", err)
}
if err := ids.Activate(ctx, idID); err != nil {
t.Fatalf("Activate: %v", err)
}
const secret = "JBSWY3DPEHPK3PXP"
if err := ids.PutTOTPSecret(ctx, idID, []byte(secret)); err != nil {
t.Fatalf("PutTOTPSecret: %v", err)
}
// The wizard persists the config (`finish`).
pre, err := httpx.NewPreSessionCSRF([]byte(cfg.Secrets.CookieSecret), false)
if err != nil {
t.Fatalf("NewPreSessionCSRF: %v", err)
}
wiz, err := setup.New(setup.Deps{
Config: cfg, ConfigPath: cfgPath, Users: us, Identities: ids,
Enroll: stubEnroller{}, CSRF: pre, Audit: aud, Printout: io.Discard,
})
if err != nil {
t.Fatalf("setup.New: %v", err)
}
form := url.Values{"app.locale": {"en-GB"}, "app.timezone": {"Europe/London"}}
req := httptest.NewRequest("POST", "/setup?do=setup&action=finish", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
if err := wiz.Finish(rec, req); err != nil {
t.Fatalf("Finish: %v", err)
}
if rec.Code != http.StatusSeeOther {
t.Fatalf("Finish = %d, want 303", rec.Code)
}
if err := db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
// Restart: the persisted config must carry the SAME TOTP key, and the
// sealed secret must still open with it (D-1 — the whole point).
restored, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("the persisted config does not load after setup: %v", err)
}
if restored.Setup {
t.Error("the persisted config still says setup: true")
}
if restored.Secrets.TOTPEncryptionKey != key {
t.Fatalf("the persisted TOTP key differs from the runtime key (D-1):\n got %q\nwant %q",
restored.Secrets.TOTPEncryptionKey, key)
}
db2, err := store.Open(restored.Database.Path)
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer func() { _ = db2.Close() }()
sealer2, err := auth.NewAEADSealer([]byte(restored.Secrets.TOTPEncryptionKey))
if err != nil {
t.Fatalf("restart sealer: %v", err)
}
ids2 := auth.New(db2.DB, nil, auth.WithSealer(sealer2)).Identities()
got, err := ids2.TOTPSecret(ctx, idID)
if err != nil {
t.Fatalf("the TOTP secret no longer unseals after a restart (D-1/M2c): %v", err)
}
if string(got) != secret {
t.Errorf("TOTP secret after restart = %q, want %q", got, secret)
}
}