Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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)

Expand Down
72 changes: 67 additions & 5 deletions docker/card/admin-ui/src/components/two-factor-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ export function TwoFactorCard() {
const [code, setCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(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"] });

Expand All @@ -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");
},
Expand Down Expand Up @@ -188,25 +200,75 @@ export function TwoFactorCard() {
</Dialog>

{/* Disable confirmation */}
<Dialog open={showDisable} onOpenChange={setShowDisable}>
<Dialog
open={showDisable}
onOpenChange={(o) => {
setShowDisable(o);
if (!o) resetDisable();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Disable two-factor authentication</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Turning 2FA off requires your password and a current code, to prove
you still hold the second factor.
</p>
<div className="space-y-2">
<Label htmlFor="disable-pw">Confirm admin password</Label>
<Label htmlFor="disable-pw">Admin password</Label>
<Input
id="disable-pw"
type="password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="disable-code">
{disableUseRecovery ? "Recovery code" : "Authenticator code"}
</Label>
<Input
id="disable-code"
inputMode={disableUseRecovery ? "text" : "numeric"}
autoComplete="one-time-code"
value={disableCode}
onChange={(e) => 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"}
/>
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-foreground"
onClick={() => {
setDisableUseRecovery((v) => !v);
setDisableCode("");
}}
>
{disableUseRecovery
? "Use authenticator code instead"
: "Use a recovery code instead"}
</button>
</div>
<DialogFooter>
<Button
variant="destructive"
onClick={() => disable.mutate()}
disabled={disable.isPending || disablePassword.length === 0}
disabled={
disable.isPending ||
disablePassword.length === 0 ||
disableCode.length === 0
}
>
{disable.isPending ? "Disabling..." : "Disable"}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion docker/card/build/build.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package build

var Version string = "0.20.2"
var Version string = "0.20.3"
var Date string
var Time string
20 changes: 20 additions & 0 deletions docker/card/web/admin_api_2fa.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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", "")
Expand Down
70 changes: 51 additions & 19 deletions docker/card/web/admin_api_2fa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
10 changes: 3 additions & 7 deletions docker/card/web/totp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 0 additions & 5 deletions docker/card/web/totp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading