From 6ea40ad36aba0e5b9ad8a3c40e727dc9d4879533 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Mon, 15 Jun 2026 14:11:23 +0000 Subject: [PATCH 01/14] Add design spec for optional admin login 2FA (TOTP) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-15-admin-totp-2fa-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md diff --git a/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md b/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md new file mode 100644 index 0000000..009d19f --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md @@ -0,0 +1,206 @@ +# Admin Login 2FA (TOTP) — Design + +**Date:** 2026-06-15 +**Status:** Approved (design) +**Target version:** 0.20.0 + +## Summary + +Add **optional** TOTP-based two-factor authentication to the single-admin +login. When enabled, login requires a 6-digit TOTP code (or a one-time +recovery code) in addition to the existing admin password. The feature is: + +- Implemented with `github.com/pquerna/otp` (no hand-rolled crypto). +- Stored in the existing `settings` key-value table — **no schema migration**. +- Enrolled via a QR-code flow in the admin UI Settings page. +- Recoverable via either one-time backup codes **or** a `docker exec` CLI + command, so a lost authenticator never permanently locks the admin out. + +Enforcement is **login-only**. The withdraw handler keeps its existing +password re-prompt; it does not additionally require a TOTP code. + +## Decisions (locked during brainstorming) + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Recovery | One-time recovery codes **and** a CLI disable command | Defense in depth; never weakens the second factor | +| TOTP impl | `pquerna/otp` library | Avoids subtle RFC 6238 / base32 / skew bugs | +| Enforcement | Login only | Session is already 2FA-backed; avoids 30s-code friction during a payout | +| Versioning | SemVer minor bump (0.19.8 → 0.20.0) | New backward-compatible feature | + +### Non-goals (YAGNI) + +- No TOTP requirement on the withdraw re-prompt (login-only enforcement). +- No separate backend rate-limiter — Caddy already rate-limits + `/admin/api/auth/login` at 10 req/min/IP. +- No multi-admin / per-user 2FA — the system is single-admin by design. +- No "password still works as fallback" mode — that would defeat 2FA. + +## Storage (settings table — no migration) + +Three new keys in the `settings` table: + +| Key | Value | Settings-UI visibility | +|-----|-------|------------------------| +| `admin_totp_enabled` | `"Y"` / `"N"` (or empty) | Shown — status only | +| `admin_totp_secret` | base32 TOTP shared secret | **Redacted** | +| `admin_totp_recovery_hash` | JSON array of bcrypt hashes of the **unused** recovery codes | **Redacted** (matches existing `_hash` suffix) | + +**Redaction:** add a `_secret` suffix to the `strings.HasSuffix` guard in +`web/admin_api_settings.go` (currently `_hash` / `_token` / `_code`). The +recovery key already ends in `_hash`, so it is redacted with no change. + +**Why the secret is plaintext but redacted:** TOTP is a shared-secret scheme, +so the secret must be stored recoverable to validate codes. Redaction keeps it +out of the settings list endpoint. Recovery codes, by contrast, are stored +**bcrypt-hashed** — a DB read never yields usable codes. + +**Enforcement keys on `admin_totp_enabled == "Y"`**, never on the mere presence +of a secret. This guarantees a half-finished enrollment (secret generated but +code never confirmed) cannot lock anyone out. + +## TOTP implementation + +Package: `github.com/pquerna/otp` + `github.com/pquerna/otp/totp`. + +- **Secret generation:** `totp.Generate(totp.GenerateOpts{Issuer: "Bolt Card + Hub", AccountName: })` → returns an `*otp.Key` exposing + `.Secret()` (base32) and `.URL()` (the `otpauth://` URI). +- **Validation:** `totp.ValidateCustom(code, secret, time.Now(), + totp.ValidateOpts{Period: 30, Skew: 1, Digits: 6, Algorithm: SHA1})` — + **±1 time-step skew** tolerates client/server clock drift. +- **QR rendering:** feed `key.URL()` into the **existing** + `util.QrPngBase64Encode()` (skip2/go-qrcode) — we do not use the library's + own image path. The authenticator app shows + "Bolt Card Hub (hub.example.com)" using the `host_domain` setting as the + account label. + +New dependency footprint: `github.com/pquerna/otp` and its transitive +`github.com/boombuler/barcode` (small, pure Go). `go.mod` / `go.sum` must be +committed (CI fails otherwise). + +## Login flow (stateless — no half-authenticated state) + +Extend `adminApiLogin` (`web/admin_api.go`) to accept `{ password, code? }`: + +1. Verify password via existing `verifyAdminPassword`. Wrong → `401 + { error: "invalid password" }`. +2. If `admin_totp_enabled != "Y"` → issue session (unchanged behavior). +3. If enabled and `code` is empty → `401 { error: "2fa required", + totpRequired: true }`. **No session issued.** +4. If `code` is present, accept it if **either**: + - it is a valid TOTP code for `admin_totp_secret` (±1 skew), **or** + - it matches an unused entry in `admin_totp_recovery_hash` + (bcrypt compare). On a recovery match, **remove that hash** from the JSON + array and persist (single-use), then issue the session. + - Otherwise → `401 { error: "invalid code", totpRequired: true }`. +5. On success, issue the session token exactly as today (24h cookie). + +The password is re-sent together with the code, so there is **no intermediate +"pending 2FA" token** to store or expire. Brute force is bounded by Caddy's +existing 10 req/min/IP limit (a 6-digit code with ±1 skew = 3 valid values out +of 1,000,000 per window). + +`adminApiAuthCheck` is unchanged — it only validates an already-issued session +cookie, which is only minted after the full factor(s) pass. + +## New endpoints (all behind existing `adminApiAuth` session) + +Registered in the `CreateHandler_AdminApi` switch in `web/admin_api.go`: + +| Method + path | Body | Returns | +|---------------|------|---------| +| `GET /admin/api/auth/2fa/status` | — | `{ enabled: bool, recoveryCodesRemaining: int }` | +| `POST /admin/api/auth/2fa/setup` | — | `{ secret, otpauthUri, qrPng }` — generates a **pending** secret (`enabled` stays `N`). Returns `400` if `admin_totp_enabled == "Y"` (must `disable` first) so an active secret is never clobbered | +| `POST /admin/api/auth/2fa/enable` | `{ code }` | Validates `code` against the pending secret; sets `enabled=Y`; generates and returns `{ recoveryCodes: [...] }` **once** (stores only their bcrypt hashes) | +| `POST /admin/api/auth/2fa/disable` | `{ password }` | Re-verifies password via `verifyAdminPassword`; clears all three keys | + +Handler code lives in a new `web/admin_api_2fa.go` (mirrors the +`admin_api_*.go` per-domain convention). TOTP helpers (generate/validate/ +recovery-code generate+hash) live in a new `web/totp.go`. + +**Recovery codes:** generated as ~10 random codes (e.g. 10 hex/base32 chars, +formatted for readability), shown to the admin exactly once on enable, and +persisted only as bcrypt hashes. The UI must warn that they cannot be shown +again. + +## CLI recovery + +Add `case "DisableAdmin2FA"` to the switch in `cli.go` → +clears `admin_totp_enabled`, `admin_totp_secret`, and +`admin_totp_recovery_hash` via `Db_set_setting(... "")`. Documented usage: + +```bash +docker exec -it card ./app DisableAdmin2FA +``` + +Also add it to the CLI Commands list in CLAUDE.md. + +## Frontend (admin-ui React SPA) + +- **`hooks/use-auth.tsx`:** `login()` gains an optional `code` argument and + surfaces a `totpRequired` signal (e.g. a typed error or returned status) so + the login page can reveal the code field without losing the entered password. +- **`pages/login.tsx`:** when `totpRequired` is signalled, reveal a 6-digit + code input plus a "Use a recovery code instead" toggle, and resubmit + password + code. Follows existing shadcn `Input` / `Alert` / `Button` + patterns already in the file. +- **`pages/settings.tsx`:** new **Security** card — "Two-Factor + Authentication": + - Disabled state → "Enable" button → setup flow: show QR (`qrPng`) + the + manual base32 secret → enter a code → confirm → show recovery codes once + (with copy/download affordance + "save these now" warning). + - Enabled state → "codes remaining: N" + "Disable" button → confirm with + password. + - Backed by the four `/admin/api/auth/2fa/*` endpoints. + +## Testing + +Web-package unit tests using existing helpers (`openTestApp`, +`setupAdminSession`), generating live codes with `totp.GenerateCode(secret, +time.Now())`: + +- 2FA enabled + password only → `401 totpRequired`. +- 2FA enabled + valid TOTP code → session issued. +- 2FA enabled + invalid code → `401`. +- Recovery code logs in once, then is **consumed** (second use of the same + code fails). +- `setup` → `enable` → `status` reflects `enabled: true`; `disable` clears all + three keys and `status` returns `enabled: false`. +- Settings-list endpoint never exposes `admin_totp_secret` + (redaction assertion). + +## Versioning + +Bump `Version` in `docker/card/build/build.go` `0.19.8 → 0.20.0` (SemVer +minor: new backward-compatible feature) and update the matching `Version:` +references in CLAUDE.md and the project memory file. + +**Convention refinement** (to record in CLAUDE.md + memory): adopt semantic +versioning — feature PRs bump MINOR, bug-fix/maintenance PRs bump PATCH, +breaking changes bump MAJOR. This supersedes the prior "patch-bump every PR" +rule. + +## File-change checklist + +**Backend** +- `web/totp.go` (new) — generate/validate TOTP, generate + hash recovery codes. +- `web/admin_api_2fa.go` (new) — status / setup / enable / disable handlers. +- `web/admin_api.go` — route the four new endpoints; extend `adminApiLogin` + with `code` + TOTP/recovery verification. +- `web/admin_api_settings.go` — add `_secret` to the redaction suffix check. +- `cli.go` — `DisableAdmin2FA` command. +- `go.mod` / `go.sum` — add `github.com/pquerna/otp`. +- `build/build.go` — version bump. + +**Frontend** +- `admin-ui/src/hooks/use-auth.tsx` — `login(password, code?)` + `totpRequired`. +- `admin-ui/src/pages/login.tsx` — conditional code field + recovery toggle. +- `admin-ui/src/pages/settings.tsx` — Security / 2FA management card. + +**Docs** +- `CLAUDE.md` — settings keys, CLI command, versioning convention. +- Project memory file — new facts + versioning convention. + +**Tests** +- `web/admin_api_2fa_test.go` (new) — login + enrollment + recovery + redaction. From 1e63bf269b6957b890fe5d52c4e05bcca9ab7965 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Mon, 15 Jun 2026 14:24:02 +0000 Subject: [PATCH 02/14] Add implementation plan for admin login 2FA (TOTP) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-15-admin-totp-2fa.md | 1589 +++++++++++++++++ 1 file changed, 1589 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-admin-totp-2fa.md diff --git a/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md b/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md new file mode 100644 index 0000000..a1b73b4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md @@ -0,0 +1,1589 @@ +# Admin Login 2FA (TOTP) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional TOTP two-factor authentication to the single-admin login, with recovery codes and a CLI escape hatch. + +**Architecture:** TOTP secret + bcrypt-hashed recovery codes live in the existing `settings` key-value table (no schema migration). `pquerna/otp` handles RFC 6238. Login becomes a stateless single request `{password, code?}`: password is verified first, then — only when `admin_totp_enabled == "Y"` — a TOTP or recovery code is required before a session is issued. Enrollment and disable run through new session-protected `/admin/api/auth/2fa/*` endpoints. A `DisableAdmin2FA` CLI command clears the keys for lost-authenticator recovery. + +**Tech Stack:** Go 1.25 (CGo sqlite), `github.com/pquerna/otp`, `golang.org/x/crypto/bcrypt`, React 19 + Vite + shadcn/ui. + +**Spec:** `docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md` + +**Working branch:** `claude/admin-totp-2fa` (already checked out; carries the uncommitted CLAUDE.md SemVer note from brainstorming — it folds into Task 10's commit). + +**Conventions reminder:** +- All Go commands run from `docker/card/`. Tests: `go test -race -count=1 ./web/` (sets `HOST_DOMAIN` via helpers). +- Frontend build (typecheck) from `docker/card/admin-ui/`: + ```bash + export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 + npm run build + ``` +- Settings access: `db.Db_get_setting(conn, key)` / `db.Db_set_setting(conn, key, value)`. Reads use `app.db_read`, writes use `app.db_write`. + +--- + +## File Structure + +**Backend (create):** +- `docker/card/web/totp.go` — pure TOTP/recovery-code helpers (no DB). +- `docker/card/web/totp_test.go` — unit tests for the helpers. +- `docker/card/web/admin_api_2fa.go` — settings helpers (DB) + the four `/admin/api/auth/2fa/*` handlers. +- `docker/card/web/admin_api_2fa_test.go` — handler + login-flow tests. + +**Backend (modify):** +- `docker/card/web/admin_api.go` — route the four new endpoints; add TOTP enforcement to `adminApiLogin`. +- `docker/card/web/admin_api_settings.go` — add `_secret` to the redaction suffix list. +- `docker/card/cli.go` — `DisableAdmin2FA` command. +- `docker/card/go.mod` / `go.sum` — add `github.com/pquerna/otp`. +- `docker/card/build/build.go` — version bump. + +**Frontend (modify):** +- `docker/card/admin-ui/src/hooks/use-auth.tsx` — `login(password, code?)` + `TotpRequiredError`. +- `docker/card/admin-ui/src/pages/login.tsx` — conditional code field + recovery toggle. +- `docker/card/admin-ui/src/pages/settings.tsx` — render the new 2FA card. + +**Frontend (create):** +- `docker/card/admin-ui/src/components/two-factor-card.tsx` — enable/disable/enrollment UI. + +**Docs (modify):** +- `CLAUDE.md` — settings keys, CLI command (SemVer note already edited, uncommitted). + +--- + +## Task 1: Add `pquerna/otp` + TOTP helper module + +**Files:** +- Create: `docker/card/web/totp.go` +- Create: `docker/card/web/totp_test.go` +- Modify: `docker/card/go.mod`, `docker/card/go.sum` + +- [ ] **Step 1: Add the dependency** + +From `docker/card/`: +```bash +go get github.com/pquerna/otp@v1.4.0 +go mod tidy +``` +Expected: `go.mod` gains `github.com/pquerna/otp v1.4.0` and `go.sum` updates (also pulls indirect `github.com/boombuler/barcode`). + +- [ ] **Step 2: Write the failing test** + +Create `docker/card/web/totp_test.go`: +```go +package web + +import ( + "strings" + "testing" + "time" + + "github.com/pquerna/otp/totp" +) + +func TestNewTotpKey_ProducesValidatableSecret(t *testing.T) { + secret, url, err := newTotpKey("hub.example.com") + if err != nil { + t.Fatal(err) + } + if secret == "" { + t.Fatal("expected non-empty secret") + } + if !strings.HasPrefix(url, "otpauth://totp/") { + t.Fatalf("expected otpauth URI, got %q", url) + } + + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + if !validateTotpCode(secret, code) { + t.Fatal("freshly generated code should validate") + } + if validateTotpCode(secret, "000000") { + t.Fatal("an arbitrary wrong code should not validate") + } +} + +func TestGenerateRecoveryCodes_HashesMatch(t *testing.T) { + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + t.Fatal(err) + } + if len(plain) != 10 || len(hashes) != 10 { + t.Fatalf("expected 10 codes and 10 hashes, got %d and %d", len(plain), len(hashes)) + } + for i := range plain { + if !CheckPassword(plain[i], hashes[i]) { + t.Fatalf("hash %d does not verify against its plaintext", i) + } + } + // codes must be unique + seen := map[string]bool{} + for _, c := range plain { + if seen[c] { + t.Fatalf("duplicate recovery code %q", c) + } + seen[c] = true + } +} + +func TestMatchRecoveryCode(t *testing.T) { + plain, hashes, err := generateRecoveryCodes(3) + if err != nil { + t.Fatal(err) + } + idx, ok := matchRecoveryCode(plain[1], hashes) + if !ok || idx != 1 { + t.Fatalf("expected match at index 1, got idx=%d ok=%v", idx, ok) + } + if _, ok := matchRecoveryCode("nope-not-a-code", hashes); ok { + t.Fatal("a non-code should not match") + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `go test ./web/ -run 'Totp|RecoveryCode' -v` +Expected: compile failure — `undefined: newTotpKey`, `validateTotpCode`, `generateRecoveryCodes`, `matchRecoveryCode`. + +- [ ] **Step 4: Implement the helpers** + +Create `docker/card/web/totp.go`: +```go +package web + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/pquerna/otp/totp" +) + +// newTotpKey generates a fresh TOTP secret for the admin. accountName is the +// label shown in the authenticator app (we pass host_domain). Returns the +// base32 secret and the otpauth:// provisioning URI. +func newTotpKey(accountName string) (secret string, url string, err error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Bolt Card Hub", + AccountName: accountName, + }) + if err != nil { + return "", "", err + } + return key.Secret(), key.URL(), nil +} + +// validateTotpCode checks a 6-digit code against the secret. totp.Validate +// already applies a ±1 time-step skew to tolerate clock drift. +func validateTotpCode(secret, code string) bool { + return totp.Validate(code, secret) +} + +// generateRecoveryCodes returns `count` single-use recovery codes (8 hex chars +// each, 32 bits of entropy) plus their bcrypt hashes. Only the hashes are +// persisted; the plaintext is shown to the admin exactly once. +func generateRecoveryCodes(count int) (plain []string, hashes []string, err error) { + for i := 0; i < count; i++ { + b := make([]byte, 4) + if _, err = rand.Read(b); err != nil { + return nil, nil, err + } + code := hex.EncodeToString(b) + h, herr := HashPassword(code) + if herr != nil { + return nil, nil, herr + } + plain = append(plain, code) + hashes = append(hashes, h) + } + return plain, hashes, nil +} + +// matchRecoveryCode returns the index of the first hash that verifies against +// code, or ok=false if none match. +func matchRecoveryCode(code string, hashes []string) (int, bool) { + for i, h := range hashes { + if CheckPassword(code, h) { + return i, true + } + } + return -1, false +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./web/ -run 'Totp|RecoveryCode' -v` +Expected: PASS for all three tests. + +- [ ] **Step 6: Commit** + +```bash +git add docker/card/web/totp.go docker/card/web/totp_test.go docker/card/go.mod docker/card/go.sum +git commit -m "Add TOTP and recovery-code helpers" +``` + +--- + +## Task 2: Redact the TOTP secret in the settings list + +**Files:** +- Modify: `docker/card/web/admin_api_settings.go:24-26` +- Test: `docker/card/web/admin_api_2fa_test.go` (new file; first test added here) + +- [ ] **Step 1: Write the failing test** + +Create `docker/card/web/admin_api_2fa_test.go`. Start with only the imports this first test needs; Tasks 4 and 6 add `"time"` and `"github.com/pquerna/otp/totp"` when their tests first use them (Go errors on unused imports, so don't add them early): +```go +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "card/db" +) + +func TestAdminApiSettings_RedactsTotpSecret(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + db.Db_set_setting(app.db_write, "admin_totp_secret", "SUPERSECRETBASE32") + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/settings", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "SUPERSECRETBASE32") { + t.Fatal("settings response leaked the raw TOTP secret") + } + + var resp struct { + Settings []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"settings"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + found := false + for _, s := range resp.Settings { + if s.Name == "admin_totp_secret" { + found = true + if s.Value != "REDACTED" { + t.Fatalf("expected REDACTED, got %q", s.Value) + } + } + } + if !found { + t.Fatal("admin_totp_secret not present in settings list") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./web/ -run TestAdminApiSettings_RedactsTotpSecret -v` +Expected: FAIL — value is the raw secret, not `REDACTED`. + +- [ ] **Step 3: Add the `_secret` suffix to the redaction guard** + +In `docker/card/web/admin_api_settings.go`, change: +```go + if strings.HasSuffix(s.Name, "_hash") || + strings.HasSuffix(s.Name, "_token") || + strings.HasSuffix(s.Name, "_code") { + value = "REDACTED" +``` +to: +```go + if strings.HasSuffix(s.Name, "_hash") || + strings.HasSuffix(s.Name, "_token") || + strings.HasSuffix(s.Name, "_code") || + strings.HasSuffix(s.Name, "_secret") { + value = "REDACTED" +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./web/ -run TestAdminApiSettings_RedactsTotpSecret -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add docker/card/web/admin_api_settings.go docker/card/web/admin_api_2fa_test.go +git commit -m "Redact admin_totp_secret in settings list" +``` + +--- + +## Task 3: 2FA settings helpers + status endpoint + +**Files:** +- Create: `docker/card/web/admin_api_2fa.go` +- Modify: `docker/card/web/admin_api.go` (route) +- Test: `docker/card/web/admin_api_2fa_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `docker/card/web/admin_api_2fa_test.go`: +```go +func TestAdminApi2faStatus_DisabledByDefault(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Enabled bool `json:"enabled"` + RecoveryCodesRemaining int `json:"recoveryCodesRemaining"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Enabled { + t.Fatal("expected enabled=false by default") + } + if resp.RecoveryCodesRemaining != 0 { + t.Fatalf("expected 0 remaining, got %d", resp.RecoveryCodesRemaining) + } +} + +func TestAdminApi2faStatus_RequiresSession(t *testing.T) { + app := openTestApp(t) + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without session, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./web/ -run TestAdminApi2faStatus -v` +Expected: FAIL — route returns 404 (`adminApi2faStatus` not wired / not defined). + +- [ ] **Step 3: Create the handler file with helpers + status handler** + +Create `docker/card/web/admin_api_2fa.go` (the `card/util` import is added in Task 4 when the setup handler first needs it — adding it now would be an unused-import error): +```go +package web + +import ( + "card/db" + "encoding/json" + "net/http" + + log "github.com/sirupsen/logrus" +) + +// totpEnabled reports whether admin TOTP 2FA is currently active. +func (app *App) totpEnabled() bool { + return db.Db_get_setting(app.db_read, "admin_totp_enabled") == "Y" +} + +// loadRecoveryHashes returns the stored bcrypt hashes of unused recovery codes. +func (app *App) loadRecoveryHashes() []string { + raw := db.Db_get_setting(app.db_read, "admin_totp_recovery_hash") + if raw == "" { + return nil + } + var hashes []string + if err := json.Unmarshal([]byte(raw), &hashes); err != nil { + log.Error("recovery hash unmarshal error: ", err) + return nil + } + return hashes +} + +// saveRecoveryHashes persists the recovery-code hashes as a JSON array. +func (app *App) saveRecoveryHashes(hashes []string) { + b, err := json.Marshal(hashes) + if err != nil { + log.Error("recovery hash marshal error: ", err) + return + } + db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", string(b)) +} + +// consumeRecoveryCode returns true if code matches an unused recovery code, +// removing it (single use) before returning. +func (app *App) consumeRecoveryCode(code string) bool { + hashes := app.loadRecoveryHashes() + idx, ok := matchRecoveryCode(code, hashes) + if !ok { + return false + } + hashes = append(hashes[:idx], hashes[idx+1:]...) + app.saveRecoveryHashes(hashes) + return true +} + +func (app *App) adminApi2faStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]interface{}{ + "enabled": app.totpEnabled(), + "recoveryCodesRemaining": len(app.loadRecoveryHashes()), + }) +} +``` + +- [ ] **Step 4: Wire the route** + +In `docker/card/web/admin_api.go`, inside the `CreateHandler_AdminApi` switch, add after the existing auth cases (e.g. just after the `auth/logout` case): +```go + case path == "/admin/api/auth/2fa/status" && r.Method == "GET": + app.adminApiAuth(app.adminApi2faStatus)(w, r) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./web/ -run TestAdminApi2faStatus -v` +Expected: PASS for both status tests. + +- [ ] **Step 6: Commit** + +```bash +git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go +git commit -m "Add 2FA status endpoint and settings helpers" +``` + +--- + +## Task 4: 2FA setup + enable endpoints + +**Files:** +- Modify: `docker/card/web/admin_api_2fa.go` +- Modify: `docker/card/web/admin_api.go` (routes) +- Test: `docker/card/web/admin_api_2fa_test.go` + +- [ ] **Step 1: Write the failing test** + +First add the two imports this test introduces to the import block of `docker/card/web/admin_api_2fa_test.go`: +```go + "time" + + "github.com/pquerna/otp/totp" +``` +(`"time"` goes in the stdlib group; the `totp` import goes in its own group after `"card/db"`.) + +Then append to `docker/card/web/admin_api_2fa_test.go`: +```go +func TestAdminApi2faSetupThenEnable(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + handler := app.CreateHandler_AdminApi() + cookie := &http.Cookie{Name: "admin_session_token", Value: token} + + // setup → returns a secret + QR, leaves 2FA disabled + rs := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) + rs.AddCookie(cookie) + ws := httptest.NewRecorder() + handler.ServeHTTP(ws, rs) + if ws.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", ws.Code, ws.Body.String()) + } + var setup struct { + Secret string `json:"secret"` + OtpauthUri string `json:"otpauthUri"` + QrPng string `json:"qrPng"` + } + if err := json.Unmarshal(ws.Body.Bytes(), &setup); err != nil { + t.Fatal(err) + } + if setup.Secret == "" || setup.QrPng == "" { + t.Fatal("expected non-empty secret and qrPng") + } + if app.totpEnabled() { + t.Fatal("2FA must not be enabled until a code is confirmed") + } + + // enable with a wrong code → 400 (session is valid; the code is not) + rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", + strings.NewReader(`{"code":"000000"}`)) + rbad.AddCookie(cookie) + wbad := httptest.NewRecorder() + handler.ServeHTTP(wbad, rbad) + if wbad.Code != http.StatusBadRequest { + t.Fatalf("enable(bad code): expected 400, got %d", wbad.Code) + } + if app.totpEnabled() { + t.Fatal("2FA must stay disabled after a wrong code") + } + + // enable with a valid code → 200, returns recovery codes, 2FA active + code, _ := totp.GenerateCode(setup.Secret, time.Now()) + rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", + strings.NewReader(`{"code":"`+code+`"}`)) + rok.AddCookie(cookie) + wok := httptest.NewRecorder() + handler.ServeHTTP(wok, rok) + if wok.Code != http.StatusOK { + t.Fatalf("enable(valid): expected 200, got %d: %s", wok.Code, wok.Body.String()) + } + var en struct { + RecoveryCodes []string `json:"recoveryCodes"` + } + if err := json.Unmarshal(wok.Body.Bytes(), &en); err != nil { + t.Fatal(err) + } + if len(en.RecoveryCodes) != 10 { + t.Fatalf("expected 10 recovery codes, got %d", len(en.RecoveryCodes)) + } + if !app.totpEnabled() { + t.Fatal("2FA should be enabled after a valid code") + } +} + +func TestAdminApi2faSetup_RejectedWhenAlreadyEnabled(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when already enabled, got %d", w.Code) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./web/ -run TestAdminApi2faSetup -v` +Expected: FAIL — routes 404 (`adminApi2faSetup`/`adminApi2faEnable` undefined). + +- [ ] **Step 3: Implement setup + enable handlers** + +First add `"card/util"` to the import block of `docker/card/web/admin_api_2fa.go` (the setup handler uses `util.QrPngBase64Encode`). Then append: +```go +func (app *App) adminApi2faSetup(w http.ResponseWriter, r *http.Request) { + if app.totpEnabled() { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "2fa already enabled"}) + return + } + + hostDomain := db.Db_get_setting(app.db_read, "host_domain") + secret, url, err := newTotpKey(hostDomain) + if err != nil { + log.Error("totp generate error: ", err) + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]string{"error": "internal error"}) + return + } + + // Store the pending secret; it is inert until enable sets enabled=Y. + db.Db_set_setting(app.db_write, "admin_totp_secret", secret) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") + + writeJSON(w, map[string]string{ + "secret": secret, + "otpauthUri": url, + "qrPng": util.QrPngBase64Encode(url), + }) +} + +func (app *App) adminApi2faEnable(w http.ResponseWriter, r *http.Request) { + if app.totpEnabled() { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "2fa already enabled"}) + return + } + + var req struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid request body"}) + return + } + + secret := db.Db_get_setting(app.db_read, "admin_totp_secret") + if secret == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "no pending 2fa setup"}) + return + } + if !validateTotpCode(secret, req.Code) { + // session is valid; only the submitted code is wrong → 400, not 401, + // so the shared apiFetch does not mislabel it as "session expired". + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid code"}) + return + } + + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + log.Error("recovery code generation error: ", err) + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]string{"error": "internal error"}) + return + } + app.saveRecoveryHashes(hashes) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + writeJSON(w, map[string]interface{}{"recoveryCodes": plain}) +} +``` + +- [ ] **Step 4: Wire the routes** + +In `docker/card/web/admin_api.go`, after the `2fa/status` case add: +```go + case path == "/admin/api/auth/2fa/setup" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faSetup)(w, r) + + case path == "/admin/api/auth/2fa/enable" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faEnable)(w, r) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./web/ -run TestAdminApi2faSetup -v` +Expected: PASS for both setup/enable tests. + +- [ ] **Step 6: Commit** + +```bash +git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go +git commit -m "Add 2FA setup and enable endpoints" +``` + +--- + +## Task 5: 2FA disable endpoint + +**Files:** +- Modify: `docker/card/web/admin_api_2fa.go` +- Modify: `docker/card/web/admin_api.go` (route) +- Test: `docker/card/web/admin_api_2fa_test.go` + +- [ ] **Step 1: Write the failing test** + +Append to `docker/card/web/admin_api_2fa_test.go`: +```go +func TestAdminApi2faDisable(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) // sets admin_password_hash for "testpass" + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + db.Db_set_setting(app.db_write, "admin_totp_secret", "SOMESECRET") + app.saveRecoveryHashes([]string{"h1", "h2"}) + + handler := app.CreateHandler_AdminApi() + cookie := &http.Cookie{Name: "admin_session_token", Value: token} + + // wrong password → 400, 2FA stays on + rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", + strings.NewReader(`{"password":"wrong"}`)) + rbad.AddCookie(cookie) + wbad := httptest.NewRecorder() + handler.ServeHTTP(wbad, rbad) + if wbad.Code != http.StatusBadRequest { + t.Fatalf("disable(wrong pw): expected 400, got %d", wbad.Code) + } + if !app.totpEnabled() { + t.Fatal("2FA should remain enabled after a wrong password") + } + + // correct password → 200, all keys cleared + rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", + strings.NewReader(`{"password":"testpass"}`)) + rok.AddCookie(cookie) + wok := httptest.NewRecorder() + handler.ServeHTTP(wok, rok) + if wok.Code != http.StatusOK { + t.Fatalf("disable(correct pw): expected 200, got %d: %s", wok.Code, wok.Body.String()) + } + if app.totpEnabled() { + t.Fatal("2FA should be disabled") + } + if db.Db_get_setting(app.db_read, "admin_totp_secret") != "" { + t.Fatal("secret should be cleared") + } + if len(app.loadRecoveryHashes()) != 0 { + t.Fatal("recovery hashes should be cleared") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./web/ -run TestAdminApi2faDisable -v` +Expected: FAIL — route 404 (`adminApi2faDisable` undefined). + +- [ ] **Step 3: Implement the disable handler** + +Append to `docker/card/web/admin_api_2fa.go`: +```go +func (app *App) adminApi2faDisable(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid request body"}) + return + } + if !app.verifyAdminPassword(req.Password) { + // in-session re-auth failure → 400 (see enable handler rationale) + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid password"}) + return + } + + db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") + db.Db_set_setting(app.db_write, "admin_totp_secret", "") + db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", "") + + writeJSON(w, map[string]bool{"ok": true}) +} +``` + +- [ ] **Step 4: Wire the route** + +In `docker/card/web/admin_api.go`, after the `2fa/enable` case add: +```go + case path == "/admin/api/auth/2fa/disable" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faDisable)(w, r) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test ./web/ -run TestAdminApi2faDisable -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go +git commit -m "Add 2FA disable endpoint" +``` + +--- + +## Task 6: Enforce TOTP at login + +**Files:** +- Modify: `docker/card/web/admin_api.go` (`adminApiLogin`) +- Test: `docker/card/web/admin_api_2fa_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `docker/card/web/admin_api_2fa_test.go`. This helper enables 2FA for an app and returns the secret: +```go +func enable2faForTest(t *testing.T, app *App) (secret string, recovery []string) { + t.Helper() + hash, _ := HashPassword("testpass") + db.Db_set_setting(app.db_write, "admin_password_hash", hash) + + s, _, err := newTotpKey("hub.example.com") + if err != nil { + t.Fatal(err) + } + db.Db_set_setting(app.db_write, "admin_totp_secret", s) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + t.Fatal(err) + } + app.saveRecoveryHashes(hashes) + return s, plain +} + +func postLogin(app *App, body string) *httptest.ResponseRecorder { + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("POST", "/admin/api/auth/login", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w +} + +func TestAdminLogin_2faRequired_PasswordOnly(t *testing.T) { + app := openTestApp(t) + enable2faForTest(t, app) + + w := postLogin(app, `{"password":"testpass"}`) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + TotpRequired bool `json:"totpRequired"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.TotpRequired { + t.Fatal("expected totpRequired=true") + } + // no session token should be issued + if db.Db_get_setting(app.db_read, "admin_session_token") != "" { + t.Fatal("no session should be issued without a code") + } +} + +func TestAdminLogin_2fa_ValidCode(t *testing.T) { + app := openTestApp(t) + secret, _ := enable2faForTest(t, app) + code, _ := totp.GenerateCode(secret, time.Now()) + + w := postLogin(app, `{"password":"testpass","code":"`+code+`"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if db.Db_get_setting(app.db_read, "admin_session_token") == "" { + t.Fatal("expected a session token to be issued") + } +} + +func TestAdminLogin_2fa_InvalidCode(t *testing.T) { + app := openTestApp(t) + enable2faForTest(t, app) + + w := postLogin(app, `{"password":"testpass","code":"000000"}`) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestAdminLogin_2fa_RecoveryCodeConsumedOnce(t *testing.T) { + app := openTestApp(t) + _, recovery := enable2faForTest(t, app) + + // first use → success + w1 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) + if w1.Code != http.StatusOK { + t.Fatalf("recovery first use: expected 200, got %d: %s", w1.Code, w1.Body.String()) + } + if len(app.loadRecoveryHashes()) != 9 { + t.Fatalf("expected 9 codes remaining, got %d", len(app.loadRecoveryHashes())) + } + + // reuse of the same code → 401 + w2 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) + if w2.Code != http.StatusUnauthorized { + t.Fatalf("recovery reuse: expected 401, got %d", w2.Code) + } +} + +func TestAdminLogin_NoTotp_PasswordOnlyStillWorks(t *testing.T) { + app := openTestApp(t) + hash, _ := HashPassword("testpass") + db.Db_set_setting(app.db_write, "admin_password_hash", hash) + + w := postLogin(app, `{"password":"testpass"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 with 2FA off, got %d: %s", w.Code, w.Body.String()) + } +} +``` + +(Wrong-password behavior with 2FA on is already covered by the existing `invalid password` login tests, so no separate test is added here.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./web/ -run 'TestAdminLogin_2fa|TestAdminLogin_NoTotp' -v` +Expected: FAIL — `TestAdminLogin_2faRequired_PasswordOnly` currently returns 200 and issues a session (no enforcement yet). + +- [ ] **Step 3: Add TOTP enforcement to `adminApiLogin`** + +In `docker/card/web/admin_api.go`, update the request struct and add the enforcement block. Change: +```go + var req struct { + Password string `json:"password"` + } +``` +to: +```go + var req struct { + Password string `json:"password"` + Code string `json:"code"` + } +``` +Then, immediately **after** the `verifyAdminPassword` failure check (after its closing `}`) and **before** `sessionToken := util.Random_hex()`, insert: +```go + if app.totpEnabled() { + if req.Code == "" { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, map[string]interface{}{ + "error": "2fa code required", + "totpRequired": true, + }) + return + } + secret := db.Db_get_setting(app.db_read, "admin_totp_secret") + if !validateTotpCode(secret, req.Code) && !app.consumeRecoveryCode(req.Code) { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, map[string]interface{}{ + "error": "invalid code", + "totpRequired": true, + }) + return + } + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./web/ -run 'TestAdminLogin_2fa|TestAdminLogin_NoTotp' -v` +Expected: PASS for all. + +- [ ] **Step 5: Run the full web package to check for regressions** + +Run: `go test -race -count=1 ./web/` +Expected: PASS (all existing + new tests). + +- [ ] **Step 6: Commit** + +```bash +git add docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go +git commit -m "Require TOTP or recovery code at admin login when 2FA enabled" +``` + +--- + +## Task 7: `DisableAdmin2FA` CLI command + +**Files:** +- Modify: `docker/card/cli.go` + +- [ ] **Step 1: Add the command case** + +In `docker/card/cli.go`, inside the `switch args[0]` in `processArgs`, add before `default:`: +```go + case "DisableAdmin2FA": + disableAdmin2FA(db_conn) +``` + +- [ ] **Step 2: Add the function** + +Append to `docker/card/cli.go`: +```go +// DisableAdmin2FA clears admin TOTP 2FA. Recovery path for a lost +// authenticator: run via `docker exec -it card ./app DisableAdmin2FA`. +func disableAdmin2FA(db_conn *sql.DB) { + db.Db_set_setting(db_conn, "admin_totp_enabled", "N") + db.Db_set_setting(db_conn, "admin_totp_secret", "") + db.Db_set_setting(db_conn, "admin_totp_recovery_hash", "") + log.Info("admin 2FA disabled") +} +``` + +- [ ] **Step 3: Verify it builds and vets** + +Run: `go build ./... && go vet ./...` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add docker/card/cli.go +git commit -m "Add DisableAdmin2FA CLI command for lost-authenticator recovery" +``` + +--- + +## Task 8: Frontend — `login()` with TOTP support + +**Files:** +- Modify: `docker/card/admin-ui/src/hooks/use-auth.tsx` + +The shared `apiFetch` throws `AuthError` on any 401 and discards the body, so `login()` must do its own fetch to read `totpRequired` and the precise error message. + +- [ ] **Step 1: Add `TotpRequiredError` and update `login`** + +In `docker/card/admin-ui/src/hooks/use-auth.tsx`: + +Add near the top (after imports): +```tsx +export class TotpRequiredError extends Error { + constructor() { + super("2fa required"); + this.name = "TotpRequiredError"; + } +} +``` + +Change the context type: +```tsx + login: (password: string) => Promise; +``` +to: +```tsx + login: (password: string, code?: string) => Promise; +``` + +Replace the `login` implementation: +```tsx + const login = async (password: string) => { + await apiPost("/auth/login", { password }); + await refresh(); + }; +``` +with: +```tsx + const login = async (password: string, code?: string) => { + const res = await fetch("/admin/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password, code }), + }); + if (res.ok) { + await refresh(); + return; + } + const body = await res.json().catch(() => ({}) as { error?: string; totpRequired?: boolean }); + if (body.totpRequired) { + throw new TotpRequiredError(); + } + throw new Error(body.error || `HTTP ${res.status}`); + }; +``` + +(`code` undefined is dropped by `JSON.stringify`, so the backend sees no code — identical to the password-only case.) + +- [ ] **Step 2: Verify it typechecks** + +Run (from `docker/card/admin-ui/`): +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +npm run build +``` +Expected: build succeeds (no TS errors). + +- [ ] **Step 3: Commit** + +```bash +git add docker/card/admin-ui/src/hooks/use-auth.tsx +git commit -m "Frontend: login() handles TOTP code and totpRequired signal" +``` + +--- + +## Task 9: Frontend — login page code field + +**Files:** +- Modify: `docker/card/admin-ui/src/pages/login.tsx` + +- [ ] **Step 1: Replace the login page** + +Replace the body of `docker/card/admin-ui/src/pages/login.tsx` with: +```tsx +import { useState, type FormEvent } from "react"; +import { useAuth, TotpRequiredError } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Zap } from "lucide-react"; + +export function LoginPage() { + const { login } = useAuth(); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [totpRequired, setTotpRequired] = useState(false); + const [useRecovery, setUseRecovery] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + await login(password, totpRequired ? code : undefined); + } catch (err) { + if (err instanceof TotpRequiredError) { + setTotpRequired(true); + setError(""); + } else { + setError(err instanceof Error ? err.message : "Login failed"); + } + } finally { + setLoading(false); + } + } + + return ( +
+ + + + + + + + Bolt Card Hub + + + + +
+ {error && ( + + {error} + + )} +
+ + setPassword(e.target.value)} + required + autoFocus + disabled={totpRequired} + /> +
+ {totpRequired && ( +
+ + setCode(e.target.value.trim())} + required + autoFocus + placeholder={useRecovery ? "8-character code" : "6-digit code"} + /> + +
+ )} + +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run (from `docker/card/admin-ui/`): +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +npm run build +``` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add docker/card/admin-ui/src/pages/login.tsx +git commit -m "Frontend: login page prompts for TOTP / recovery code" +``` + +--- + +## Task 10: Frontend — 2FA management card on Settings + +**Files:** +- Create: `docker/card/admin-ui/src/components/two-factor-card.tsx` +- Modify: `docker/card/admin-ui/src/pages/settings.tsx` + +- [ ] **Step 1: Create the 2FA card component** + +Create `docker/card/admin-ui/src/components/two-factor-card.tsx`: +```tsx +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiFetch, apiPost } from "@/lib/api"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +interface TwoFaStatus { + enabled: boolean; + recoveryCodesRemaining: number; +} + +interface SetupData { + secret: string; + otpauthUri: string; + qrPng: string; +} + +export function TwoFactorCard() { + const queryClient = useQueryClient(); + const { data } = useQuery({ + queryKey: ["2fa-status"], + queryFn: () => apiFetch("/auth/2fa/status"), + }); + + const [setup, setSetup] = useState(null); + const [code, setCode] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [disablePassword, setDisablePassword] = useState(""); + const [showDisable, setShowDisable] = useState(false); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["2fa-status"] }); + + const startSetup = useMutation({ + mutationFn: () => apiPost("/auth/2fa/setup"), + onSuccess: (d) => { + setSetup(d); + setCode(""); + }, + onError: (err) => toast.error(err.message), + }); + + const enable = useMutation({ + mutationFn: () => apiPost<{ recoveryCodes: string[] }>("/auth/2fa/enable", { code }), + onSuccess: (d) => { + setSetup(null); + setRecoveryCodes(d.recoveryCodes); + invalidate(); + toast.success("Two-factor authentication enabled"); + }, + onError: (err) => toast.error(err.message), + }); + + const disable = useMutation({ + mutationFn: () => apiPost("/auth/2fa/disable", { password: disablePassword }), + onSuccess: () => { + setShowDisable(false); + setDisablePassword(""); + invalidate(); + toast.success("Two-factor authentication disabled"); + }, + onError: (err) => toast.error(err.message), + }); + + return ( + + + + Two-Factor Authentication + {data?.enabled ? ( + Enabled + ) : ( + Disabled + )} + + + + {data?.enabled ? ( + <> +

