The first-run wizard's `admin.totp.begin` delegated to the enroll flow's
`TOTPBegin`, which is `webauthn.js`'s JSON API: the browser was shown raw
`{"otpauth_url":…}` and the wizard stalled at step 2, rendering no secret.
Extract the issue step into a write-free seam, `flow.Actions.IssueTOTP`, and
keep `TOTPBegin`'s JSON contract by having it call the seam. The wizard now
issues via the seam and renders its own HTML page (`text/html`, inside the
shell, no inline scripts) showing the otpauth URL, the human-readable secret
for manual entry, and the verify form (pre-session CSRF, A15).
`setup.Enroller` drops `TOTPBegin` — the wizard no longer calls it, and a
JSON-writing method on a no-JS seam is the coupling that caused the bug.
Tighten the tests that missed it: `setup_wizard_test.go` asserts the `begin`
response is `text/html` and carries the secret and the verify form (not merely
200), keeping the end-to-end begin→code→303 path; `enroll_login_test.go` pins
the enroll flow's JSON contract (`application/json` + `otpauth_url`).
Spec: docs/specs/spec-005-setup-wizard.md §11.
166 lines
5.5 KiB
Go
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) IssueTOTP(context.Context, string) (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))
|
|
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)
|
|
}
|
|
}
|