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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -69,6 +69,7 @@ docker exec -it card bash
./app ClearCardBalancesForTag <group_tag>
./app ProgramBatch <group_tag> <max_group_num> <initial_balance> <expiry_hours>
./app WipeCard <card_id>
./app DisableAdmin2FA
```

## Architecture
Expand All @@ -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`)
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 /admin/api/auth/2fa/disable /auth
}
handle @auth_paths {
rate_limit {
Expand Down
211 changes: 211 additions & 0 deletions docker/card/admin-ui/src/components/two-factor-card.tsx
Original file line number Diff line number Diff line change
@@ -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<TwoFaStatus>("/auth/2fa/status"),
});

const [setup, setSetup] = useState<SetupData | null>(null);
const [code, setCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [disablePassword, setDisablePassword] = useState("");
const [showDisable, setShowDisable] = useState(false);

const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["2fa-status"] });

const startSetup = useMutation({
mutationFn: () => apiPost<SetupData>("/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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Two-Factor Authentication
{data?.enabled ? (
<Badge>Enabled</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{data?.enabled ? (
<>
<p className="text-sm text-muted-foreground">
Login requires a code from your authenticator app.
{" "}Recovery codes remaining: {data.recoveryCodesRemaining}.
</p>
<Button variant="destructive" onClick={() => setShowDisable(true)}>
Disable 2FA
</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
Add a second factor (TOTP) to admin login using an authenticator app.
</p>
<Button
onClick={() => startSetup.mutate()}
disabled={startSetup.isPending}
>
{startSetup.isPending ? "Preparing..." : "Enable 2FA"}
</Button>
</>
)}
</CardContent>

{/* Enrollment dialog: QR + confirm code */}
<Dialog open={!!setup} onOpenChange={(o) => !o && setSetup(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Scan with your authenticator</DialogTitle>
</DialogHeader>
{setup && (
<div className="space-y-4">
<img
src={`data:image/png;base64,${setup.qrPng}`}
alt="TOTP QR code"
className="mx-auto h-48 w-48"
/>
<p className="text-xs text-muted-foreground break-all">
Manual key: <span className="font-mono">{setup.secret}</span>
</p>
<div className="space-y-2">
<Label htmlFor="enable-code">Enter the 6-digit code</Label>
<Input
id="enable-code"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
placeholder="123456"
/>
</div>
</div>
)}
<DialogFooter>
<Button
onClick={() => enable.mutate()}
disabled={enable.isPending || code.length === 0}
>
{enable.isPending ? "Verifying..." : "Confirm"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Recovery codes shown once */}
<Dialog open={!!recoveryCodes} onOpenChange={(o) => !o && setRecoveryCodes(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save your recovery codes</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Store these somewhere safe. Each can be used once if you lose your
authenticator. They will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
{recoveryCodes?.map((c) => (
<span key={c} className="rounded bg-muted px-2 py-1">{c}</span>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
navigator.clipboard?.writeText((recoveryCodes ?? []).join("\n"));
toast.success("Copied");
}}
>
Copy
</Button>
<Button onClick={() => setRecoveryCodes(null)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Disable confirmation */}
<Dialog open={showDisable} onOpenChange={setShowDisable}>
<DialogContent>
<DialogHeader>
<DialogTitle>Disable two-factor authentication</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="disable-pw">Confirm admin password</Label>
<Input
id="disable-pw"
type="password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
onClick={() => disable.mutate()}
disabled={disable.isPending || disablePassword.length === 0}
>
{disable.isPending ? "Disabling..." : "Disable"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
31 changes: 27 additions & 4 deletions docker/card/admin-ui/src/hooks/use-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@ 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;
registered: boolean;
}

interface AuthContextType extends AuthState {
login: (password: string) => Promise<void>;
login: (password: string, code?: string) => Promise<void>;
register: (password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
Expand Down Expand Up @@ -45,9 +52,25 @@ 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 });
// 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}`);
};

const register = async (password: string) => {
Expand Down
Loading
Loading