Commit Graph
5 Commits
Author SHA1 Message Date
jwinterm 2d0077b2b9 feat(capabilities): honest secret-presence downgrade + admin features view [operator §4]
Make /capabilities fail-closed and add the admin features surface.

- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
  unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
  secret) now reports enabled:false — so /capabilities never advertises a chain
  that would 404, and a missing-secret downgrade is indistinguishable from a
  deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
  requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
  value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
  downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
  config, contains no secret value (asserts no "integration-test-" substring).

openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.

Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
2026-06-27 15:35:56 -04:00
jwinterm 51a3371be1 feat(admin,landing): admin-plane hardening + public landing [operator §2]
Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
  unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
  (localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
  since rebinding must supply one and the bind is the primary control) to defeat
  DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
  CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.

Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
  by serializing config/state). Default projection is minimal { software.name,
  status }; version (coarse major.minor), chains, price_feed, and uptime (bool
  `up` only) are each individually opt-in. The enabled-feature tuple is NEVER
  emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
  hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
  per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.

Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.

Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
2026-06-27 15:32:11 -04:00
jwinterm 5df12d3df8 fix(admin): harden allowlist per adversarial review (Subsystems 0-1)
From a 5-lens adversarial review of the admin surface (6 confirmed findings,
collapsing to 4 fixes; 2 dismissed after verification):

- HIGH: last-key-revoke TOCTOU (lockout). The count(live)<=1 floor was checked in
  the handler, then revoke ran separately — two concurrent revokes could both pass
  and empty the allowlist. Moved the floor INTO revoke_admin_key_full under a
  per-tx advisory lock (admin_keys_mutate_lock); returns RevokeKeyOutcome
  {Revoked,NotFound,WouldEmptyAllowlist}.
- LOW: ADMIN_MAX_KEYS cap TOCTOU. Same check-then-mutate race on add. Cap is now
  counted INSIDE create_admin_key_audited under the same lock; returns
  AddKeyOutcome {Created,CapReached}. rotate takes the lock too, so all
  add/revoke/rotate serialize.
- LOW: integrity MAC didn't cover activation_deadline — a DB-write attacker could
  null/extend the deadline to revive an expired pending key without breaking the
  MAC. Added activation_deadline to AdminKeyMacInput + the canonical input +
  every compute/verify site; new KAT binds it.
- LOW: get_active_admin_key (and create_*) logged the full pubkey via #[instrument]
  (params weren't skipped), defeating the prefix-only / no-social-graph design.
  Skipped pubkey/input from those spans.

Dismissed (both skeptics not-a-bug): audit-chain JSONB `details` round-trip — the
handlers always pass details:None and serde's sorted-key default makes the
string case round-trip (the L1 chain test proves it). Documented v1 deferrals
(login timing-oracle hardening, per-pubkey backoff, hard non-loopback bind guard)
remain tracked, not regressions.

All gates green: 138 lib + 138 integration, no openapi drift, secret-scan, leak.
2026-06-26 07:14:03 -04:00
jwinterm 62104ca85e feat(admin): allowlist key CRUD + kill-switches [operator §1.5]
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.
2026-06-26 06:46:07 -04:00
jwinterm 4489c1c540 feat(admin): admin auth flow + guard + loopback plane [operator §1.1-1.4, §2.1]
"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.
2026-06-26 06:36:55 -04:00