Commit Graph
14 Commits
Author SHA1 Message Date
such-gitea 985a05adf8 fix(website): resolve sign-in by the asset that signed, not only the BTC hash
website_verify resolved every asset through get_user_by_pubkey_hash, but
users.pubkey_hash only ever holds the BTC key's hash. On this backend only BTC
could ever resolve; ltc, xmr, wow and grin were structurally impossible, and the
failure was indistinguishable from an unregistered user, so the page told people
to register with an extension they already had.

That did not surface while smirk.cash pointed at the legacy backend, which
resolves by asset key against its own database. It becomes the whole bug the
moment the site is repointed here, so it has to land with that change, not after.

get_user_by_pubkey_hash stays as the BTC fallback: of 95 users on this backend,
84 arrived through migrate-legacy, which imported identity without keys, and
would otherwise stop resolving entirely.

The rejection message is unchanged and still says nothing about whether an
account exists.
2026-09-14 22:07:20 -04:00
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 cf85dce89f privacy: drop raw-IP PII columns and the dead wallets table
The published privacy policy states that IP addresses are retained only as a
salted one-way hash for rate limiting. That was true of the login-event path
but not of sessions.ip_address or audit_logs.ip_address, both of which stored
a raw client IP tied to user_id on every login.

Drop both columns and all writer/model code, so the schema enforces the policy
rather than relying on convention. The hashed rate-limit path (login_events,
restore_attempts) is unchanged and remains the only IP retention.

Also drops the `wallets` table, which was created in the initial schema and
never read or written: a latent user_id -> address / view_key store.

The operator forensic trail (admin_audit_logs, admin_sessions) deliberately
keeps its raw IP. Privileged-action forensics is a different threat model and
is not covered by the user-facing claim.
2026-07-28 16:01:07 -04:00
jwinterm 586d29efe7 chore: cargo fmt with current stable rustfmt (repo-wide)
The repo pins channel=stable (no rustfmt version), so CI now pulls
rustfmt 1.9.0, which reformats code last touched by an older rustfmt.
main itself failed `cargo fmt --check` under the current toolchain;
this is a formatting-only sweep across 24 files to make the fmt gate
green. No behavior change; tests + clippy -D warnings still pass.
2026-07-19 03:44:47 -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 2f610aecb2 test(harness): expose response headers + config override; verify per-route CORS
The integration harness dropped response headers (returned only status + JSON body),
which is exactly why CORS/security-header behavior couldn't be tested. Add:
  - request_full(method, uri, extra_headers, body) -> (status, HeaderMap, body):
    sends arbitrary request headers (e.g. an Origin) and returns response headers.
  - try_app_with(|cfg| ...): tweak the validated Config before building the router
    (e.g. restrict cors_allowed_origins). try_app now delegates to it.

New tests/cors.rs proves the well-known fix end-to-end: with the API CORS restricted
to one origin, /.well-known/nostr.json still serves ACAO: * to a foreign origin
(federation works) while /api/v1/* does NOT — the two policies are genuinely
distinct. Verified against Postgres (passes); existing auth_flow/capabilities unaffected.
2026-07-08 09:23:17 -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 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 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 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