+ Login requires a code from your authenticator app. + {" "}Recovery codes remaining: {data.recoveryCodesRemaining}. +

+ + + ) : ( + <> +

+ Add a second factor (TOTP) to admin login using an authenticator app. +

+ + + )} +
+ + {/* Enrollment dialog: QR + confirm code */} + !o && setSetup(null)}> + + + Scan with your authenticator + + {setup && ( +
+ TOTP QR code +

+ Manual key: {setup.secret} +

+
+ + setCode(e.target.value.trim())} + placeholder="123456" + /> +
+
+ )} + + + +
+
+ + {/* Recovery codes shown once */} + !o && setRecoveryCodes(null)}> + + + Save your recovery codes + +

+ Store these somewhere safe. Each can be used once if you lose your + authenticator. They will not be shown again. +

+
+ {recoveryCodes?.map((c) => ( + {c} + ))} +
+ + + + +
+
+ + {/* Disable confirmation */} + + + + Disable two-factor authentication + +
+ + setDisablePassword(e.target.value)} + /> +
+ + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Render it on the Settings page** + +In `docker/card/admin-ui/src/pages/settings.tsx`: + +Add the import: +```tsx +import { TwoFactorCard } from "@/components/two-factor-card"; +``` + +In the returned JSX, place the card just under the heading, before the settings table. Change: +```tsx +
+

Settings

+ + {data.settings.length === 0 ? ( +``` +to: +```tsx +
+

Settings

+ + + + {data.settings.length === 0 ? ( +``` + +- [ ] **Step 3: Verify it typechecks/builds** + +Run (from `docker/card/admin-ui/`): +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +npm run build +``` +Expected: build succeeds. If `apiPost("/auth/2fa/setup")` with no body trips lint, it's fine — `apiPost` treats body as optional. + +- [ ] **Step 4: Commit** + +```bash +git add docker/card/admin-ui/src/components/two-factor-card.tsx docker/card/admin-ui/src/pages/settings.tsx +git commit -m "Frontend: 2FA management card on settings page" +``` + +--- + +## Task 11: Version bump + docs + +**Files:** +- Modify: `docker/card/build/build.go` +- Modify: `CLAUDE.md` (CLI command list, settings keys; SemVer note already edited and currently uncommitted) + +- [ ] **Step 1: Bump the version (SemVer minor — new feature)** + +In `docker/card/build/build.go` change: +```go +var Version string = "0.19.8" +``` +to: +```go +var Version string = "0.20.0" +``` + +- [ ] **Step 2: Update CLAUDE.md** + +In `CLAUDE.md`: + +(a) Sync the version reference in the `build/` architecture bullet: +``` +- `build/` — Version string (currently "0.19.8"), date/time injected at build +``` +to: +``` +- `build/` — Version string (currently "0.20.0"), date/time injected at build +``` + +(b) Add `DisableAdmin2FA` to the CLI Commands code block: +``` +./app WipeCard +``` +add a line after it: +``` +./app DisableAdmin2FA +``` + +(c) In the **Settings** section's active-settings list, add: +``` +- `admin_totp_enabled`, `admin_totp_secret`, `admin_totp_recovery_hash` — optional admin login 2FA (TOTP). `admin_totp_secret` (base32) and `admin_totp_recovery_hash` (JSON array of bcrypt-hashed single-use recovery codes) are redacted in the settings UI; `admin_totp_enabled` ("Y"/"N") gates enforcement at login. Cleared by the `DisableAdmin2FA` CLI command. +``` + +(d) In the **Authentication** section, under the Admin bullet, append: +``` +Optional TOTP 2FA: when `admin_totp_enabled="Y"`, login also requires a 6-digit TOTP code or a single-use recovery code (see `web/admin_api_2fa.go`, `web/totp.go`). +``` + +(e) In the redaction note ("values with `_hash`, `_token`, `_code` suffixes shown as REDACTED"), add `_secret`: +``` +sensitive values (`_hash`, `_token`, `_code`, `_secret` suffixes) redacted +``` + +- [ ] **Step 3: Final full-suite verification** + +Run from `docker/card/`: +```bash +go vet ./... && go build ./... && go test -race -count=1 ./... +``` +Expected: all PASS. + +Run from `docker/card/admin-ui/`: +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +npm run build +``` +Expected: build succeeds. + +- [ ] **Step 4: Commit (folds in the SemVer CLAUDE.md edit from brainstorming)** + +```bash +git add docker/card/build/build.go CLAUDE.md +git commit -m "Bump version to 0.20.0 and document admin 2FA" +``` + +--- + +## Post-implementation + +- [ ] Update the project memory file (`~/.claude/projects/-home-debian-hub/memory/MEMORY.md`) with: the 2FA settings keys, the `DisableAdmin2FA` recovery command, login-only enforcement, and the `pquerna/otp` dependency. (Required by CLAUDE.md "Memory File" convention.) +- [ ] Open the PR per the finishing-a-development-branch flow. Confirm CI is green (vet, build, test, govulncheck, frontend build, Docker builds). + +--- + +## Manual verification checklist (after deploy to test hub) + +These exercise paths unit tests can't (real authenticator app, browser): +1. Settings → Enable 2FA → scan QR with an authenticator → confirm code → recovery codes shown once. +2. Log out → log in: password accepted → prompted for code → correct code logs in. +3. Wrong code rejected; "Use a recovery code instead" accepts a recovery code once; reused recovery code rejected. +4. Settings shows decremented "recovery codes remaining" after a recovery login. +5. `docker exec -it card ./app DisableAdmin2FA` → next login is password-only. +6. Settings list shows `admin_totp_secret` as REDACTED. From cd81627b041aa997c216f211113194edc465fa7c Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:17:11 +0000 Subject: [PATCH 03/14] Add TOTP and recovery-code helpers Co-Authored-By: Claude Sonnet 4.6 --- docker/card/go.mod | 2 + docker/card/go.sum | 5 +++ docker/card/web/totp.go | 59 ++++++++++++++++++++++++++++ docker/card/web/totp_test.go | 76 ++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+) create mode 100644 docker/card/web/totp.go create mode 100644 docker/card/web/totp_test.go diff --git a/docker/card/go.mod b/docker/card/go.mod index fc366b0..b5dfb66 100644 --- a/docker/card/go.mod +++ b/docker/card/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/mattn/go-sqlite3 v1.14.32 github.com/nbd-wtf/ln-decodepay v1.13.0 + github.com/pquerna/otp v1.4.0 github.com/sirupsen/logrus v1.9.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.50.0 @@ -17,6 +18,7 @@ require ( require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/siphash v1.0.1 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/btcsuite/btcd v0.24.3-0.20250318170759-4f4ea81776d6 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect github.com/btcsuite/btcd/btcutil v1.1.6 // indirect diff --git a/docker/card/go.sum b/docker/card/go.sum index 09ea42e..d4fed2e 100644 --- a/docker/card/go.sum +++ b/docker/card/go.sum @@ -14,6 +14,8 @@ github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -285,6 +287,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= @@ -310,6 +314,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= diff --git a/docker/card/web/totp.go b/docker/card/web/totp.go new file mode 100644 index 0000000..0c21b2f --- /dev/null +++ b/docker/card/web/totp.go @@ -0,0 +1,59 @@ +package web + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/pquerna/otp/totp" +) + +// newTotpKey generates a fresh TOTP secret for the admin. accountName is the +// label shown in the authenticator app (we pass host_domain). Returns the +// base32 secret and the otpauth:// provisioning URI. +func newTotpKey(accountName string) (secret string, url string, err error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Bolt Card Hub", + AccountName: accountName, + }) + if err != nil { + return "", "", err + } + return key.Secret(), key.URL(), nil +} + +// validateTotpCode checks a 6-digit code against the secret. totp.Validate +// already applies a ±1 time-step skew to tolerate clock drift. +func validateTotpCode(secret, code string) bool { + return totp.Validate(code, secret) +} + +// generateRecoveryCodes returns `count` single-use recovery codes (16 hex +// chars each, 64 bits of entropy) plus their bcrypt hashes. Only the hashes +// are persisted; the plaintext is shown to the admin exactly once. +func generateRecoveryCodes(count int) (plain []string, hashes []string, err error) { + for range count { + b := make([]byte, 8) + if _, err = rand.Read(b); err != nil { + return nil, nil, err + } + code := hex.EncodeToString(b) + h, herr := HashPassword(code) + if herr != nil { + return nil, nil, herr + } + plain = append(plain, code) + hashes = append(hashes, h) + } + return plain, hashes, nil +} + +// matchRecoveryCode returns the index of the first hash that verifies against +// code, or ok=false if none match. +func matchRecoveryCode(code string, hashes []string) (int, bool) { + for i, h := range hashes { + if CheckPassword(code, h) { + return i, true + } + } + return -1, false +} diff --git a/docker/card/web/totp_test.go b/docker/card/web/totp_test.go new file mode 100644 index 0000000..76420b0 --- /dev/null +++ b/docker/card/web/totp_test.go @@ -0,0 +1,76 @@ +package web + +import ( + "strings" + "testing" + "time" + + "github.com/pquerna/otp/totp" +) + +func TestNewTotpKey_ProducesValidatableSecret(t *testing.T) { + secret, url, err := newTotpKey("hub.example.com") + if err != nil { + t.Fatal(err) + } + if secret == "" { + t.Fatal("expected non-empty secret") + } + if !strings.HasPrefix(url, "otpauth://totp/") { + t.Fatalf("expected otpauth URI, got %q", url) + } + + code, err := totp.GenerateCode(secret, time.Now()) + if err != nil { + t.Fatal(err) + } + if !validateTotpCode(secret, code) { + t.Fatal("freshly generated code should validate") + } + // A code generated for 24h ago must not validate now. Skip the + // astronomically unlikely case where it equals the current code, so the + // test is deterministic rather than flaky. + past, err := totp.GenerateCode(secret, time.Now().Add(-24*time.Hour)) + if err != nil { + t.Fatal(err) + } + if past != code && validateTotpCode(secret, past) { + t.Fatal("a code from 24h ago should not validate now") + } +} + +func TestGenerateRecoveryCodes_HashesMatch(t *testing.T) { + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + t.Fatal(err) + } + if len(plain) != 10 || len(hashes) != 10 { + t.Fatalf("expected 10 codes and 10 hashes, got %d and %d", len(plain), len(hashes)) + } + for i := range plain { + if !CheckPassword(plain[i], hashes[i]) { + t.Fatalf("hash %d does not verify against its plaintext", i) + } + } + seen := map[string]bool{} + for _, c := range plain { + if seen[c] { + t.Fatalf("duplicate recovery code %q", c) + } + seen[c] = true + } +} + +func TestMatchRecoveryCode(t *testing.T) { + plain, hashes, err := generateRecoveryCodes(3) + if err != nil { + t.Fatal(err) + } + idx, ok := matchRecoveryCode(plain[1], hashes) + if !ok || idx != 1 { + t.Fatalf("expected match at index 1, got idx=%d ok=%v", idx, ok) + } + if _, ok := matchRecoveryCode("nope-not-a-code", hashes); ok { + t.Fatal("a non-code should not match") + } +} From 4452e2f5e8b86930880232e49f76eb254d32cad7 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:23:44 +0000 Subject: [PATCH 04/14] Redact admin_totp_secret in settings list --- docker/card/web/admin_api_2fa_test.go | 52 +++++++++++++++++++++++++++ docker/card/web/admin_api_settings.go | 3 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 docker/card/web/admin_api_2fa_test.go diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go new file mode 100644 index 0000000..2e383aa --- /dev/null +++ b/docker/card/web/admin_api_2fa_test.go @@ -0,0 +1,52 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "card/db" +) + +func TestAdminApiSettings_RedactsTotpSecret(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + db.Db_set_setting(app.db_write, "admin_totp_secret", "SUPERSECRETBASE32") + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/settings", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "SUPERSECRETBASE32") { + t.Fatal("settings response leaked the raw TOTP secret") + } + + var resp struct { + Settings []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"settings"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + found := false + for _, s := range resp.Settings { + if s.Name == "admin_totp_secret" { + found = true + if s.Value != "REDACTED" { + t.Fatalf("expected REDACTED, got %q", s.Value) + } + } + } + if !found { + t.Fatal("admin_totp_secret not present in settings list") + } +} diff --git a/docker/card/web/admin_api_settings.go b/docker/card/web/admin_api_settings.go index 122a9ab..1935aaf 100644 --- a/docker/card/web/admin_api_settings.go +++ b/docker/card/web/admin_api_settings.go @@ -23,7 +23,8 @@ func (app *App) adminApiGetSettings(w http.ResponseWriter, r *http.Request) { value := s.Value if strings.HasSuffix(s.Name, "_hash") || strings.HasSuffix(s.Name, "_token") || - strings.HasSuffix(s.Name, "_code") { + strings.HasSuffix(s.Name, "_code") || + strings.HasSuffix(s.Name, "_secret") { value = "REDACTED" } if s.Name == "log_level" { From 5a24b64bf7dc8377b374401940d44600377a3616 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:27:20 +0000 Subject: [PATCH 05/14] Add 2FA status endpoint and settings helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/card/web/admin_api.go | 3 ++ docker/card/web/admin_api_2fa.go | 63 +++++++++++++++++++++++++++ docker/card/web/admin_api_2fa_test.go | 59 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 docker/card/web/admin_api_2fa.go diff --git a/docker/card/web/admin_api.go b/docker/card/web/admin_api.go index 16d8703..5262c73 100644 --- a/docker/card/web/admin_api.go +++ b/docker/card/web/admin_api.go @@ -96,6 +96,9 @@ func (app *App) CreateHandler_AdminApi() http.HandlerFunc { app.adminApiLogout(w, r) // Protected endpoints (session required) + case path == "/admin/api/auth/2fa/status" && r.Method == "GET": + app.adminApiAuth(app.adminApi2faStatus)(w, r) + case path == "/admin/api/dashboard": app.adminApiAuth(app.adminApiDashboard)(w, r) diff --git a/docker/card/web/admin_api_2fa.go b/docker/card/web/admin_api_2fa.go new file mode 100644 index 0000000..5f4b89f --- /dev/null +++ b/docker/card/web/admin_api_2fa.go @@ -0,0 +1,63 @@ +package web + +import ( + "card/db" + "encoding/json" + "net/http" + + log "github.com/sirupsen/logrus" +) + +// totpEnabled reports whether admin TOTP 2FA is currently active. +func (app *App) totpEnabled() bool { + return db.Db_get_setting(app.db_read, "admin_totp_enabled") == "Y" +} + +// loadRecoveryHashes returns the stored bcrypt hashes of unused recovery codes. +func (app *App) loadRecoveryHashes() []string { + raw := db.Db_get_setting(app.db_read, "admin_totp_recovery_hash") + if raw == "" { + return nil + } + var hashes []string + if err := json.Unmarshal([]byte(raw), &hashes); err != nil { + log.Error("recovery hash unmarshal error: ", err) + return nil + } + return hashes +} + +// saveRecoveryHashes persists the recovery-code hashes as a JSON array. +func (app *App) saveRecoveryHashes(hashes []string) { + b, err := json.Marshal(hashes) + if err != nil { + log.Error("recovery hash marshal error: ", err) + return + } + db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", string(b)) +} + +// consumeRecoveryCode returns true if code matches an unused recovery code, +// removing it (single use) before returning. +func (app *App) consumeRecoveryCode(code string) bool { + hashes := app.loadRecoveryHashes() + idx, ok := matchRecoveryCode(code, hashes) + if !ok { + return false + } + hashes = append(hashes[:idx], hashes[idx+1:]...) + app.saveRecoveryHashes(hashes) + return true +} + +func (app *App) adminApi2faStatus(w http.ResponseWriter, r *http.Request) { + enabled := app.totpEnabled() + remaining := 0 + if enabled { + remaining = len(app.loadRecoveryHashes()) + } + writeJSON(w, map[string]interface{}{ + "enabled": enabled, + "recoveryCodesRemaining": remaining, + }) +} diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go index 2e383aa..564c807 100644 --- a/docker/card/web/admin_api_2fa_test.go +++ b/docker/card/web/admin_api_2fa_test.go @@ -50,3 +50,62 @@ func TestAdminApiSettings_RedactsTotpSecret(t *testing.T) { t.Fatal("admin_totp_secret not present in settings list") } } + +func TestAdminApi2faStatus_DisabledByDefault(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Enabled bool `json:"enabled"` + RecoveryCodesRemaining int `json:"recoveryCodesRemaining"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Enabled { + t.Fatal("expected enabled=false by default") + } + if resp.RecoveryCodesRemaining != 0 { + t.Fatalf("expected 0 remaining, got %d", resp.RecoveryCodesRemaining) + } +} + +func TestAdminApi2faStatus_RequiresSession(t *testing.T) { + app := openTestApp(t) + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 without session, got %d", w.Code) + } +} + +func TestAdminApi2faStatus_CorruptRecoveryHash(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + // A corrupt/un-parseable value must not break the endpoint or report codes. + db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", "not-valid-json") + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), `"recoveryCodesRemaining":0`) == false { + t.Fatalf("expected recoveryCodesRemaining=0 on corrupt input, got %s", w.Body.String()) + } +} From 079ed4f2d9de2c924d7ebc314f34677669fb5f82 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:35:12 +0000 Subject: [PATCH 06/14] Add 2FA setup and enable endpoints Co-Authored-By: Claude Sonnet 4.6 --- Caddyfile | 2 +- docker/card/web/admin_api.go | 6 ++ docker/card/web/admin_api_2fa.go | 74 ++++++++++++++++++++ docker/card/web/admin_api_2fa_test.go | 98 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) diff --git a/Caddyfile b/Caddyfile index f8a0060..f8aeac8 100644 --- a/Caddyfile +++ b/Caddyfile @@ -20,7 +20,7 @@ https://{$HOST_DOMAIN} { } @auth_paths { - path /admin/login/ /admin/api/auth/login /auth + path /admin/login/ /admin/api/auth/login /admin/api/auth/2fa/enable /auth } handle @auth_paths { rate_limit { diff --git a/docker/card/web/admin_api.go b/docker/card/web/admin_api.go index 5262c73..719841b 100644 --- a/docker/card/web/admin_api.go +++ b/docker/card/web/admin_api.go @@ -99,6 +99,12 @@ func (app *App) CreateHandler_AdminApi() http.HandlerFunc { case path == "/admin/api/auth/2fa/status" && r.Method == "GET": app.adminApiAuth(app.adminApi2faStatus)(w, r) + case path == "/admin/api/auth/2fa/setup" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faSetup)(w, r) + + case path == "/admin/api/auth/2fa/enable" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faEnable)(w, r) + case path == "/admin/api/dashboard": app.adminApiAuth(app.adminApiDashboard)(w, r) diff --git a/docker/card/web/admin_api_2fa.go b/docker/card/web/admin_api_2fa.go index 5f4b89f..c74ec9c 100644 --- a/docker/card/web/admin_api_2fa.go +++ b/docker/card/web/admin_api_2fa.go @@ -2,6 +2,7 @@ package web import ( "card/db" + "card/util" "encoding/json" "net/http" @@ -61,3 +62,76 @@ func (app *App) adminApi2faStatus(w http.ResponseWriter, r *http.Request) { "recoveryCodesRemaining": remaining, }) } + +func (app *App) adminApi2faSetup(w http.ResponseWriter, r *http.Request) { + if app.totpEnabled() { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "2fa already enabled"}) + return + } + + hostDomain := db.Db_get_setting(app.db_read, "host_domain") + secret, url, err := newTotpKey(hostDomain) + if err != nil { + log.Error("totp generate error: ", err) + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]string{"error": "internal error"}) + return + } + + // Store the pending secret; it is inert until enable sets enabled=Y. + if db.Db_get_setting(app.db_read, "admin_totp_secret") != "" { + log.Info("replacing pending (unenabled) TOTP secret") + } + db.Db_set_setting(app.db_write, "admin_totp_secret", secret) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") + + writeJSON(w, map[string]string{ + "secret": secret, + "otpauthUri": url, + "qrPng": util.QrPngBase64Encode(url), + }) +} + +func (app *App) adminApi2faEnable(w http.ResponseWriter, r *http.Request) { + if app.totpEnabled() { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "2fa already enabled"}) + return + } + + var req struct { + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid request body"}) + return + } + + secret := db.Db_get_setting(app.db_read, "admin_totp_secret") + if secret == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "no pending 2fa setup"}) + return + } + if !validateTotpCode(secret, req.Code) { + // session is valid; only the submitted code is wrong → 400, not 401, + // so the shared apiFetch does not mislabel it as "session expired". + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid code"}) + return + } + + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + log.Error("recovery code generation error: ", err) + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]string{"error": "internal error"}) + return + } + app.saveRecoveryHashes(hashes) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + writeJSON(w, map[string]interface{}{"recoveryCodes": plain}) +} diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go index 564c807..e884dab 100644 --- a/docker/card/web/admin_api_2fa_test.go +++ b/docker/card/web/admin_api_2fa_test.go @@ -6,8 +6,11 @@ import ( "net/http/httptest" "strings" "testing" + "time" "card/db" + + "github.com/pquerna/otp/totp" ) func TestAdminApiSettings_RedactsTotpSecret(t *testing.T) { @@ -109,3 +112,98 @@ func TestAdminApi2faStatus_CorruptRecoveryHash(t *testing.T) { t.Fatalf("expected recoveryCodesRemaining=0 on corrupt input, got %s", w.Body.String()) } } + +func TestAdminApi2faSetupThenEnable(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + handler := app.CreateHandler_AdminApi() + cookie := &http.Cookie{Name: "admin_session_token", Value: token} + + // setup → returns a secret + QR, leaves 2FA disabled + rs := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) + rs.AddCookie(cookie) + ws := httptest.NewRecorder() + handler.ServeHTTP(ws, rs) + if ws.Code != http.StatusOK { + t.Fatalf("setup: expected 200, got %d: %s", ws.Code, ws.Body.String()) + } + var setup struct { + Secret string `json:"secret"` + OtpauthUri string `json:"otpauthUri"` + QrPng string `json:"qrPng"` + } + if err := json.Unmarshal(ws.Body.Bytes(), &setup); err != nil { + t.Fatal(err) + } + if setup.Secret == "" || setup.QrPng == "" { + t.Fatal("expected non-empty secret and qrPng") + } + if app.totpEnabled() { + t.Fatal("2FA must not be enabled until a code is confirmed") + } + + // enable with a wrong code → 400 (session is valid; the code is not) + rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", + strings.NewReader(`{"code":"000000"}`)) + rbad.AddCookie(cookie) + wbad := httptest.NewRecorder() + handler.ServeHTTP(wbad, rbad) + if wbad.Code != http.StatusBadRequest { + t.Fatalf("enable(bad code): expected 400, got %d", wbad.Code) + } + if app.totpEnabled() { + t.Fatal("2FA must stay disabled after a wrong code") + } + + // enable with a valid code → 200, returns recovery codes, 2FA active + code, _ := totp.GenerateCode(setup.Secret, time.Now()) + rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", + strings.NewReader(`{"code":"`+code+`"}`)) + rok.AddCookie(cookie) + wok := httptest.NewRecorder() + handler.ServeHTTP(wok, rok) + if wok.Code != http.StatusOK { + t.Fatalf("enable(valid): expected 200, got %d: %s", wok.Code, wok.Body.String()) + } + var en struct { + RecoveryCodes []string `json:"recoveryCodes"` + } + if err := json.Unmarshal(wok.Body.Bytes(), &en); err != nil { + t.Fatal(err) + } + if len(en.RecoveryCodes) != 10 { + t.Fatalf("expected 10 recovery codes, got %d", len(en.RecoveryCodes)) + } + if !app.totpEnabled() { + t.Fatal("2FA should be enabled after a valid code") + } +} + +func TestAdminApi2faSetup_RejectedWhenAlreadyEnabled(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when already enabled, got %d", w.Code) + } +} + +func TestAdminApi2faEnable_WithoutSetup(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", + strings.NewReader(`{"code":"123456"}`)) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 without prior setup, got %d", w.Code) + } +} From e485dfbe23583e8b4fbf86bacf96f42b7129c9e9 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:45:39 +0000 Subject: [PATCH 07/14] Add 2FA disable endpoint Co-Authored-By: Claude Opus 4.8 (1M context) --- Caddyfile | 2 +- docker/card/web/admin_api.go | 3 ++ docker/card/web/admin_api_2fa.go | 23 ++++++++++++++ docker/card/web/admin_api_2fa_test.go | 43 +++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/Caddyfile b/Caddyfile index f8aeac8..5d20b88 100644 --- a/Caddyfile +++ b/Caddyfile @@ -20,7 +20,7 @@ https://{$HOST_DOMAIN} { } @auth_paths { - path /admin/login/ /admin/api/auth/login /admin/api/auth/2fa/enable /auth + path /admin/login/ /admin/api/auth/login /admin/api/auth/2fa/enable /admin/api/auth/2fa/disable /auth } handle @auth_paths { rate_limit { diff --git a/docker/card/web/admin_api.go b/docker/card/web/admin_api.go index 719841b..c97a915 100644 --- a/docker/card/web/admin_api.go +++ b/docker/card/web/admin_api.go @@ -105,6 +105,9 @@ func (app *App) CreateHandler_AdminApi() http.HandlerFunc { case path == "/admin/api/auth/2fa/enable" && r.Method == "POST": app.adminApiAuth(app.adminApi2faEnable)(w, r) + case path == "/admin/api/auth/2fa/disable" && r.Method == "POST": + app.adminApiAuth(app.adminApi2faDisable)(w, r) + case path == "/admin/api/dashboard": app.adminApiAuth(app.adminApiDashboard)(w, r) diff --git a/docker/card/web/admin_api_2fa.go b/docker/card/web/admin_api_2fa.go index c74ec9c..db4f4dc 100644 --- a/docker/card/web/admin_api_2fa.go +++ b/docker/card/web/admin_api_2fa.go @@ -93,6 +93,29 @@ func (app *App) adminApi2faSetup(w http.ResponseWriter, r *http.Request) { }) } +func (app *App) adminApi2faDisable(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid request body"}) + return + } + if !app.verifyAdminPassword(req.Password) { + // in-session re-auth failure → 400 (see enable handler rationale) + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid password"}) + return + } + + db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") + db.Db_set_setting(app.db_write, "admin_totp_secret", "") + db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", "") + + writeJSON(w, map[string]bool{"ok": true}) +} + func (app *App) adminApi2faEnable(w http.ResponseWriter, r *http.Request) { if app.totpEnabled() { w.WriteHeader(http.StatusBadRequest) diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go index e884dab..ba7a856 100644 --- a/docker/card/web/admin_api_2fa_test.go +++ b/docker/card/web/admin_api_2fa_test.go @@ -207,3 +207,46 @@ func TestAdminApi2faEnable_WithoutSetup(t *testing.T) { t.Fatalf("expected 400 without prior setup, got %d", w.Code) } } + +func TestAdminApi2faDisable(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) // sets admin_password_hash for "testpass" + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + db.Db_set_setting(app.db_write, "admin_totp_secret", "SOMESECRET") + app.saveRecoveryHashes([]string{"h1", "h2"}) + + handler := app.CreateHandler_AdminApi() + cookie := &http.Cookie{Name: "admin_session_token", Value: token} + + // wrong password → 400, 2FA stays on + rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", + strings.NewReader(`{"password":"wrong"}`)) + rbad.AddCookie(cookie) + wbad := httptest.NewRecorder() + handler.ServeHTTP(wbad, rbad) + if wbad.Code != http.StatusBadRequest { + t.Fatalf("disable(wrong pw): expected 400, got %d", wbad.Code) + } + if !app.totpEnabled() { + t.Fatal("2FA should remain enabled after a wrong password") + } + + // correct password → 200, all keys cleared + rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", + strings.NewReader(`{"password":"testpass"}`)) + rok.AddCookie(cookie) + wok := httptest.NewRecorder() + handler.ServeHTTP(wok, rok) + if wok.Code != http.StatusOK { + t.Fatalf("disable(correct pw): expected 200, got %d: %s", wok.Code, wok.Body.String()) + } + if app.totpEnabled() { + t.Fatal("2FA should be disabled") + } + if db.Db_get_setting(app.db_read, "admin_totp_secret") != "" { + t.Fatal("secret should be cleared") + } + if len(app.loadRecoveryHashes()) != 0 { + t.Fatal("recovery hashes should be cleared") + } +} From 05808d4467050d055fff85e45d6cc6728bad27af Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 08:56:11 +0000 Subject: [PATCH 08/14] Require TOTP or recovery code at admin login when 2FA enabled Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/card/web/admin_api.go | 21 +++++ docker/card/web/admin_api_2fa_test.go | 118 ++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/docker/card/web/admin_api.go b/docker/card/web/admin_api.go index c97a915..d3a6751 100644 --- a/docker/card/web/admin_api.go +++ b/docker/card/web/admin_api.go @@ -196,6 +196,7 @@ func (app *App) adminApiAuthCheck(w http.ResponseWriter, r *http.Request) { func (app *App) adminApiLogin(w http.ResponseWriter, r *http.Request) { var req struct { Password string `json:"password"` + Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) @@ -216,6 +217,26 @@ func (app *App) adminApiLogin(w http.ResponseWriter, r *http.Request) { return } + if app.totpEnabled() { + if req.Code == "" { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, map[string]interface{}{ + "error": "2fa code required", + "totpRequired": true, + }) + return + } + secret := db.Db_get_setting(app.db_read, "admin_totp_secret") + if !validateTotpCode(secret, req.Code) && !app.consumeRecoveryCode(req.Code) { + w.WriteHeader(http.StatusUnauthorized) + writeJSON(w, map[string]interface{}{ + "error": "invalid code", + "totpRequired": true, + }) + return + } + } + sessionToken := util.Random_hex() db.Db_set_setting(app.db_write, "admin_session_token", sessionToken) db.Db_set_setting(app.db_write, "admin_session_created", diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go index ba7a856..60c67c6 100644 --- a/docker/card/web/admin_api_2fa_test.go +++ b/docker/card/web/admin_api_2fa_test.go @@ -250,3 +250,121 @@ func TestAdminApi2faDisable(t *testing.T) { t.Fatal("recovery hashes should be cleared") } } + +func enable2faForTest(t *testing.T, app *App) (secret string, recovery []string) { + t.Helper() + hash, _ := HashPassword("testpass") + db.Db_set_setting(app.db_write, "admin_password_hash", hash) + + s, _, err := newTotpKey("hub.example.com") + if err != nil { + t.Fatal(err) + } + db.Db_set_setting(app.db_write, "admin_totp_secret", s) + db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") + + plain, hashes, err := generateRecoveryCodes(10) + if err != nil { + t.Fatal(err) + } + app.saveRecoveryHashes(hashes) + return s, plain +} + +func postLogin(app *App, body string) *httptest.ResponseRecorder { + handler := app.CreateHandler_AdminApi() + r := httptest.NewRequest("POST", "/admin/api/auth/login", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w +} + +func TestAdminLogin_2faRequired_PasswordOnly(t *testing.T) { + app := openTestApp(t) + enable2faForTest(t, app) + + w := postLogin(app, `{"password":"testpass"}`) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + TotpRequired bool `json:"totpRequired"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if !resp.TotpRequired { + t.Fatal("expected totpRequired=true") + } + if db.Db_get_setting(app.db_read, "admin_session_token") != "" { + t.Fatal("no session should be issued without a code") + } +} + +func TestAdminLogin_2fa_ValidCode(t *testing.T) { + app := openTestApp(t) + secret, _ := enable2faForTest(t, app) + code, _ := totp.GenerateCode(secret, time.Now()) + + w := postLogin(app, `{"password":"testpass","code":"`+code+`"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if db.Db_get_setting(app.db_read, "admin_session_token") == "" { + t.Fatal("expected a session token to be issued") + } +} + +func TestAdminLogin_2fa_InvalidCode(t *testing.T) { + app := openTestApp(t) + enable2faForTest(t, app) + + w := postLogin(app, `{"password":"testpass","code":"000000"}`) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestAdminLogin_2fa_RecoveryCodeConsumedOnce(t *testing.T) { + app := openTestApp(t) + _, recovery := enable2faForTest(t, app) + + w1 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) + if w1.Code != http.StatusOK { + t.Fatalf("recovery first use: expected 200, got %d: %s", w1.Code, w1.Body.String()) + } + if len(app.loadRecoveryHashes()) != 9 { + t.Fatalf("expected 9 codes remaining, got %d", len(app.loadRecoveryHashes())) + } + + w2 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) + if w2.Code != http.StatusUnauthorized { + t.Fatalf("recovery reuse: expected 401, got %d", w2.Code) + } +} + +func TestAdminLogin_NoTotp_PasswordOnlyStillWorks(t *testing.T) { + app := openTestApp(t) + hash, _ := HashPassword("testpass") + db.Db_set_setting(app.db_write, "admin_password_hash", hash) + + w := postLogin(app, `{"password":"testpass"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 with 2FA off, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestAdminLogin_2fa_WrongPasswordValidCode_Fails(t *testing.T) { + app := openTestApp(t) + secret, _ := enable2faForTest(t, app) + code, _ := totp.GenerateCode(secret, time.Now()) + + w := postLogin(app, `{"password":"wrongpass","code":"`+code+`"}`) + if w.Code != http.StatusUnauthorized { + t.Fatalf("wrong password with valid code: expected 401, got %d", w.Code) + } + if db.Db_get_setting(app.db_read, "admin_session_token") != "" { + t.Fatal("no session should be issued with wrong password") + } +} From 13b411ad55d7f8621acba3496cd6df108c76cf24 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:03:05 +0000 Subject: [PATCH 09/14] Add DisableAdmin2FA CLI command for lost-authenticator recovery --- docker/card/cli.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docker/card/cli.go b/docker/card/cli.go index 3c89e97..b5c4700 100644 --- a/docker/card/cli.go +++ b/docker/card/cli.go @@ -27,6 +27,8 @@ func processArgs(db_conn *sql.DB, args []string) { programBatch(db_conn, args) case "WipeCard": wipeCard(db_conn, args) + case "DisableAdmin2FA": + disableAdmin2FA(db_conn) default: log.Warn("CLI command not found : " + args[0]) } @@ -209,6 +211,15 @@ func programBatch(db_conn *sql.DB, args []string) { fmt.Println(boltcardLink) } +// DisableAdmin2FA clears admin TOTP 2FA. Recovery path for a lost +// authenticator: run via `docker exec -it card ./app DisableAdmin2FA`. +func disableAdmin2FA(db_conn *sql.DB) { + db.Db_set_setting(db_conn, "admin_totp_enabled", "N") + db.Db_set_setting(db_conn, "admin_totp_secret", "") + db.Db_set_setting(db_conn, "admin_totp_recovery_hash", "") + log.Info("admin 2FA disabled") +} + // used for testing the wipe card function // // $ docker exec -it card bash From 5aaeb11b704f45e450186bfa69282ea05925d509 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:05:20 +0000 Subject: [PATCH 10/14] Frontend: login() handles TOTP code and totpRequired signal --- docker/card/admin-ui/src/hooks/use-auth.tsx | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docker/card/admin-ui/src/hooks/use-auth.tsx b/docker/card/admin-ui/src/hooks/use-auth.tsx index bf41e9a..d72f451 100644 --- a/docker/card/admin-ui/src/hooks/use-auth.tsx +++ b/docker/card/admin-ui/src/hooks/use-auth.tsx @@ -8,6 +8,13 @@ import { } from "react"; import { apiFetch, apiPost } from "@/lib/api"; +export class TotpRequiredError extends Error { + constructor() { + super("2fa required"); + this.name = "TotpRequiredError"; + } +} + interface AuthState { loading: boolean; authenticated: boolean; @@ -15,7 +22,7 @@ interface AuthState { } interface AuthContextType extends AuthState { - login: (password: string) => Promise; + login: (password: string, code?: string) => Promise; register: (password: string) => Promise; logout: () => Promise; refresh: () => Promise; @@ -45,9 +52,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { refresh(); }, [refresh]); - const login = async (password: string) => { - await apiPost("/auth/login", { password }); - await refresh(); + const login = async (password: string, code?: string) => { + const res = await fetch("/admin/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password, code }), + }); + if (res.ok) { + await refresh(); + return; + } + const body = await res + .json() + .catch(() => ({}) as { error?: string; totpRequired?: boolean }); + if (body.totpRequired) { + throw new TotpRequiredError(); + } + throw new Error(body.error || `HTTP ${res.status}`); }; const register = async (password: string) => { From 4b76ad019b4f82f5d574af911e362b0d454dd7b1 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:08:35 +0000 Subject: [PATCH 11/14] Frontend: login page prompts for TOTP / recovery code Co-Authored-By: Claude Sonnet 4.6 --- docker/card/admin-ui/src/pages/login.tsx | 51 ++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/docker/card/admin-ui/src/pages/login.tsx b/docker/card/admin-ui/src/pages/login.tsx index 8c1cd18..88d92e5 100644 --- a/docker/card/admin-ui/src/pages/login.tsx +++ b/docker/card/admin-ui/src/pages/login.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from "react"; -import { useAuth } from "@/hooks/use-auth"; +import { useAuth, TotpRequiredError } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -10,6 +10,9 @@ import { Zap } from "lucide-react"; export function LoginPage() { const { login } = useAuth(); const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [totpRequired, setTotpRequired] = useState(false); + const [useRecovery, setUseRecovery] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -18,9 +21,14 @@ export function LoginPage() { setError(""); setLoading(true); try { - await login(password); + await login(password, totpRequired ? code : undefined); } catch (err) { - setError(err instanceof Error ? err.message : "Login failed"); + if (err instanceof TotpRequiredError) { + setTotpRequired(true); + setError(""); + } else { + setError(err instanceof Error ? err.message : "Login failed"); + } } finally { setLoading(false); } @@ -55,10 +63,45 @@ export function LoginPage() { onChange={(e) => setPassword(e.target.value)} required autoFocus + disabled={totpRequired} />
+ {totpRequired && ( +
+ + setCode(e.target.value.trim())} + required + autoFocus + placeholder={useRecovery ? "recovery code" : "6-digit code"} + /> + +
+ )} From f5457ada9dfba5d04a6b806813e04195afe35839 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:13:15 +0000 Subject: [PATCH 12/14] Frontend: 2FA management card on settings page Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/two-factor-card.tsx | 211 ++++++++++++++++++ docker/card/admin-ui/src/pages/settings.tsx | 3 + 2 files changed, 214 insertions(+) create mode 100644 docker/card/admin-ui/src/components/two-factor-card.tsx diff --git a/docker/card/admin-ui/src/components/two-factor-card.tsx b/docker/card/admin-ui/src/components/two-factor-card.tsx new file mode 100644 index 0000000..91e1e22 --- /dev/null +++ b/docker/card/admin-ui/src/components/two-factor-card.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiFetch, apiPost } from "@/lib/api"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +interface TwoFaStatus { + enabled: boolean; + recoveryCodesRemaining: number; +} + +interface SetupData { + secret: string; + otpauthUri: string; + qrPng: string; +} + +export function TwoFactorCard() { + const queryClient = useQueryClient(); + const { data } = useQuery({ + queryKey: ["2fa-status"], + queryFn: () => apiFetch("/auth/2fa/status"), + }); + + const [setup, setSetup] = useState(null); + const [code, setCode] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState(null); + const [disablePassword, setDisablePassword] = useState(""); + const [showDisable, setShowDisable] = useState(false); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["2fa-status"] }); + + const startSetup = useMutation({ + mutationFn: () => apiPost("/auth/2fa/setup"), + onSuccess: (d) => { + setSetup(d); + setCode(""); + }, + onError: (err) => toast.error(err.message), + }); + + const enable = useMutation({ + mutationFn: () => apiPost<{ recoveryCodes: string[] }>("/auth/2fa/enable", { code }), + onSuccess: (d) => { + setSetup(null); + setRecoveryCodes(d.recoveryCodes); + invalidate(); + toast.success("Two-factor authentication enabled"); + }, + onError: (err) => toast.error(err.message), + }); + + const disable = useMutation({ + mutationFn: () => apiPost("/auth/2fa/disable", { password: disablePassword }), + onSuccess: () => { + setShowDisable(false); + setDisablePassword(""); + invalidate(); + toast.success("Two-factor authentication disabled"); + }, + onError: (err) => toast.error(err.message), + }); + + return ( + + + + Two-Factor Authentication + {data?.enabled ? ( + Enabled + ) : ( + Disabled + )} + + + + {data?.enabled ? ( + <> +

+ Login requires a code from your authenticator app. + {" "}Recovery codes remaining: {data.recoveryCodesRemaining}. +

+ + + ) : ( + <> +

+ Add a second factor (TOTP) to admin login using an authenticator app. +

+ + + )} +
+ + {/* Enrollment dialog: QR + confirm code */} + !o && setSetup(null)}> + + + Scan with your authenticator + + {setup && ( +
+ TOTP QR code +

+ Manual key: {setup.secret} +

+
+ + setCode(e.target.value.trim())} + placeholder="123456" + /> +
+
+ )} + + + +
+
+ + {/* Recovery codes shown once */} + !o && setRecoveryCodes(null)}> + + + Save your recovery codes + +

