diff --git a/CLAUDE.md b/CLAUDE.md index cbe8a30..e3da456 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,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.20.2"), date/time injected at build +- `build/` — Version string (currently "0.20.3"), date/time injected at build - `web-content/` — Static assets under `public/`, SPA build output under `admin/spa/` ### Route Groups (`web/app.go`) @@ -134,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. 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. +- **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`. Disabling 2FA via the admin UI requires the password **and** a current TOTP/recovery code (proof of possession). 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) diff --git a/docker/card/admin-ui/src/components/two-factor-card.tsx b/docker/card/admin-ui/src/components/two-factor-card.tsx index 779bc57..77a77cc 100644 --- a/docker/card/admin-ui/src/components/two-factor-card.tsx +++ b/docker/card/admin-ui/src/components/two-factor-card.tsx @@ -37,8 +37,16 @@ export function TwoFactorCard() { const [code, setCode] = useState(""); const [recoveryCodes, setRecoveryCodes] = useState(null); const [disablePassword, setDisablePassword] = useState(""); + const [disableCode, setDisableCode] = useState(""); + const [disableUseRecovery, setDisableUseRecovery] = useState(false); const [showDisable, setShowDisable] = useState(false); + const resetDisable = () => { + setDisablePassword(""); + setDisableCode(""); + setDisableUseRecovery(false); + }; + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["2fa-status"] }); @@ -63,10 +71,14 @@ export function TwoFactorCard() { }); const disable = useMutation({ - mutationFn: () => apiPost("/auth/2fa/disable", { password: disablePassword }), + mutationFn: () => + apiPost("/auth/2fa/disable", { + password: disablePassword, + code: disableCode, + }), onSuccess: () => { setShowDisable(false); - setDisablePassword(""); + resetDisable(); invalidate(); toast.success("Two-factor authentication disabled"); }, @@ -188,13 +200,23 @@ export function TwoFactorCard() { {/* Disable confirmation */} - + { + setShowDisable(o); + if (!o) resetDisable(); + }} + > Disable two-factor authentication +

+ Turning 2FA off requires your password and a current code, to prove + you still hold the second factor. +

