Commit Graph
25 Commits
Author SHA1 Message Date
jw 81a3e4cf58 fix: pre-ship audit remediation for auth identity, abuse control and retention
Fixes every confirmed backend finding from the v0.3.0 pre-ship review.

Identity (the two that corrupt accounts):
- nostr_register creates a row with pubkey_hash NULL, so that wallet's later
  /auth/extension call matched nothing, fell through the rotation path (which no
  client ever satisfies, since nothing sends rotation_signature) and minted a
  SECOND account. A fingerprint match on a NULL-pubkey_hash row now adopts the
  submitted pubkey_hash onto that row, but only when the row's on-file BTC key
  equals the submitted one, so a bare fingerprint (which check_restore discloses,
  and is therefore not a secret) still cannot claim an account.
- The fingerprint-ownership check no longer short-circuits on returning_by_pubkey.
  It was skipped for returning users, so a fingerprint owned by another row got
  backfilled, violated the UNIQUE index, and 500'd that account on every
  subsequent login.
- The rotation proof is verified over a distinct, instance- and target-bound
  message. It shared the login message, so a captured login signature was a
  replayable rotation proof against another instance.

Abuse control:
- The rate limiters key on the real client IP, honouring X-Forwarded-For only
  from a trusted proxy. Behind the TLS terminator every request looked like
  127.0.0.1, so the whole internet shared one bucket and ~3 req/s 429'd every
  wallet unlock globally.
- A PoW solution is single-use instead of replayable for the challenge TTL.
- Delegated /verify is opt-in and rate-limited; it was an unauthenticated
  amplifier into Electrum, LWS and a full-genesis grin scan.
- A grin scan with no start_height gets the same depth policy and PoW price as
  one that passes a height, instead of an unbounded free scan.

Retention and logging:
- The retention sweep is implemented AND scheduled. RETENTION_* was advertised
  and inert, which made the privacy policy's retention promise untrue. Zero
  means keep forever, and each statement is batched so a backlog drains over
  ticks rather than holding row locks.
- login_events.ip_hash is no longer written, and legacy values are scrubbed:
  nothing ever read it, so it was retention without a purpose.
- Tracing spans log user_id only, not the raw npub and username.
- secret-scan.sh matches the coverage its comment claims.

Also fixes the test suite, which did not compile on this commit: NewSession
gained an id in c64cd9b and two call sites were never updated. mint_session now
binds one session_id for both the token and the row, since a different id there
would silently recreate the bug c64cd9b fixed.
2026-08-13 10:31:02 -04:00
jwinterm a80fa46daf feat(config): configurable restart_apply_mode + graceful shutdown (Stage 3b)
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
  DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
  (AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
  restart-required change (systemd Restart=always brings it back with the new config);
  `manual` just flags restart_pending. The console warns before an auto save.

Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
2026-07-18 14:08:33 -04:00
jwinterm 4c83e53839 feat(config): effective config via ArcSwap + DB overlay at boot (Stage 2)
Wire the settings overlay into the live config path:
- AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) +
  config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every
  config read goes through it (state.cfg().field), so a runtime-safe operator edit is
  seen on the next request. One Config type, no parallel path.
- Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build
  every client/worker/router from the EFFECTIVE config. A persisted overlay that no
  longer validates is ignored (boot env-only + loud log), never bricks. Env-only when
  ADMIN_KEY_INTEGRITY_SECRET is unset.
- Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a
  reference outlives the statement; integrity_secret returns owned String.
- Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep).

Behavior-preserving: with no operator_settings rows, effective == env config.
cargo check --all-targets clean; lib tests 256/0.
2026-07-18 06:00:16 -04:00
jwinterm 5d8d5e193f feat(tips): stage 5 — GC workers + fail-closed flag + capabilities + openapi
- GC: cancel_old_drafts (draft >7d -> cancelled), cancel_stuck_pending_confirmation
  (>7d), log_stuck_claiming (read-only, >15min). Draft GC hourly + lifecycle GC
  5min, spawned behind FEATURE_TIPS. Funded-but-unattached safety net is the
  7-day window + clawback incl 'cancelled'.
- config validate(): tips enabled fails closed unless a supported chain
  (btc/ltc/xmr/wow) is on AND tip_share_base is set.