+ Store these somewhere safe. Each can be used once if you lose your + authenticator. They will not be shown again. +

+
+ {recoveryCodes?.map((c) => ( + {c} + ))} +
+ + + + +
+
+ + {/* Disable confirmation */} + + + + Disable two-factor authentication + +
+ + setDisablePassword(e.target.value)} + /> +
+ + + +
+
+
+ ); +} diff --git a/docker/card/admin-ui/src/pages/settings.tsx b/docker/card/admin-ui/src/pages/settings.tsx index 7402c54..a2aff6f 100644 --- a/docker/card/admin-ui/src/pages/settings.tsx +++ b/docker/card/admin-ui/src/pages/settings.tsx @@ -1,5 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { apiFetch, apiPut } from "@/lib/api"; +import { TwoFactorCard } from "@/components/two-factor-card"; import { toast } from "sonner"; import { Table, @@ -52,6 +53,8 @@ export function SettingsPage() {

Settings

+ + {data.settings.length === 0 ? (
No settings found. From 9410489b26a114731c269ef9557464e2863cbf49 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:21:25 +0000 Subject: [PATCH 13/14] Bump version to 0.20.0 and document admin 2FA Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 ++++++---- docker/card/build/build.go | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a163314..f09f1e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Uses Go 1.25.11 with CGo enabled for sqlite3, Node 22 for frontend. Docker Hub p ## Versioning -**Bump the version for every PR.** Patch-bump `Version` in `docker/card/build/build.go` (e.g. `0.19.3` → `0.19.4`) as part of each PR. On merge to `main`, CI republishes `boltcard/card:latest` with `org.opencontainers.image.version` set to this string; the About-page update check only surfaces the "Update available" button on deployed hubs when that label exceeds their running version. A PR that changes the image without bumping the version therefore ships an update no one can see. +**Bump the version for every PR.** Bump `Version` in `docker/card/build/build.go` as part of each PR, following [semantic versioning](https://semver.org/): a new backward-compatible feature bumps MINOR (e.g. `0.19.9` → `0.20.0`), a bug fix or maintenance change bumps PATCH (e.g. `0.19.3` → `0.19.4`), and a breaking change bumps MAJOR. On merge to `main`, CI republishes `boltcard/card:latest` with `org.opencontainers.image.version` set to this string; the About-page update check only surfaces the "Update available" button on deployed hubs when that label exceeds their running version. A PR that changes the image without bumping the version therefore ships an update no one can see. ## CLI Commands (run inside card container) @@ -69,6 +69,7 @@ docker exec -it card bash ./app ClearCardBalancesForTag ./app ProgramBatch ./app WipeCard +./app DisableAdmin2FA ``` ## Architecture @@ -92,7 +93,7 @@ Entry point: `main.go` → opens SQLite DB → runs CLI or starts HTTP server on - `phoenix/` — HTTP client for Phoenix Server API (invoices, payments, balance, channels). Uses basic auth from phoenix config (password cached at startup with `sync.Once`) - `crypto/` — AES-CMAC authentication and AES decryption for Bolt Card NFC protocol - `util/` — Error handling helpers (`CheckAndLog`), random hex generation, QR code encoding -- `build/` — Version string (currently "0.19.8"), date/time injected at build +- `build/` — Version string (currently "0.20.0"), date/time injected at build - `web-content/` — Static assets under `public/`, SPA build output under `admin/spa/` ### Route Groups (`web/app.go`) @@ -133,7 +134,7 @@ Schema version managed by idempotent `update_schema_*` functions in `db_create.g ### Authentication -- **Admin**: bcrypt password hash in settings table, session cookies with 24-hour expiry. Legacy SHA256 hashes auto-migrate to bcrypt on login. Constant-time token comparison. +- **Admin**: bcrypt password hash in settings table, session cookies with 24-hour expiry. Legacy SHA256 hashes auto-migrate to bcrypt on login. Constant-time token comparison. Optional TOTP 2FA (RFC 6238 via `github.com/pquerna/otp`): when `admin_totp_enabled="Y"`, login also requires a 6-digit TOTP code or a single-use recovery code, verified in `adminApiLogin` before a session is issued (login-only enforcement). Enrollment/disable endpoints live in `web/admin_api_2fa.go`, TOTP/recovery helpers in `web/totp.go`. Recovery for a lost authenticator: backup codes or the `DisableAdmin2FA` CLI command. - **Bolt Card NFC**: AES-CMAC with 5 keys per card (K0-K4), counter-based replay protection - **Wallet/PoS API**: Login/password → access_token + refresh_token (random hex) @@ -155,6 +156,7 @@ The `settings` table stores key-value config. Active settings used by the app: - `host_domain` — domain for building LNURL/callback URLs (set from `HOST_DOMAIN` env var on first run) - `log_level` — logrus log level, applied at startup and changeable live via admin UI dropdown - `admin_password_hash`, `admin_password_salt`, `admin_session_token`, `admin_session_created` — admin auth +- `admin_totp_enabled`, `admin_totp_secret`, `admin_totp_recovery_hash` — optional admin login 2FA (TOTP). `admin_totp_enabled` ("Y"/"N") gates enforcement at login; `admin_totp_secret` (base32) and `admin_totp_recovery_hash` (JSON array of bcrypt-hashed single-use recovery codes) are redacted in the settings UI. Cleared by the `DisableAdmin2FA` CLI command. - `new_card_code` — secret for card programming endpoint - `invite_secret` — optional secret for wallet API card creation - `bolt_card_hub_api`, `bolt_card_pos_api` — feature flags ("enabled" to activate) @@ -166,7 +168,7 @@ Withdraw limits (`min_withdraw_sats=1`, `max_withdraw_sats=100000000`) are hardc > **Note (Phoenix routing fees):** the hub does *not* send a fee cap to phoenixd — `phoenix/send_lightning_payment.go` posts only `amountSat` + `invoice` to `/payinvoice`, so phoenixd applies its own default fee budget. The `max_network_fee_sats` value (`4 + amountSats*4/1000` in `lnurlw_callback.go`) is used only to reserve card balance, not as the payment fee ceiling. This bit us once: phoenixd 0.7.3 (lightning-kmp 1.11) couldn't route tiny ~20–250 sat payments within its default budget and rejected them with `"routing fees are insufficient"`; upgrading to 0.8.0 (lightning-kmp 1.12.0) fixed it. **Possible future hardening (may never be needed):** pass an explicit `maxFeeFlatSat` (sats) to `/payinvoice` — e.g. a `5 + amountSats*4/1000` floor — so the hub controls the fee budget instead of relying on phoenixd defaults. -The admin settings page (`/admin/settings/`) displays all settings with sensitive values (`_hash`, `_token`, `_code` suffixes) redacted. `log_level` has an inline dropdown that submits on change. +The admin settings page (`/admin/settings/`) displays all settings with sensitive values (`_hash`, `_token`, `_code`, `_secret` suffixes) redacted. `log_level` has an inline dropdown that submits on change. ## Memory File diff --git a/docker/card/build/build.go b/docker/card/build/build.go index cffb806..5d10af2 100644 --- a/docker/card/build/build.go +++ b/docker/card/build/build.go @@ -1,5 +1,5 @@ package build -var Version string = "0.19.9" +var Version string = "0.20.0" var Date string var Time string From b9b89196f7d7ae559804bd80540f0b4e93276292 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Tue, 16 Jun 2026 09:29:31 +0000 Subject: [PATCH 14/14] Surface error when a submitted 2FA code is rejected at login --- docker/card/admin-ui/src/hooks/use-auth.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/card/admin-ui/src/hooks/use-auth.tsx b/docker/card/admin-ui/src/hooks/use-auth.tsx index d72f451..9b4aa59 100644 --- a/docker/card/admin-ui/src/hooks/use-auth.tsx +++ b/docker/card/admin-ui/src/hooks/use-auth.tsx @@ -65,7 +65,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const body = await res .json() .catch(() => ({}) as { error?: string; totpRequired?: boolean }); - if (body.totpRequired) { + // Only signal "code required" when we haven't already submitted one; + // a rejected code falls through to surface its error message. + if (body.totpRequired && !code) { throw new TotpRequiredError(); } throw new Error(body.error || `HTTP ${res.status}`);