Commit Graph
7 Commits
Author SHA1 Message Date
jwinterm 67faf8f762 feat(admin): first-run bootstrap latch + CLI setup [operator §3]
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.
2026-06-27 14:11:28 -04:00
jwinterm 49df1d1ae5 feat(admin): tamper-evident privileged-action audit chain [operator §0.4]
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).
2026-06-26 06:06:54 -04:00
jwinterm a03dfc6253 feat(admin): allowlist + integrity-MAC data model [operator §0.3/§1.2]
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.
2026-06-25 18:38:57 -04:00
jwinterm 4f9d00f1d2 feat(db): unified single-use challenge store [operator §0.2]
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).
2026-06-25 17:33:59 -04:00
jwinterm 586f681287 feat: Grin slatepack relay (non-custodial async mailbox, feature-flagged)
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.
2026-06-23 17:39:13 -04:00
jwinterm 3caa678dea feat: db query layer (sessions, keys, audit, grin relay, login, restore)
Six entity query modules on Database, all explicit-column (no SELECT *), legacy
methods dropped:
- sessions: create + active-lookup by (already-peppered) refresh_token_hash,
  revoke/revoke-all (race-aware bool), touch, cleanup-expired
- user_keys: upsert (ON CONFLICT user_id+asset+key_type), list, get-by-asset
- audit: log_action + recent-by-user/action
- grin_slatepacks: relay create/fetch/pending/respond/finalize/cancel/expire
- login_events: salted IP hash, retention sweep, erasure purge (LoginStats only)
- restore_attempts: peppered fingerprint + salted IP (never raw), rate-limit
  counts, 7-day sweep

migrations/...02: login_events + restore_attempts tables (keyed-hash columns,
covering indexes for the aggregations). Shared unique_violation_as + hash_ip
helpers on Database.
2026-06-22 14:32:17 -04:00
jwinterm 416fa53c17 feat: Nostr-native data models + initial schema
models/db.rs: User keyed by pubkey_hash/nostr_pubkey/username (pubkey_hash and
seed_fingerprint peppered at rest); Wallet, Session (platform=extension|web|nostr),
UserKey, GrinSlatepack, AuditLog. Dropped all telegram/discord/social-identity
columns and every tip/social/swap struct (swaps land as their own feature unit).

migrations/20260622000001_initial_schema: consolidated clean schema; enum types;
UNIQUE on identity columns; FK ON DELETE (CASCADE/SET NULL) for self-service
erasure; indexes on every lookup column. PostgreSQL 13+.
2026-06-22 07:10:55 -04:00