- capabilities: advertise tips=true only when actually serveable.
- regenerated openapi.json (8 /tips/social/* endpoints).

Public-tips port COMPLETE (stages 1-5). Full suite green.
2026-07-10 15:51:20 -04:00
jwinterm b7e3510553 feat(tips): stage 4 — claim / clawback / confirm-sweep + sweep reconciler (money-out)
Escrow settlement, audited guards ported verbatim:
- mark_tip_claiming: status IN('pending','claiming') AND confs>=required AND
  funding_amount_verified AND sweep_confirmed_at IS NULL (double-claim guard).
- confirm_tip_sweep: RECORD-not-settle, first-recorder-wins (COALESCE +
  sweep_txid IS NULL OR =$3) — RBF-steal safe; reconciler owns claiming->claimed.
- confirm_sweep_onchain: SOLE writer of 'claimed', one-way status='claiming' AND
  sweep_confirmed_at IS NULL lock; settles only after on-chain sweep confirms to
  per-asset depth (btc/ltc 1, wow 4, xmr 10).
- clawback: 5-status set incl 'cancelled', blocked once sweep_confirmed_at set.
- revert_sweep_on_reorg: only from 'claimed', re-opens to 'claiming' + clears txid.
- sweep_reconciler: address-history-driven (not recorded txid), pick_winner
  (earliest height, lexi-smallest tie), pg advisory locks on a pinned conn for
  single-instance safety; reorg pass reverts only on affirmative vanish, never on
  probe error. DMs stubbed, exactly-once stamps kept.
- POST claim/confirm-sweep/clawback endpoints + openapi.
- 16 integration + 12 reconciler unit tests; build clean; SocialTipRow dead_code
  allow removed (all fields now read).
2026-07-10 15:34:14 -04:00
jwinterm b63cac19e2 feat(tips): stage 3 — attach-funding + funding-verification worker (money-in)
- attach_funding_to_tip: atomic guarded UPDATE (COALESCE + CASE), idempotent on
  same txid, 400 on a different txid; non-drafts land pending_confirmation so the
  verifier (not the caller) owns the ->pending transition.
- worker (src/tips/confirmation.rs): XMR/WOW confirmation counting via LWS;
  amount verification for BTC/LTC (Electrum full history, confirmed-only) +
  XMR/WOW (classify_funding_amount, separate-then-subtract net). Verified->pending,
  Short->funding_mismatch; Unfunded/Drained/LwsError never mutate status (outage-
  safe). Spawned behind FEATURE_TIPS on a 60s loop w/ catch_unwind guard.
- POST /tips/social/:id/attach-funding endpoint + openapi.
- 5 DB tests + 6 classify unit tests; build clean.
2026-07-10 14:53:46 -04:00
jwinterm 635cb64601 fix(relay): harden admission (fail-safe, loopback-only, bounded, size cap) [review]
Adversarial review of the relay admission surface:

- FAIL-SAFE on admission death: nostr-rs-relay fails OPEN if its gRPC admission
  server is unreachable, so a dead admission = an unrestricted relay. The backend
  now EXITS if the admission service stops (supervisor restarts a clean pair);
  documented in RELAY.md. Pin the relay image to a digest (compose note).
- Registration-oracle protection: validate() now refuses a non-loopback
  RELAY_ADMISSION_BIND unless RELAY_ADMISSION_ALLOW_PUBLIC=true (+ test).
- No N+1 on crafted events: recipient p-tags are capped (20, deduped) and resolved
  in ONE batched query (db.any_registered_npub) instead of one-per-tag.
- Defence-in-depth size cap: admission denies events over max_event_bytes before
  any DB work (the relay enforces its own limit too).
- Docs: RELAY.md Security section (fail-open, loopback oracle, default PoW=0 spam
  footgun, client sender-verification); config.toml relay port 8088 (not 8080);
  Docker Hub image; .env.example RELAY_ADMISSION_ALLOW_PUBLIC.

Note: the client-side sender-impersonation blocker is fixed separately in the
monorepo (verifying gift-wrap unwrap).
2026-07-02 08:35:37 -04:00
jwinterm 63d2c75194 feat(relay): gRPC event-admission (nauthz) service enforcing the write policy
nostr-rs-relay's [grpc] event_admission_server calls Authorization.EventAdmit
once per inbound event; we answer PERMIT/DENY so the write policy is enforced at
ingest (dynamic, per-event — a static pubkey whitelist can't do inbox/PoW).

- proto/nauthz.proto (matches nostr-rs-relay's contract) compiled by build.rs;
  protoc is vendored (protoc-bin-vendored) so a source build needs no system
  protobuf-compiler. tonic/prost added (reuse the hyper/tower/h2 stack axum
  already pulls in); server-only codegen.
- infra/relay/nauthz: AdmissionService resolves author + recipient (p-tag)
  registration via the DB, runs the pure policy engine, returns the decision;
  served on a loopback socket (spawned in main only when relay enabled + policy
  != open), mirroring the admin plane. Fail-closed on a malformed request.
- db: is_registered_npub(hex) — a no-PII membership check safe to call per event.
- tests/relay_admission.rs: L1 handler tests (is_registered_npub membership +
  outbox/inbox/deny-non-giftwrap/deny-unregistered-recipient through the real
  event_admit handler against live Postgres).
2026-07-02 06:46:59 -04:00
jwinterm a8f1a9e50c feat(relay): messaging-plane config + provider seam + pure admission policy engine
First slice of the optional Nostr relay (messaging plane), behind a swappable
seam mirroring the PaymentProvider pattern so nostr-rs-relay can be replaced by
another relay (strfry/external) or another messaging protocol later.

- config: MessagingConfig { relay: RelayConfig } — RELAY_ENABLED (off by default),
  mode (bundled|external), advertised ws(s):// URL, write_policy
  (inbox-outbox default | author-allowlist | open), inbound_pow_bits (NIP-13),
  admission_bind, resource caps. validate() fail-closes (URL required + ws(s)://
  + wss in prod, known policy, PoW <= 40, admission bind for non-open). +7 tests.
- infra/relay: RelayProvider trait + from_config (feature-gated, fail-closed) +
  the default nostr-rs-relay/external adapter; AppState.relay wired
  (lib/main/tests).
- infra/relay/policy: PURE admission engine (WritePolicy, decide, NIP-13
  leading_zero_bits): open permits all; author-allowlist gates on a registered
  npub; inbox-outbox permits a registered author's outbox OR a kind-1059 gift-wrap
  addressed to a registered user (inbox), the external-inbound branch optionally
  behind a PoW gate. Registration lookups are resolved by the caller and passed as
  booleans so the decision is pure + fully unit-tested (10 tests).

The gRPC event-admission service that calls this engine, capabilities/NIP-05
advertising, packaging, and the client DM stack land in the following slices.
2026-07-02 06:29:38 -04:00
jwinterm 54ae9fadac feat(registration): pay-to-register via pluggable payment provider (gate #3)
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an
invoice on an external, non-custodial payment processor before it can
register. Pull model — the grant reads the processor's authenticated API as
the source of truth, so there is no inbound webhook to secure and a settled
invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass
it, exactly like the invite and PoW gates.

- infra/payment: a pluggable PaymentProvider seam (neutral Invoice /
  InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that
  also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
  plugin, so "operator picks any coin" costs one adapter. A future ETH/native
  adapter (BTCPay serves neither) implements the same trait. Only `Settled`
  grants (unknown status fails safe); the API key rides in a redacting Secret
  and hostile responses are size-capped.
- payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume
  (UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one
  invoice grants at most one registration and never a different identity.
- POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor
  `Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id
  on the register request.
- Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation
  (bars a 0-conf settled-then-double-spent free registration), bounded expiry.
  /capabilities advertises payment_required + price; OpenAPI + .env.example.

Tests: adapter status/speedPolicy mapping + config validation (unit), and L1
binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected).
Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS
lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full
adapter contract, rotation-branch bypass guard) are addressed here.
2026-06-30 21:30:12 -04:00
jwinterm b03b9cb90d feat(erasure): self-service account erasure + export [operator §5]
The last operator subsystem: a user who no longer trusts the backend can reach in
and delete their data. Action-bound and two-phase.

- migration 20260626000003: erasure_requests (user_id ON DELETE SET NULL so the
  completed tombstone survives the user delete; one-live-request-per-user partial
  unique index; due index).
- Proof model (§5.2): every action is a fresh NIP-98 signed action (purpose
  erasure_request/confirm/cancel/export) bound to a single-use challenge nonce +
  canonical descriptor + (confirm/cancel) the explicit erasure_id. A session
  token is NEVER sufficient. Erasure targets only the users.id the proof's key
  resolves to (via linked nostr_pubkey); leaked identifiers are never selectors.
  Nonce consumed AFTER signature checks (forged sig can't burn a victim's nonce).
- Public routes (proof-gated): POST /account/erasure/challenge, /account/erasure
  (request; ABSENT-user path returns identical shape — no account-existence
  oracle), /account/erasure/:id/confirm (revokes live sessions), DELETE
  /account/erasure/:id (cancel during grace), POST /account/export (see-before-
  delete; omits view keys; audited).
- Execution (§5.7): a background sweeper claims confirmed-past-grace rows
  (FOR UPDATE SKIP LOCKED) and runs ONE fail-closed tx: purge login_events
  (the origin/asset/time quasi-identifier; default) or anonymize; scrub audit
  PII; DELETE users (schema cascades wallets/sessions/keys/owned slatepacks,
  SET NULL on counterparty/audit/login links); tombstone; hash-chained
  account_erasure_completed audit.
- config: ERASURE_GRACE_PERIOD_HOURS (72h default); validate() requires the
  integrity secret + PUBLIC_API_URL when erasure is enabled.
- 1 L1 test: export -> request -> confirm -> execute -> account gone, plus the
  absent-user constant-shape path.

Scope (v1, documented): live primary. No at-rest view key exists to crypto-shred
(stateless-forward), so backup residue is bounded by retention-window docs, not
crypto-shred (non-v1 per decisions ledger). Deferred: LWS de-registration queue +
read-back probe (operators run their own LWS), audit cross-reference scrub on
OTHER users' rows, restore_attempt salted-rotated fingerprint, retention sweepers,
NIP-17 DM warning, in-flight-value blockers (no swaps/tips in the clean core),
hard per-day export rate cap, /admin/erasure/stuck observability. Routes are off
the public OpenAPI for now (stable contract; annotate later).
2026-06-27 20:43:10 -04:00
jwinterm e618574ee7 fix(operator): harden S3/S2/S4 per adversarial review
9 confirmed findings (review wbdf3c0sd), all fixed:

S4 (capabilities):
- REGRESSION: /capabilities reported grin_relay:false for a working relay (S4
  wrongly coupled it to Grin chain serviceability). The relay is a non-custodial
  mailbox — wallet broadcasts locally — so it is decoupled again. Removed the now-
  dead grin_relay downgrade note.

S3 (bootstrap latch):
- A DELETE of the latch row escaped Locked without Tampered detection on a
  CLI-bootstrapped-but-no-users instance (adoption keyed off `users` only). Boot
  now adopts on has_any_users() OR has_any_admin_keys(), so a deleted latch
  re-latches Locked instead of silently dropping to uninitialized.
- bootstrap_admin's single-bootstrap guard was a check-then-insert race (two
  concurrent `setup` could seed two admins). It now takes the admin-keys mutation
  advisory lock (shared with add/revoke/rotate) at the top of its tx.

S2 (admin posture + landing):
- Hard bind guard matched loopback by string prefix ("localhost".starts_with) —
  "localhost.evil.com" slipped past. Now resolves via to_socket_addrs and requires
  EVERY resolved addr to be loopback.
- host_allowed's IPv6 loopback check was an unanchored prefix ("[::1]" matched
  "[::1].evil"); now anchored to exactly "[::1]" / "[::1]:port". +regression tests.
- Anti-clickjacking headers (X-Frame-Options/CSP) are now applied on the 403
  Host-reject path too (computed on both branches, headers added once).
- GET /api/v1/server-info now returns a BARE 404 when landing is off (the
  {error,code} envelope itself hinted the route exists).
- Public landing `chains` now uses the SAME serviceability predicate as
  /capabilities (no over-advertising an unserviceable chain).
- Removed the dead/misleading PUBLIC_EXPOSE_FEATURES toggle (never read; the
  feature tuple is intentionally never emitted anonymously).

Dismissed after verification: latch chains-by-flag (dup), title length bound
(operator-owned + escaped), prices:true with empty PRICES_ASSETS (documented).
140 lib + 140 integration green, no openapi drift, scans clean.
2026-06-27 19:44:08 -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 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 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
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 d8b6058d40 feat: fiat price feed with per-feed operator control (GET /api/v1/prices)
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.
2026-06-25 16:41:40 -04:00
jwinterm c558e9d7bd feat: per-IP rate limiting (tower_governor tiers) + ConnectInfo wiring
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.
2026-06-23 19:02:03 -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 b7e3199200 feat: wire chain clients into AppState (feature-gated)
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).
2026-06-23 10:39:25 -04:00
jwinterm 611e02e4de test: L1 integration harness (Postgres-backed, fundless)
- 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.
2026-06-22 18:54:24 -04:00
jwinterm fb3c547d93 feat: website auth, username + key registration, NIP-05 directory
- api/website.rs: sign-in-with-wallet (dapp connect). challenge bound to origin
  + short TTL; verify is single-use (remove-before-verify, race-safe), expiry-
  checked, signature via Result<(),AppError> verifiers (no oracle), resolves an
  EXISTING user by the proven key's pubkey_hash (never mints identity), mints a
  Platform::Web session.
- api/users.rs: username set/get + by-username lookup (JWT-gated, 409 on taken,
  constant-shape lookup); per-asset key registration bound to the JWT user
  (no spoofable user_id) + public key-directory reads.
- api/nip05.rs: /.well-known/nostr.json directory (username -> nostr_pubkey).
- Wired into api/mod.rs; website+users nested at /api/v1, nip05 at root; all
  paths/schemas registered (openapi.json now 17 endpoints).
- main.rs: periodic GC of expired web challenges (bounds the in-memory store).

Adversarially reviewed (register_key authz + website identity-binding both
hold; no critical/high). 69 tests green. Deferred LOW: per-IP rate-limit on the
website routes lands with the router-assembly unit.
2026-06-22 18:13:47 -04:00
jwinterm be23042c0c feat: core auth handlers (register, restore, pow, refresh, logout, me, nostr)
api/middleware.rs: Bearer access-token extraction; client_ip() trusts XFF only
behind config.trusted_proxies (fail-closed against spoofing).
api/auth.rs: extension_register (PoW-gated; derivation-rotation requires a
signature from the on-file key, no fingerprint hijack), check_restore (per-IP +
per-fingerprint rate limits, enumeration/timing-resistant), pow_challenge,
refresh_token (re-checks active session + revoke-then-issue rotation, race-safe),
logout, get_me, nostr_login (login-grade NIP-98, never creates a user, bound to
config public_api_url), nostr_link (JWT + state-change verify_signed_action,
fail-closed nonce stub) + DTOs + routes().
Wired into api/mod.rs + nested at /api/v1 in main.rs; all 8 paths registered in
ApiDoc so openapi.json covers the auth surface (was vacuous).

Drafted+hardened via draft->adversarial-review->revise workflow; independently
re-reviewed (no critical/high). 58 tests, all gates green.

Known LOW (tracked): concurrent first-registration of the same new pubkey_hash
can 500 instead of idempotent re-read; client-retryable.
2026-06-22 17:47:20 -04:00
jwinterm 99cfc49cb2 feat: AppState + server wiring (config, pool, migrations, session manager)
- AppState (lib.rs): Config + Database + SessionManager + in-memory web-challenge
  store; injected into handlers via State<Arc<AppState>>
- main.rs: fail-closed Config::from_env, pg pool, run embedded migrations on
  start, build state, serve /health (auth/wallet routes added as handlers land)
- Cargo.toml: sqlx 'migrate' feature for embedded migrations
2026-06-22 14:36:51 -04:00
jwinterm 143f00701b chore: initial foundation — server skeleton, autodocs, CI
- minimal Axum server exposing GET /health
- OpenAPI contract generated from handlers (gen_openapi -> openapi.json),
  drift-gated in CI so the spec can never silently diverge from the code
- CI: secret-scan + gitleaks, openapi drift gate, fmt/clippy/build/test/doc-test, cargo-deny
- Cargo.lock tracked for reproducible builds; publish=false; pinned toolchain
- README, SECURITY.md, deny.toml, .env.example
2026-06-22 06:18:57 -04:00