- + setDisablePassword(e.target.value)} />
+
+ + setDisableCode(e.target.value.trim())} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + disablePassword.length > 0 && + disableCode.length > 0 && + !disable.isPending + ) { + e.preventDefault(); + disable.mutate(); + } + }} + placeholder={disableUseRecovery ? "recovery code" : "6-digit code"} + /> + +
diff --git a/docker/card/build/build.go b/docker/card/build/build.go index 0e3893f..4d7fd11 100644 --- a/docker/card/build/build.go +++ b/docker/card/build/build.go @@ -1,5 +1,5 @@ package build -var Version string = "0.20.2" +var Version string = "0.20.3" var Date string var Time string diff --git a/docker/card/web/admin_api_2fa.go b/docker/card/web/admin_api_2fa.go index db4f4dc..702496d 100644 --- a/docker/card/web/admin_api_2fa.go +++ b/docker/card/web/admin_api_2fa.go @@ -96,6 +96,7 @@ 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"` + Code string `json:"code"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(http.StatusBadRequest) @@ -109,6 +110,25 @@ func (app *App) adminApi2faDisable(w http.ResponseWriter, r *http.Request) { return } + // Turning OFF the second factor must prove possession of it — a 2FA-backed + // session can be up to 24h old and the password alone is exactly the threat + // 2FA defends against. Require a current TOTP code or a single-use recovery + // code (recovery codes and the DisableAdmin2FA CLI remain the + // lost-authenticator escape hatches, so this can't cause a lockout). + if app.totpEnabled() { + if req.Code == "" { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "2fa code required"}) + return + } + secret := db.Db_get_setting(app.db_read, "admin_totp_secret") + if !validateTotpCode(secret, req.Code) && !app.consumeRecoveryCode(req.Code) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "invalid code"}) + 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", "") diff --git a/docker/card/web/admin_api_2fa_test.go b/docker/card/web/admin_api_2fa_test.go index 60c67c6..ed1471c 100644 --- a/docker/card/web/admin_api_2fa_test.go +++ b/docker/card/web/admin_api_2fa_test.go @@ -210,35 +210,48 @@ func TestAdminApi2faEnable_WithoutSetup(t *testing.T) { 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"}) + token := setupAdminSession(t, app) // session cookie + password "testpass" + secret, _ := enable2faForTest(t, app) + code, _ := totp.GenerateCode(secret, time.Now()) handler := app.CreateHandler_AdminApi() cookie := &http.Cookie{Name: "admin_session_token", Value: token} + disable := func(body string) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", strings.NewReader(body)) + r.AddCookie(cookie) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } - // 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) + // wrong password → 400 (checked before the code), 2FA stays on + if w := disable(`{"password":"wrong","code":"` + code + `"}`); w.Code != http.StatusBadRequest { + t.Fatalf("disable(wrong pw): expected 400, got %d", w.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) + // correct password but NO code → 400, 2FA stays on (proof-of-possession required) + if w := disable(`{"password":"testpass"}`); w.Code != http.StatusBadRequest { + t.Fatalf("disable(no code): expected 400, got %d", w.Code) + } + if !app.totpEnabled() { + t.Fatal("2FA should remain enabled when no code is supplied") + } + + // correct password + wrong code → 400, 2FA stays on + if w := disable(`{"password":"testpass","code":"000000"}`); w.Code != http.StatusBadRequest { + t.Fatalf("disable(wrong code): expected 400, got %d", w.Code) + } + if !app.totpEnabled() { + t.Fatal("2FA should remain enabled after a wrong code") + } + + // correct password + valid TOTP code → 200, all keys cleared + wok := disable(`{"password":"testpass","code":"` + code + `"}`) if wok.Code != http.StatusOK { - t.Fatalf("disable(correct pw): expected 200, got %d: %s", wok.Code, wok.Body.String()) + t.Fatalf("disable(valid): expected 200, got %d: %s", wok.Code, wok.Body.String()) } if app.totpEnabled() { t.Fatal("2FA should be disabled") @@ -251,6 +264,25 @@ func TestAdminApi2faDisable(t *testing.T) { } } +func TestAdminApi2faDisable_RecoveryCode(t *testing.T) { + app := openTestApp(t) + token := setupAdminSession(t, app) + _, recovery := enable2faForTest(t, app) + + r := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", + strings.NewReader(`{"password":"testpass","code":"`+recovery[0]+`"}`)) + r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) + w := httptest.NewRecorder() + app.CreateHandler_AdminApi().ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("disable(recovery code): expected 200, got %d: %s", w.Code, w.Body.String()) + } + if app.totpEnabled() { + t.Fatal("2FA should be disabled via a recovery code") + } +} + func enable2faForTest(t *testing.T, app *App) (secret string, recovery []string) { t.Helper() hash, _ := HashPassword("testpass") diff --git a/docker/card/web/totp.go b/docker/card/web/totp.go index 2ebb485..0c21b2f 100644 --- a/docker/card/web/totp.go +++ b/docker/card/web/totp.go @@ -8,15 +8,11 @@ import ( ) // newTotpKey generates a fresh TOTP secret for the admin. accountName is the -// label shown in the authenticator app (we pass host_domain). The issuer is -// "Boltcard Hub" (one token) to avoid wrong-logo brand matching in Authy. -// Returns the base32 secret and the otpauth:// provisioning URI. +// 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{ - // "Boltcard" as a single token (not "Bolt Card") so authenticator apps - // that pick a logo by matching the issuer (e.g. Authy) don't false-match - // the unrelated "Bolt" brand and suggest the wrong logo. - Issuer: "Boltcard Hub", + Issuer: "Bolt Card Hub", AccountName: accountName, }) if err != nil { diff --git a/docker/card/web/totp_test.go b/docker/card/web/totp_test.go index ff8c55a..76420b0 100644 --- a/docker/card/web/totp_test.go +++ b/docker/card/web/totp_test.go @@ -19,11 +19,6 @@ func TestNewTotpKey_ProducesValidatableSecret(t *testing.T) { if !strings.HasPrefix(url, "otpauth://totp/") { t.Fatalf("expected otpauth URI, got %q", url) } - // Issuer must be the single-token "Boltcard" so Authy doesn't match the - // unrelated "Bolt" brand and suggest the wrong logo. - if !strings.Contains(url, "Boltcard") { - t.Fatalf("expected 'Boltcard' issuer in URI, got %q", url) - } code, err := totp.GenerateCode(secret, time.Now()) if err != nil {