Commit Graph
32 Commits
Author SHA1 Message Date
jwinterm dec9994e06 fix(config): fail closed on POW_REQUIRED without FEATURE_POW; harden premium key check
- Reject boot when POW_REQUIRED (or the per-pubkey PoW list) is set while
  FEATURE_POW is off: the gate would silently never apply and /capabilities
  would report pow_required:false — a signup endpoint the operator believes
  is protected but isn't.
- Validate the premium PAYMENT_API_KEY through the same require_secret path
  the pay-to-register flow uses, so a placeholder key cannot reach production.
- Advertise the premium-post write policy in MessagingCapability and
  regenerate openapi.json to match.
- Give the premium integration fixtures unique per-run ids so the suite is
  re-runnable against a persistent local test database.
2026-07-02 19:47:38 -04:00
jwinterm eb98e4d625 feat(premium): recurring tiered relay-posting subscription (premium-post)
A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.

- Admission: a new `premium-post` write policy gates on kind x membership —
  premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
  DM-relay-list) -> free; registered + general -> premium required; external ->
  gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
  fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
  invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
  JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
  extends premium_until in ONE transaction (a mid-flight failure keeps the paid
  invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
  advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
  creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
  expiry processor floors apply on the premium path too (its common case is
  require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
  parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
  expiry, feature gate). OpenAPI + migration updated.

Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
2026-07-02 17:56:44 -04:00
jwinterm b4935b15e8 feat(admin): migrate-legacy — import v0.2.x user identity into v0.3
`smirk-admin migrate-legacy --source-url <pg> [--commit] [--limit N]` copies
user identity from a legacy v0.2.x backend database into this v0.3 DB, so users
keep their handle (and NIP-05 name@domain) when they move to a v0.3 client.

- Identity-only, joined on the derivation-independent seed_fingerprint; also
  carries pubkey_hash (v3 wallets reclaim their row with no rotation step),
  username, nostr_pubkey, wallet_birthday and chain restore heights. Socials/
  tips/keys/wallets are intentionally not carried (no v0.3 home, or re-registered
  by the client on first unlock).
- pubkey_hash + seed_fingerprint are peppered at rest, so the importer builds its
  target with the real SEED_FINGERPRINT_PEPPER (run with the v0.3 .env).
- Idempotent (skips users already present by either peppered identity column),
  dry-run unless --commit, best-effort per row: a transient error or a concurrent
  native onboarding is reconciled, never aborting the pass. Reserved/taken/
  malformed usernames and already-linked npubs are dropped; the user still lands.
- Exports validate_username so the importer applies the identical format +
  reserved-name rules the HTTP handler does.
- docs/operations/LEGACY_MIGRATION.md + scripts/test-legacy-migration.sh, a
  self-contained E2E that seeds alice/bob/carol plus edge cases and asserts
  import, peppering, collision handling, and idempotency.

Reviewed adversarially (correctness / security / operational) before commit.
2026-07-02 13:49:45 -04:00
jwinterm f1872c781c feat(relay): advertise the relay via /capabilities + NIP-05 relay hints
So clients can discover + connect to the relay (and self-adapt when it's off):

- /capabilities: features.nostr_relay + a messaging{ relay_url, write_policy,
  inbound_pow_bits, supported_nips } object, with the same config-presence
  downgrade as chains/nostr_identity (advertised only when enabled AND a URL is
  configured — never advertise a relay clients can't reach). OpenAPI regenerated.
- nip05.rs: /.well-known/nostr.json relay hints are now config-driven — this
  instance's own relay (the user's DM inbox) first when enabled, then the public
  interop relays (damus/nos.lol) so cross-wallet delivery still works. Replaces
  the hardcoded-only default.
2026-07-02 07:00:02 -04:00
jwinterm 1181b5938a fix(auth): canonicalize verified Nostr pubkey + harden the link/login grade
Adversarial review (Identity Phase 1 flow) findings:

- Canonicalize the pubkey returned by verify_nip98 / verify_signed_action to
  lowercase hex. BIP-340 hex-decode is case-insensitive, so an UPPER-case-hex
  pubkey is a fully self-consistent, verifying event — but stored/looked-up
  verbatim it would let ONE key bind to two accounts via case variance (bypassing
  the UNIQUE index + 409) and yield non-canonical NIP-05 output. Now every
  downstream store/compare/lookup is on the canonical form. +KAT.
- nostr_link records a login event on success: a session-authed link (including a
  rotation that REPLACES an existing npub) is a security-relevant binding change
  and must not be silent.
- Tighten NIP98_LOGIN_MAX_AGE_SECS 60 -> 30s: a captured login token is a bearer
  credential within the window; match it to the state-change grade.
2026-07-02 05:43:20 -04:00
jwinterm 8d4f78a8a3 feat(auth): wire the Nostr link nonce — link-challenge endpoint + atomic consume
The link flow was stubbed: consume_link_nonce always returned false and no nonce
issuer existed, so nostr_login worked only for already-linked npubs and nothing
could link one. Complete it:

- GET /auth/nostr/link-challenge (authenticated) issues a single-use nonce via
  the existing challenge store, bound to the caller (subject) + the nostr_link
  purpose, 5-minute TTL.
- nostr_link now consumes it atomically (DELETE … RETURNING, so a replay loses
  the race), cross-checks the nonce was issued to THIS user, then verifies the
  signed-action proof (unchanged) and stores the npub.
- Route registered + OpenAPI regenerated (no drift).

tests/nostr_auth.rs: pins the empty-body descriptor hash as a cross-impl KAT (the
wallet binds the identical value), and covers a full challenge→link round-trip
plus replay and wrong-user-nonce rejection.
2026-07-02 04:36:40 -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 56238dcac8 feat(restore): default create-only + PoW-priced restore depth (gate #2)
Default restore policy is now create-only — which betrays no self-sovereignty,
since that lives at the ECOSYSTEM level (run your own backend). On top of the
create-only|bounded|unlimited depth gate, restore depth can be PRICED in hashcash
PoW so an importer pays compute proportional to the chain-scan cost they impose
on the operator (deeper restore => more work).

- core::restore_pow: a self-contained hashcash bound to (asset, address,
  start_height) — a solved nonce is valid only for that exact restore, so no
  challenge state, no replay, no depth-downgrade. Difficulty = leading zero bits.
- RestoreConfig: pow_free_days / pow_days_per_bit (0 = off) / pow_max_bits curve;
  required_restore_pow_bits + enforce_restore_pow (literal errors, no oracle).
- Enforced at xmr/wow register + grin scan via a new restore_pow_nonce; the curve
  is advertised via /capabilities so the wallet computes + solves it.
- Default WALLET_RESTORE_POLICY flipped bounded -> create-only.
- Unit tests (curve scaling/cap, off-when-zero, valid/invalid nonce, hashcash
  roundtrip + param-binding); .env.example; OpenAPI drift clean.
2026-06-30 18:35:20 -04:00
jwinterm 610819bb5d feat(registration): composable gate framework + invite codes (gate #1)
First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.

- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
  no pepper needed). Redemption is an atomic single-statement claim
  (UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
  submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
  ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
2026-06-30 17:14:53 -04:00
jwinterm 99e1727204 feat(lws): balance as a verification passthrough (spent_outputs + mempool pending)
POST /wallet/lws/balance returned a server-summarized {unlocked, locked} where
unlocked = total_received - locked OVER-COUNTED: a non-custodial backend cannot
verify spends without the spend key, so it never subtracted them. Reshape to the
wallet's verification-passthrough contract — aggregate get_address_info +
get_address_txs (parallel) and return total_received, locked_balance,
pending_balance (0-conf summed directly from mempool rows: webhook-free, cleaner
than the private backend's DB table), and spent_outputs[] for client-side
key-image verification. The wallet nets its own spends (privacy-correct).
spent_outputs fixes the over-count immediately; pending_balance activates once
the parity LWS mempool feature is deployed. Adversarially reviewed (3 lenses,
0 confirmed findings).
2026-06-29 16:13:46 -04:00
jwinterm 224ef36ad3 feat(restore): operator-configurable wallet restore policy
How far back a wallet may ask THIS instance to scan on import is an operator
decision — the backfill cost lands on its LWS/node. Add WALLET_RESTORE_POLICY
(create-only | bounded | unlimited, default bounded) + WALLET_MAX_RESTORE_DEPTH_DAYS,
advertise it via /capabilities so the wallet adapts its import UX, and enforce
it at the scan path (xmr/wow register, grin scan): a too-deep start_height is a
ValidationError naming the bound, never the caller's value. Create needs no
check (no tip lookup). Self-sovereignty stays intact at the ecosystem level —
a deeper restore means self-hosting or a looser operator. Fail-closed config
validation; a unit test per policy; OpenAPI drift clean.
2026-06-28 22:15:42 -04:00
jwinterm ff89ca6584 feat(wallet): unified GET /wallet/heights (tip per chain)
The wallet's ChainProvider seam fetches all chain tips in one call
(getBlockchainHeights) and selects per asset; the backend exposed per-chain
height endpoints but no aggregate, so the client's call 404'd. Add a
best-effort /wallet/heights returning {btc,ltc,xmr,wow,grin} concurrently — a
disabled or unreachable chain is null, never failing the others. JWT-gated,
OpenAPI-annotated (drift clean).
2026-06-28 22:01:14 -04:00
jwinterm 56c8dd463a feat(openapi): document bearer auth on the 28 token-gated routes
The spec carried no security scheme, so generated clients/docs could not tell
which endpoints require a session token — e.g. /wallet/utxo/balance read as
public while its handler enforces auth via extract_user_id_from_token. Add a
`bearer_auth` HTTP/JWT scheme through a Modify addon and annotate every route
that resolves a session; auth-bootstrap, public username/key lookups, and
system routes stay open. Regenerated openapi.json (drift clean).
2026-06-28 21:42:32 -04:00
jwinterm efb54911de docs: scrub private-doc references from source comments (pre-publish)
Source comments pointed at gitignored docs/private/*.md design docs — harmless
(no secret) but dangling references in a public tree. Inlined the rationale and
dropped the pointers. Comment-only.
2026-06-28 08:33:24 -04:00
jwinterm 8120d93fb8 fix(erasure): harden per adversarial review (Subsystem 5)
6 confirmed findings (review wqrd2gka3), 3 root-cause fixes:

- HIGH: execute_erasure had no status recheck and the sweeper's SKIP LOCKED row
  lock was released before execution (separate connection), so (a) a request
  CANCELLED after the sweep's SELECT was still executed, and (b) a fleet could
  double-execute one erasure (duplicate hash-chained audit row, double count).
  Fixed by making execution atomically CLAIM the row as its first statement —
  `UPDATE ... SET status='completed' WHERE id=$1 AND status='confirmed'
  RETURNING user_id` — and skipping (returns false) when 0 rows match. Only the
  first executor of a still-confirmed request proceeds; a cancelled/already-done
  row is a no-op; on any later failure the whole tx (claim included) rolls back.
- account-existence oracle: the absent-user request path returned a FRESH random
  erasure_id + advancing scheduled_for each call, while a real account returned a
  STABLE get-or-create id — a re-call distinguisher. The request response is now
  constant-shape: a STABLE id (real, or a deterministic integrity-secret-keyed
  synthetic for the absent path), a fixed "pending" status, and NO scheduled_for
  (it's meaningful only at confirm, where grace starts, and is returned there).
- config: ERASURE_GRACE_PERIOD_HOURS is bounded (<= 10 years) in validate(), so
  the u64->i64 cast can't wrap negative (defeating grace) and Duration::hours
  can't panic.

Dismissed after verification: ERASURE_ENABLED without FEATURE_NOSTR_IDENTITY
(validate already requires PUBLIC_API_URL; the nostr_identity coupling is a
nicety). Tests extended: cancelled-request-not-executed (HIGH regression),
double-execute no-op. 140 lib + 140 integration green, no drift, scans clean.
2026-06-28 07:59:52 -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 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
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 1e2bdf3f16 feat: GET /api/v1/capabilities (client feature discovery)
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).
2026-06-25 16:27:13 -04:00
jwinterm 707613c92b feat: router middleware (CORS, body limit, timeout, compression, tracing)
Layer global middleware onto build_router (applies to every route, shared by
main + the test harness):
- CORS: configurable allowed origins via CORS_ALLOWED_ORIGINS; empty -> allow
  any origin, which is safe here because auth is a Bearer token (no cookies, no
  ambient credentials). Credentials are never allowed.
- Body limit: 1 MiB global backstop (handlers already validate tighter per
  field); /wallet/lws/submit_tx overrides to ~2 MiB+ for raw Monero tx hex.
- Timeout: 60s request backstop (above the chain clients' ~30s timeouts, so
  their errors surface first; bounds any slow handler). Uses the http-aware
  tower_http TimeoutLayer (-> 408), not tower::timeout (fallible, rejected by
  axum's Infallible Router).
- gzip compression + request tracing (outermost).

Adds cors_allowed_origins config + the tower-http "timeout" feature. Per-IP
rate limiting is deliberately separate (needs ConnectInfo) — next commit.

All gates green; integration tests pass through the layered router (CORS/timeout/
compression/body-limit are oneshot-safe). openapi unchanged.
2026-06-23 18:25:10 -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 e62f1bec8e feat: Grin wallet handlers (view-only, non-custodial)
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.
2026-06-23 16:02:44 -04:00
jwinterm 10b508fa8e feat: XMR/WOW wallet handlers (LWS proxy, stateless-forward)
Second wallet-handler slice. Authenticated, non-custodial, stateless-forward
proxies over the LwsClient: the wallet sends its private view key per request,
the backend forwards it (never stores it; the LWS holds it for scanning), and
the wallet constructs + signs spends locally.

Endpoints (POST, under /api/v1/wallet/lws, asset = xmr|wow in body):
- register     -> add/import account (view key) for LWS scanning (+start_height)
- balance      -> total_received / unlocked / locked + scan state
- history      -> txs incl. mempool pending + candidate spent_outputs
- unspent_outs -> outputs + fee params for client-side spend construction
- random_outs  -> ring decoys (count clamped + fan-out capped in the client)
- submit_tx    -> broadcast a finalized signed tx
- height       -> daemon chain height
- confirmations-> tx confirmation count

- JWT-gated; the view key is skipped from every tracing span (never logged) and
  never stored. Feature-gated dispatch; unknown/disabled asset -> 400.
- Boundary validation: view_key (64 hex), address (bounded), txid (64 hex),
  tx_hex (even hex, <=2 MiB) — all before any LWS call.
- Shared validators in wallet/mod.rs (validate_hex/view_key/cn_address).
- Colliding DTO names disambiguated (Lws-prefixed) so the openapi schema
  registry stays correct; 8 paths + schemas + xmr_wow tag registered; spec
  regenerated, drift-clean.

6 L1 integration tests (auth gate on balance/register; unknown-asset,
bad-view-key, malformed-tx-hex, malformed-txid 400s) + validator unit tests.
2026-06-23 14:32:38 -04:00
jwinterm 1ec4639652 feat: BTC/LTC wallet handlers (electrum proxy, authenticated)
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.
2026-06-23 11:57:43 -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 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