No trust-on-first-use: bootstrap mode is an explicit, MAC-protected latch
(server_config singleton), NOT inferred from "no admin keys". The latch MAC
(ADMIN_KEY_INTEGRITY_SECRET over setup_state|bootstrap_completed_at) means a
DB-write attacker who restores a pre-bootstrap backup or flips the state to
re-open TOFU produces a mismatch the boot path detects and fails closed on.
- migration 20260626000002: server_config (id=1 singleton; setup_state +
bootstrap_completed_at + integrity_mac).
- db::server_config: read_setup_state -> {Fresh,Uninitialized,Locked,Tampered}
(MAC-verified); has_any_users; init_server_config (adoption: a live upgrade
latches `locked` so it never exposes a setup window; a truly empty DB begins
uninitialized); bootstrap_admin (insert the FIRST active admin AND latch
locked + hash-chained audit, ONE tx; refuses if an active admin already
exists); reset_setup (shell recovery; recomputes MAC, preserves admin keys).
- main boot (admin enabled): adopt/await-bootstrap, and FAIL CLOSED on a tampered
latch.
- smirk-admin: `setup --pubkey` (the supported headless bootstrap) + `reset-setup
--i-understand`.
- 1 L1 test: Fresh -> locked(valid) -> wrong-secret Tampered -> raw-flip Tampered
-> reset Uninitialized -> bootstrap refused while an active admin exists.
Deferred (documented): the HTTP /setup/* wizard. Per the spec the CLI / `docker
exec` is the SUPPORTED bootstrap (pure-PaaS without shell is explicitly
unsupported), so the loopback TTY wizard is a later convenience, not load-bearing.
Admin-plane key management, all admin_guard-protected and fail-closed audited:
- POST /admin/keys: add a pending key (validated 64-char lowercase x-only hex;
created_by_kind=admin; activation_deadline = now + ADMIN_PENDING_KEY_TTL_DAYS).
Capped at ADMIN_MAX_KEYS live keys (anti allowlist-stuffing).
- GET /admin/keys: list active/pending/revoked (sanitized view + status).
- DELETE /admin/keys/🆔 soft-revoke + cascade-revoke its sessions + audit in ONE
tx. Refuses to revoke the LAST live key over the network (CLI-only), so an
operator can't lock everyone out.
- POST /admin/keys/:id/rotate: atomic revoke-old(+sessions)+add-new(pending)+audit
in one tx — permitted on the last key (add+revoke, not bare revoke), the in-band
recovery for a compromised solo key.
db: create_admin_key refactored onto a shared insert_admin_key(&mut PgConnection)
helper; create_admin_key_audited / revoke_admin_key_full / rotate_admin_key run
their state change + session cascade + hash-chained audit in a single transaction.
1 L1 test extended to cover add/list/rotate/revoke + malformed-pubkey rejection.
NOTE: the live-key cap and last-key revoke guard are count-based on a table the
shared test DB pollutes across suites, so their boundaries aren't integration-
asserted (covered by review); the happy path raises the cap.
"Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a
single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard
so a protected route physically cannot run without both. All rejections collapse
to one opaque 401 (no enumeration oracle).
- src/api/admin.rs: admin_guard (verify access token -> live session by jti ->
live MAC-verified, activated allowlist entry) + AdminContext; handlers:
- challenge: mint a 120s admin_login nonce + return the {url, instance_id} the
wallet must bind.
- verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume
the nonce (forged sig can't burn a victim's nonce; replay loses the race) ->
authorize (pending key allowed for first login, which activates it) -> mint +
persist session AND the login audit row in ONE tx (fail-closed) -> bump
last_used.
- refresh: re-run FULL authorization before minting (closes the "8h refresh is
the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so
the old access token dies.
- logout: revoke the session; audit. me: guarded whoami.
- admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality
by socket, not middleware); no CORS layer (cross-origin browser calls blocked);
warns if bound non-loopback (hard bind-guard lands with admin posture). Routes
are intentionally absent from the public OpenAPI.
- config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled).
AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access
(refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used).
- test harness: admin_router() + request_on(router); 1 L1 test exercising the full
flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and
post-logout revocation.
NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost
allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion
exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey
backoff (§0.5) also pending.
A separate, low-volume audit trail for admin/setup/erasure actions, hash-chained
so a DB-write attacker cannot silently rewrite history. Kept distinct from the
high-volume user audit_logs (which keeps its enum action) — the chain applies
only where the privacy-maximalist mandate requires it.
- migration 20260626000001: admin_audit_logs (free-text action, actor_kind,
actor_pubkey_prefix [prefix only, no social graph], target, details, real-peer
ip, prev_hash, row_hash; monotonic seq).
- db::admin_audit: append_admin_audit(&mut PgConnection) so the audit row commits
in the SAME transaction as the state change it records (fail-closed, not a
best-effort side effect); record_admin_audit (own tx) for standalone audits;
verify_admin_audit_chain (boot/doctor walk — detects edit/delete/reorder/broken
linkage); list_admin_audit (panel). row_hash = HMAC(integrity_secret,
prev_hash || canonical(row)); appends serialize on a tx advisory lock so
seq/prev_hash cannot race.
- AdminAuditLog/NewAdminAudit models; 1 L1 test (link + verify + tamper-detect +
restore).
Admin tokens are cryptographically distinct from user tokens: a dedicated
ADMIN_JWT_SECRET, a distinct audience (smirk-admin) plus an explicit scope, and
the centralized strict Validation (HS256 pinned, zero leeway, aud required) —
Validation::default() is never used on this path. Access TTL 15 min (carries a
jti recorded in the session row, so a soft-revoked admin's still-valid token is
rejected at the next call), refresh TTL 8 h (binds a session id).
- core::admin_session: AdminSessionManager (mint/verify access+refresh) with the
four required regressions + scope check: user token fails admin decode;
wrong/absent aud fails; HS512 (alg confusion) fails; expired fails at zero
leeway; non-admin scope fails.
- core::session::strict_validation made pub(crate) and shared (no drift).
- db::admin_sessions: create, find-active-by-jti (guard), find-active-by-id +
rotate-jti (refresh), revoke (logout), revoke-all-for-key (key cascade), and a
dead-session sweep. Lookups return only live (unrevoked, unexpired) rows.
- NewAdminSession model; 2 L1 tests (lookup/rotate/logout, key->session cascade).
The admin authorization data model. The server stores only public keys; an admin
is authorized by presence in admin_keys, and each row carries an HMAC
(ADMIN_KEY_INTEGRITY_SECRET, separate from DATABASE_URL) over
id|pubkey|scope|created_at|activated_at|revoked_at. Because the MAC covers the
revocation/activation state, a DB-write attacker who clears revoked_at (un-revoke)
or revives a key produces a mismatch the guard rejects — the MAC proves a pubkey
is *currently* authorized, not merely once.
- migration 20260625000002: admin_keys (partial unique index = one active row per
pubkey; soft revoke keeps history) + admin_sessions (separate table/secret from
user sessions; cascade-on-key-delete).
- core::crypto::admin_mac: compute/verify over a JSON-canonical field set,
timestamps as microsecond epochs; constant-time compare; KATs bind
revoked/activated state.
- db::admin_keys: create / get_active (re-verifies MAC, fail-closed + logs tamper)
/ list / count_live / activate / revoke. Every covered-field write recomputes
the MAC centrally; timestamps truncated to microseconds so the signed value
round-trips; revoke/activate are FOR UPDATE transactions.
- 5 L1 tests incl. the load-bearing raw-SQL un-revoke -> MAC mismatch -> rejected.
The shared server-nonce store every signed action consumes (admin login,
first-run setup, self-service erasure). DB-backed so consumption is atomic and
correct under concurrent requests and a future load-balanced fleet:
DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
RETURNING subject
Winning the race returns one row; zero rows = replay/expired/wrong-purpose =>
reject (no TOCTOU). issue_challenge mints a 256-bit nonce bound to a purpose and
optional subject; consume_challenge is the atomic single-use step that pairs with
verify_signed_action (already built, §0.1). Periodic expiry sweep bounds the
table; the table holds no secret and is documented as backup-excluded
(ephemeral, links pubkey->time).
migration 20260625000001; ConsumedChallenge exported; 3 L1 tests (consume-once /
replay-rejected, wrong-purpose rejected, expired rejected).
From a multi-lens adversarial review of the capabilities + prices work:
- PRICES_ASSETS="" now means NONE, not all (real bug): from_env used env_opt,
which collapses unset and present-but-empty into None, so the documented
"empty = none" silently served all five feeds. Read via env::var so unset =>
all, present (even empty) => the listed subset or none.
- PRICES_CURRENCY is now validated against a curated SUPPORTED_PRICE_CURRENCIES
allowlist. Previously a typo (e.g. "usdd") booted, advertised prices:true, and
served an empty feed forever (the provider returns empty quotes for an unknown
currency) — a fail-open path inconsistent with the adjacent provider/asset
checks.
- PRICES_PROVIDER is lowercased at read, matching the asset/currency
normalization (mixed-case no longer trips the allowlist).
- validate() now requires an Electrum URL for an enabled BTC/LTC chain (hard
error in prod, warn in dev), symmetric with the xmr/wow/grin secret checks.
Keeps /capabilities honest: a chain it can't serve won't boot advertising
itself as available.
Tests: config regressions (currency, utxo-electrum); a PriceClient feed-subset
selection unit test; the /prices integration test now populates the snapshot and
asserts the served quotes + RFC-3339 timestamp (the prior PRICES_ASSETS env-var
was inert under the harness); capabilities asserts network presence/null across
all five chains.
Dismissed after verification: coingecko json() was already switched to a
streaming read_capped; fleet refresh-jitter (no API key, single-node,
self-healing on 429); prices capability staying a bare bool (/prices carries the
currency + per-asset map).
Cached crypto→fiat price feed, refreshed by a background task and served from an
in-memory snapshot — no upstream call on the request path. Public and
feature-flagged: when FEATURE_PRICES is off, /prices is 404 and no upstream is
ever contacted.
Per-feed control, as a public server needs: PRICES_ASSETS narrows which feeds an
instance quotes (unset = all supported; an explicit list serves a subset; empty
serves none), validated fail-closed against SUPPORTED_PRICE_ASSETS. Provider
(PRICES_PROVIDER, coingecko) and currency (PRICES_CURRENCY, usd) are configurable;
the refresh interval is clamped to a provider-friendly minimum.
- infra/prices: PriceClient (feed whitelist → provider coin ids), bounded by the
HTTP client timeout; non-finite/negative/missing quotes are skipped, not faked.
A fetch failure keeps the last good snapshot. Drift guard: a test asserts the
provider mapping covers exactly SUPPORTED_PRICE_ASSETS.
- main: background refresh task only when enabled and at least one feed is set.
- AppState.prices snapshot; handler 404s when disabled.
- openapi (prices tag); unit tests + L1 test over both feature-flag states.
Public, unauthenticated description of what this deployment offers, so the
wallet can adapt to the instance it's pointed at — grey out disabled chains and
features, and pick the right network. This is the client-facing half of the
open-core/feature-flag design: every capability is a config switch, surfaced
here. No secrets — only on/off flags and public network names.
Response: { version, chains: { btc/ltc/xmr/wow/grin: { enabled, network } },
features: { grin_relay, prices, nostr_identity, tips } }. UTXO chains carry
their configured network; CryptoNote/Grin report null. On the normal rate-limit
tier. Registered in openapi (system tag); 1 L1 test (public, well-shaped).
Add tiered per-IP rate limiting to the router, completing the assembly:
- STRICT tier (~2 req/s, burst 20) on the unauthenticated, abuse-prone
surface: /api/v1/auth/* and /api/v1/website/*. This closes the
website-challenge growth vector (the prior rate-limit LOW): an unauthenticated
flood of challenge requests is now throttled per IP.
- NORMAL tier (~10 req/s, burst 60) on the authenticated wallet/identity API.
- PeerIpKeyExtractor keys on the peer IP; `main` now serves with
into_make_service_with_connect_info::<SocketAddr> so the IP is available, and
the test harness injects a loopback ConnectInfo (each test builds a fresh
router with fresh limiter state).
Over-limit returns 429. The limiter sits inside the global layers (CORS/timeout/
body/compression/trace) and in front of the handlers.
L1 test proves it throttles: a 40-request burst to a strict-tier route returns
429 (the governor runs before the handler, so even unauthenticated requests
count). All 33 integration tests + 114 lib tests pass through the fully-layered
router; openapi unchanged.
Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.
Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET pending -> the caller's inbox
- POST get {slate_id} -> poll a relay's state (sender/recipient)
- POST respond {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel {slate_id} (sender or recipient)
Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).
Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).
6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
Third wallet-handler slice. Authenticated, view-only proxies over the
GrinClient — the backend holds no Grin spend key; the wallet exports its
rewind_hash (a public-derived view credential) per scan, the backend forwards
it to grin-wallet's scan_rewind_hash and stores no secret. Spending happens in
the wallet; the backend only broadcasts the finalized tx.
Endpoints (under /api/v1/wallet/grin):
- POST scan {rewind_hash, start_height?} -> {outputs, total_balance,
last_pmmr_index} (incremental resume index)
- GET height -> {height}
- POST broadcast {tx} -> {ok} (node push_transaction)
- JWT-gated. rewind_hash is a view credential: GrinScanRequest omits Debug and
is skipped from every span (never logged), and is validated as 64 hex before
any grin call (applying the wallet-handler review's secret-DTO lesson).
- Feature-gated: grin disabled -> 400.
- Shared validate_hex64 in wallet/mod.rs (view_key + rewind_hash); 3 paths +
schemas + grin tag registered; spec regenerated, drift-clean.
4 L1 integration tests (auth gate on scan/height/broadcast; malformed
rewind_hash 400).
Also: dropped Debug from the xmr_wow view_key-bearing request DTOs
(ViewRequest/RegisterRequest) per the review — defense-in-depth against a
cleartext view_key dump.
First wallet-handler slice. Thin, non-custodial, JWT-gated proxies over the
ElectrumClient — the wallet derives addresses and signs locally; these
endpoints only relay reads and a finalized broadcast.
Endpoints (POST, under /api/v1/wallet/utxo, asset = btc|ltc in body):
- balance -> {confirmed, unconfirmed} (separate integers; client sums)
- utxos -> [{txid, vout, value, height}]
- history -> [{txid, height, fee}]
- tip -> {height}
- fee -> {sat_per_vb}
- broadcast-> {txid}
- JWT-gated for abuse control; the client-supplied address is NOT persisted
against the user (no address<->identity graph stored here).
- Feature-gated dispatch: unknown/disabled asset -> 400; the Electrum client
validates the address + network before any network call.
- broadcast validates tx_hex (non-empty, even-length, hex, <=200 KB) before
reaching the node.
- Registered in build_router + openapi.json (6 paths, btc_ltc tag); spec
regenerated, drift-clean.
5 L1 integration tests (auth gate on balance/broadcast/fee; unknown-asset and
malformed-tx-hex 400s — all deterministic, no network) + tx_hex unit test.
Add ChainClients (infra/chains.rs): per-chain data-source handles built once at
startup from config and shared via AppState. Each client is present only when
its chain feature flag is on, and a malformed chain endpoint now fails startup
(fail-closed) rather than the first request. Cloneable (grin's session state is
shared via Arc) so it lives in the cloneable AppState.
Foundation for the wallet-handlers phase — handlers will read these clients
from State. No endpoints yet (openapi unchanged).
- shared build_router() (lib) so main + tests exercise identical wiring
- tests/common: spins the real router + AppState against TEST_DATABASE_URL;
self-skips when unset (cargo test stays green without a DB). Deterministic,
non-secret test JWT/peppers; ephemeral generated identities only — no funded
or sensitive wallet seeds (those stay in a separate gitignored L3 harness).
- tests/db_queries: validates migrations + db layer against real Postgres
(peppered identity lookups, UNIQUE->409, session create/lookup/revoke,
key upsert/list/delete, restore-attempt counts).
- tests/auth_flow: HTTP flows through the real router (health, auth gate,
refresh rotation + old-token revocation, username set+lookup, key register+
fetch, NIP-05 directory).
- CI: new integration job with an ephemeral postgres:16 service.
- 82 tests total (69 unit + 13 integration), all green against live PG.
Complements the monorepo's client-side suite (it tests the wire format the
client sends; the client tests the wallet side); mirrors its secret-handling
(env-driven, gitignored). Mirrors the L0-L5 tiering from docs/engineering.