diff --git a/DECISIONS.md b/DECISIONS.md index 7e76a02..9dc7863 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -145,6 +145,14 @@ `advisories` run and report but **cannot block a merge** until the operator adds them to the required set — a branch-protection change, which is the operator's act. Green is not the same as blocking. + - **⚠ SUPERSEDED 2026-07-26 (NA-0678, D614 §4b / OBS-BM). The paragraph above + was true at landing and is false now.** The operator promoted all three + contexts at the NA-0677 closeout: this repository's required set is + `["rust", "public-safety", "advisories"]`, `strict: true`, + `enforce_admins: true` (read back from the API at D614 drafting). The + original text is kept legible rather than rewritten, per the house rule + that a superseded passage stays readable — but it must not be relied on: + **both jobs now block.** - **A pre-commit call site** (`scripts/hooks/pre-commit`, opt-in via `git config core.hooksPath scripts/hooks`) runs the same instrument over the staged set. CI is the enforcement; hooks are not cloned. @@ -157,3 +165,11 @@ `586ae25a…19d57fe0a9b95a51`, 446 lines) and spine NA-0677; qsl-desktop D-0014 (the first landing, which carries the waiver-file case); spine NA-0676/D-1307 (the sanitize that made a whole-tree tier adoptable). + +- **ID:** D-0016 + - **Status:** Accepted + - **Date:** 2026-07-26 + - **Goals:** G1, G4 + - **Decision:** qsl-server gains the ADDITIVE invite-slot subsystem (messaging epic Slice 1; qsl-protocol NA-0678 / QSL-DIR-2026-07-26-614 (D614)): three routes `POST /v1/invite/create`, `POST /v1/invite/redeem`, `POST /v1/invite/revoke`, a slot-scoped admission check on the existing `POST /v1/push`, an `invites` table at `SCHEMA_VERSION` 2, and five new resource controls. **The CLIENT mints the redemption capability and uploads only `SHA-256(cap)`; there is deliberately NO mint endpoint**, so no relay-side path holds a capability in plaintext before a redeemer presents one and a relay operator cannot silently burn an invite it hosts. All three routes are POSTs carrying `invite_id` and every secret in the JSON **body**, never a path or query parameter — `invite_id` IS the mailbox route key, and D-0008/D-0009/D-0010 already retired URI-carried route tokens for exactly this reason. `invite_id`, `cap`, `revoke_token` and the handshake `ticket` are persisted only as SHA-256 digests and compared with the existing `ct_eq_secret` (D-0014) — **no new primitive**. Redemption is an atomic compare-and-set (exactly one winner; every loser gets `ERR_INVITE_ALREADY_USED`) and issues a **one-shot handshake ticket**, without which a push to an invite slot is refused — so the slot accepts exactly one handshake, from the party that actually redeemed rather than from anyone who saw the code and lost the race. A 128-bit `revoke_token` returned once at create authorizes revoke, which is idempotent; without it an open relay would let any code-holder destroy any invite. Consumed and revoked slots are **TOMBSTONED until expiry** with their blobs cleared, so `invite-already-used` stays distinguishable from `invite-not-found` — a deleted slot would report "never existed" when the truth is "someone got here first". The relay stores `bundle` and `invite_sig` as **opaque bytes** and never parses them. `MAX_INVITE_SLOTS` (256/4096) bounds storage and **never evicts** when full; a **GLOBAL** `INVITE_CREATE_BURST`/`INVITE_CREATE_REFILL_PER_SEC` bucket bounds denial — global because an invite has no route token until it exists, so the per-route push bucket structurally cannot cover it. The two are **not substitutes**: operator ruling, *the availability of invite-create is a security property; slot-cap-only is a DoS*. `GET /v1/server-info` gains `invite_v1`, `limits.max_invite_bundle_bytes` and an `invite` object, additively per DOC-SRV-006. **`/v1/push`, `/v1/pull` and `/v1/pull/ack` are UNCHANGED for every route the invite system did not create** — one indexed lookup misses and the pre-existing path runs as before. **Two defects found by the D614 census are fixed here:** (a) the store's schema-version marker was written with `INSERT OR IGNORE`, a no-op on an existing key, so a forward migration never advanced it and D-0011's fail-closed downgrade guard had been inert since the moment it was written — the migration now advances it, with a positive AND negative control; (b) `tests/na0642_durability_restart.rs` was cited as the proof that "a 200 means fsynced" and cannot be — SIGKILL destroys a process, not the page cache, and that suite passes 3/3 with `synchronous=OFF`. Its header comment is corrected and **the test is kept unchanged** for the process-crash durability it genuinely proves; the fsync claim is discharged instead by `tests/na0678_invite_durability.rs`, which counts real fsync syscalls, asserts the fsync precedes the 200 on the wire, and **skips with a stated reason** when `strace` is absent rather than passing silently. + - **Rationale:** The messaging epic's dependency chain requires the relay to expose invite and mailbox primitives before any client can redeem or handshake, and the epic's own scope split makes that ordering the safety property rather than a convenience. Building the invite ingress as new surface — rather than retro-gating the existing mailbox as the lane intent first proposed — is what keeps the shipped qsc client, the spine's pinned in-process e2e, the qsl-attachments interop contract and the live relay working while the client-side slices are still unwritten; retro-gating would have inverted the dependency chain it was meant to serve. Client-side capability minting was ruled after the census found the program authority and the design document specifying different parties: the design's relay-minting sentence was ruled a Director error predating the settled commitment architecture, and client-minting is strictly stronger at no cost. Wire surface is a recorded-decision area per D-0009/D-0010/D-0011/D-0012, so the addition is recorded here; governance authority lives in qsl-protocol. + - **References:** qsl-protocol NA-0678 / QSL-DIR-2026-07-26-614 (D614) / D-1310, D-1311; `DESIGN_invite_system_v1.md` (operator-ratified, §3 corrected 2026-07-26); D-0011 (the durable store and its downgrade guard); D-0012 (the capability document); D-0014 (`ct_eq_secret`); `src/lib.rs`; `src/store.rs`; `src/main.rs`; `docs/server/DOC-SRV-007_Invite_Slot_Contract_v1.0.0_DRAFT.md`; `tests/na0678_invite_slots.rs`; `tests/na0678_schema_version.rs`; `tests/na0678_invite_durability.rs`; `tests/na0642_durability_restart.rs` (header comment only); `tests/na0652_server_info.rs` (the two exact guards); `README.md`; `packaging/systemd/relay.env.example`; `TRACEABILITY.md` diff --git a/README.md b/README.md index a0fcfb1..5cd3f48 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ Transport-only relay for QSL demos. It forwards/stores **opaque** payloads and m - Canonical pull: `GET /v1/pull?max=N` with `X-QSL-Route-Token: ` -> JSON `{ "items": [ { "id": "", "data": [, ...] }, ... ] }` (200) or 204 if empty - Optional `X-Msg-Id` supplies an opaque message identifier. It is not an idempotency key: duplicate values are accepted as separate queued messages. Accepted message IDs are logged as non-secret operational metadata, so clients must not put secrets in this header. - Legacy path-token routes are retired. `POST /v1/push/{channel}` and `GET /v1/pull/{channel}?max=N` are no longer supported because they carry the route token in the request URI. +- Invite slots (NA-0678): `POST /v1/invite/create`, `POST /v1/invite/redeem`, `POST /v1/invite/revoke`. All three are POSTs carrying `invite_id` and any secret in the JSON **body** — never in a path or query parameter, for the same reason the legacy route-token paths above were retired. + - `create` accepts `{invite_id, cap_hash, expiry, bundle_b64, invite_sig_b64}` and returns `{revoke_token}`. The **client** mints the capability and uploads only its SHA-256; the relay never holds a capability in plaintext before a redeemer presents one, and **there is no mint endpoint**. + - `redeem` accepts `{invite_id, cap}` and returns `{bundle_b64, invite_sig_b64, ticket}`. Consumption is an atomic compare-and-set: exactly one redemption of a slot can win, and every other gets `ERR_INVITE_ALREADY_USED`. + - `revoke` accepts `{invite_id, revoke_token}` and is idempotent. + - The `ticket` is a **one-shot** credential for the handshake push: `POST /v1/push` to an invite slot requires `X-QSL-Invite-Ticket`. Pushes to routes that are not invite slots are unaffected. + - The relay stores `bundle` and `invite_sig` as **opaque bytes** and never parses them. Consumed and revoked slots are **tombstoned until expiry** (blobs cleared) so that "already used" stays distinguishable from "never existed". ## Behavior and limits - `MAX_BODY_BYTES` (default 1 MiB) → 413 + `ERR_TOO_LARGE` @@ -28,6 +34,9 @@ Transport-only relay for QSL demos. It forwards/stores **opaque** payloads and m - Missing limit values use defaults. Non-numeric values fail startup with deterministic config errors. Zero values fail startup for `MAX_BODY_BYTES`, `MAX_QUEUE_DEPTH`, `MAX_ROUTE_COUNT`, `PUSH_RATE_BURST`, and `ROUTE_IDLE_TTL_MS`; `PUSH_RATE_REFILL_PER_SEC=0` is allowed for deterministic no-refill operation. Values above the built-in ceilings are capped. - `RELAY_TOKEN` is optional. When set, canonical push/pull require `Authorization: Bearer ` and reject missing or invalid bearer tokens with 401 `ERR_UNAUTHORIZED` before mutating queues. When unset or empty, relay auth is disabled and route-token header checks still apply. - Unknown pulls return 204 without creating route slots. Draining a route to empty removes the live slot, releasing global route capacity and per-route rate accounting. +- `MAX_INVITE_SLOTS` (default 256, ceiling 4096) caps live invite slots; beyond it, `create` returns 429 + `ERR_INVITE_CAP_FULL` and **never evicts an existing slot** — an eviction path would let an attacker delete other people's invites. +- `INVITE_CREATE_BURST` (default 32) and `INVITE_CREATE_REFILL_PER_SEC` (default 1, `0` allowed) provide a **global** invite-create token bucket returning 429 + `ERR_RATE_LIMITED`. It is global rather than per-route because an invite has no route token until it exists. The cap and the bucket are both required and are not substitutes: the cap bounds storage, the bucket bounds denial. +- `MAX_INVITE_BUNDLE_BYTES` (default 16384, ceiling 65536) → 413 + `ERR_INVITE_TOO_LARGE`. `MAX_INVITE_EXPIRY_SECS` (default 259200 = 72 h, ceiling 30 days) clamps a requested expiry to what this relay offers. - Rate and global route-cap controls are minimal local in-app hardening primitives. They do not approve production deployment, and reverse proxy / edge rate limiting remains a separate deployment layer. ## Run (local) diff --git a/TRACEABILITY.md b/TRACEABILITY.md index 9e2fcbf..8fd4451 100644 --- a/TRACEABILITY.md +++ b/TRACEABILITY.md @@ -21,3 +21,4 @@ - NA-0012 implementation — `src/lib.rs`; `tests/relay_smoke.rs`; `README.md`; `docs/server/DOC-SRV-003_Relay_Inbox_Contract_v1.0.0_DRAFT.md`; `packaging/runbook_ubuntu.md`; `scripts/check_relay_compatibility.sh`; `scripts/ci/test_relay_deploy_compatibility_guard.sh`; `scripts/verify_remote.sh`; `DECISIONS.md` — route-token URI compatibility is retired, canonical header carriage remains authoritative, and deployment guards now fail when the legacy path-token surface is still enabled. - NA-0012 DONE — PR #45 (https://github.com/QuantumShieldLabs/qsl-server/pull/45) merged (merge SHA 550bd3b0ea9916fb892e8468589727fa35e33720); legacy `/v1/push/:channel` and `/v1/pull/:channel?max=N` ingress is now retired outright, canonical header-carried routing remains authoritative, relay auth and transport-only semantics stay unchanged, and the repo-local queue returns truthfully to `READY=0`. - NA-0670 implementation — `src/lib.rs` (`auth_ok` now folds via the new constant-time `ct_eq_secret`; same-length wrong-token reject test added); `DECISIONS.md` (Decision D-0014); `TRACEABILITY.md` — constant-time bearer-token comparison (2026-07-22 independent audit finding C-2), governed by qsl-protocol NA-0670 / QSL-DIR-2026-07-23-606 (D606) / D-1297. PR #64 (https://github.com/QuantumShieldLabs/qsl-server/pull/64). +- NA-0678 implementation — `src/lib.rs` (three additive `/v1/invite/*` routes, the slot-scoped push admission check, the global create-rate bucket, the server-info invite fields); `src/store.rs` (the `invites` table, `SCHEMA_VERSION` 2, the migration that now ADVANCES the stored marker, invite expiry sweep); `src/main.rs` (five invite config knobs); `docs/server/DOC-SRV-007_Invite_Slot_Contract_v1.0.0_DRAFT.md` (new); `tests/na0678_invite_slots.rs`, `tests/na0678_schema_version.rs`, `tests/na0678_invite_durability.rs` (new); `tests/na0652_server_info.rs` (both EXACT guards moved in lockstep); `tests/na0642_durability_restart.rs` (header comment corrected — the test body is unchanged); `README.md`; `packaging/systemd/relay.env.example`; `DECISIONS.md` (Decision D-0016, plus a mark-don't-rewrite correction to D-0015 recording that `public-safety` and `advisories` are now REQUIRED) — messaging epic Slice 1, the relay's invite-slot subsystem, governed by qsl-protocol NA-0678 / QSL-DIR-2026-07-26-614 (D614) / D-1310, D-1311. ⚠ ENG-0066 (this file's lapse for NA-0642/0652/0655/0670-era decisions) is NOT addressed here: D614 forbids the backfill in-lane so a wire-contract change is not buried under a documentation sweep. The backfill remains owed as its own micro-lane. diff --git a/docs/server/DOC-SRV-007_Invite_Slot_Contract_v1.0.0_DRAFT.md b/docs/server/DOC-SRV-007_Invite_Slot_Contract_v1.0.0_DRAFT.md new file mode 100644 index 0000000..1c25e73 --- /dev/null +++ b/docs/server/DOC-SRV-007_Invite_Slot_Contract_v1.0.0_DRAFT.md @@ -0,0 +1,165 @@ +# DOC-SRV-007 Invite Slot Contract v1.0.0 (DRAFT) + +## Purpose + +Define the invite-slot routes (NA-0678, directive D614; messaging epic Slice 1): +the relay-side primitives by which one party publishes a single-use invite and +another redeems it to reach them. The routes are ADDITIVE: `/v1/push`, +`/v1/pull`, `/v1/pull/ack` and `/v1/server-info` semantics are unchanged for +every route the invite system did not create. + +## Threat model and what the relay is trusted with + +The relay is **not** trusted with identity. It stores the published identity +bundle and its signature as **opaque bytes** and never parses, validates or +branches on their content; the cryptographic meaning lives entirely in the +client. What the relay learns is that an invite exists and when it was redeemed. + +Two consequences are load-bearing: + +1. **The client mints the redemption capability.** It uploads only `SHA-256(cap)`. + No relay-side path holds a capability in plaintext before a redeemer presents + one, so a relay operator cannot silently consume — "burn" — an invite it + hosts. **There is deliberately no mint endpoint.** +2. **Neither `invite_id` nor any secret travels in a URI.** All three routes are + POSTs carrying their values in the JSON body. `invite_id` *is* the mailbox + route key, and D-0008/D-0009/D-0010 already retired URI-carried route tokens + because they leak through proxy logs, shell history and traces. + +At rest, `invite_id` is stored only as its SHA-256 digest (via the same +`route_key_for` used for route tokens), so a stolen store file yields no usable +mailbox keys. `cap`, `revoke_token` and `ticket` are likewise stored only as +digests and compared in constant time with the same `ct_eq_secret` used for the +bearer token (D-0014) — no new primitive is introduced. + +## Routes (normative) + +### `POST /v1/invite/create` +Body `{invite_id, cap_hash, expiry, bundle_b64, invite_sig_b64}` → +`200 {revoke_token}`. + +`cap_hash` is the client's `SHA-256(cap)`, hex. `bundle_b64` / `invite_sig_b64` +are base64url. `expiry` is unix seconds and is **clamped** to +`MAX_INVITE_EXPIRY_SECS` rather than rejected: an over-long request is the client +asking for more than this relay offers, and the relay's ceiling governs. + +`revoke_token` is a 128-bit CSPRNG value returned **exactly once** and stored +only as a digest. Without it, revoke would be unauthorized on an open relay — +anyone who had merely *seen* an invite code could destroy it. + +Rejects: `ERR_INVITE_BAD_BODY` (400) · `ERR_INVITE_TOO_LARGE` (413) · +`ERR_INVITE_EXPIRED` (400, expiry already past) · `ERR_INVITE_DUPLICATE` (409) · +`ERR_INVITE_CAP_FULL` (429) · `ERR_RATE_LIMITED` (429) · `ERR_UNAUTHORIZED` (401). + +### `POST /v1/invite/redeem` +Body `{invite_id, cap}` → `200 {bundle_b64, invite_sig_b64, ticket}`. + +Consumption is an **atomic compare-and-set**: the update re-asserts the ACTIVE +state, so a lost race updates zero rows and returns `ERR_INVITE_ALREADY_USED`. +Exactly one redemption of a slot can win. + +`ticket` is a 128-bit **one-shot** credential for the handshake push (below). + +Cause order is deliberate: not-found → revoked → expired → already-used → +cap-invalid. Reaching this route requires knowing `invite_id`, a 128-bit secret +carried only inside the invite code, so a caller who can address the slot already +holds the capability; reporting the slot's true state to them discloses nothing +they were not given, and the failure taxonomy requires those causes to stay +distinct. + +Rejects: `ERR_INVITE_NOT_FOUND` (404) · `ERR_INVITE_REVOKED` (410) · +`ERR_INVITE_EXPIRED` (410) · `ERR_INVITE_ALREADY_USED` (409) · +`ERR_INVITE_CAP_INVALID` (403). + +### `POST /v1/invite/revoke` +Body `{invite_id, revoke_token}` → `200 {revoked: true}`. Idempotent: a second +revoke succeeds. The credential is checked **before** any state is reported, +because unlike redemption a revoke needs a secret the invite code does not carry. + +Rejects: `ERR_INVITE_NOT_FOUND` (404) · `ERR_INVITE_REVOKE_INVALID` (403). + +### Handshake ingress — `POST /v1/push` to an invite slot +A push whose route token resolves to a known invite slot is admitted **only** +when it presents a live `X-QSL-Invite-Ticket`. The ticket is issued by the +redemption that consumed the slot and is burned on first use inside the same +transaction as the message insert, so the slot accepts **exactly one** handshake +— from the party that actually redeemed it, not merely from anyone who saw the +code and lost the race. + +The ticket is a header rather than a body field because `/v1/push`'s body is the +opaque handshake payload and cannot be repurposed. This matches the +`X-QSL-Route-Token` precedent. + +Rejects: `ERR_INVITE_TICKET_INVALID` (403) · `ERR_INVITE_EXPIRED` (410) · +`ERR_INVITE_REVOKED` (410). + +**Pull is deliberately ungated**: the slot's creator addresses it with the same +`invite_id` and must be able to collect the handshake. + +**Pushes to routes that are not invite slots are entirely unaffected** — one +indexed lookup misses and the pre-existing path runs unchanged. This is the +compatibility guarantee that lets the existing client keep working until the +client-side slices land. + +## Tombstoning (normative, not an optimisation) + +Consumed and revoked slots **persist until `expiry`** with their `bundle` and +`invite_sig` blobs cleared; only state and timestamps survive, so a tombstone +carries no identity material. Deleting them instead would collapse +`invite-already-used` into `invite-not-found` and tell a redeemer "never existed" +when the truth is "someone got here first" — which is precisely the interception +signal the invite design exists to surface. The slot's own expiry bounds the +tombstone's cost. + +## Limits + +| knob | default | ceiling | +|---|---|---| +| `MAX_INVITE_SLOTS` | 256 | 4096 | +| `MAX_INVITE_BUNDLE_BYTES` | 16384 | 65536 | +| `MAX_INVITE_EXPIRY_SECS` | 259200 (72 h) | 2592000 (30 days) | +| `INVITE_CREATE_BURST` | 32 | 4096 | +| `INVITE_CREATE_REFILL_PER_SEC` | 1 | 4096 (`0` allowed) | + +**The slot cap and the create-rate bucket are both required and are not +substitutes.** The cap bounds storage; the bucket bounds denial. The bucket is +**global**, not per-route, because invite creation has no route token yet — the +per-route push bucket structurally cannot cover it. On an open relay a cap alone +would let any anonymous caller fill every slot in one burst and deny invite +creation to everyone until those slots expired. + +`ERR_INVITE_CAP_FULL` **never evicts**. An eviction path would let an attacker +delete other people's invites, which is a worse failure than the denial it would +relieve. + +## Durability + +An accepted `create` is **fsynced before its 200 reaches the socket**. This is +proven by `tests/na0678_invite_durability.rs`, which counts real +`fsync`/`fdatasync` syscalls and asserts the ordering, and which **skips with a +stated reason** when `strace` is unavailable rather than passing silently. + +⚠ A restart test is **not** evidence for this property: `SIGKILL` destroys a +process, not the OS page cache, so `synchronous=FULL` and `synchronous=OFF` are +indistinguishable to it. See the corrected header of +`tests/na0642_durability_restart.rs`. + +## Capability advertisement + +`GET /v1/server-info` gains `api: [… , "invite_v1"]`, +`limits.max_invite_bundle_bytes`, and an `invite` object carrying +`{max_expiry_secs, max_slots}` — additive per DOC-SRV-006 rule 1: nothing +removed, renamed or repurposed. + +## Storage + +`SCHEMA_VERSION` advances to 2 for the `invites` table. The migration now +**advances the stored marker**, which it previously did not: the value was +written with `INSERT OR IGNORE`, a no-op on an existing key, so the D-0011 +downgrade guard went inert after any schema change. See DECISIONS `D-0016`. + +## Decision + +Recorded as qsl-server DECISIONS `D-0016` (wire-contract surface, following +`D-0009`/`D-0010`/`D-0011`/`D-0012`). Governance authority: qsl-protocol lane +NA-0678, directive QSL-DIR-2026-07-26-614. diff --git a/packaging/systemd/relay.env.example b/packaging/systemd/relay.env.example index 4aa1fc0..edcaf6f 100644 --- a/packaging/systemd/relay.env.example +++ b/packaging/systemd/relay.env.example @@ -30,3 +30,19 @@ RELAY_TOKEN= #RELAY_ATTACHMENTS_SERVICE_URL= # Advisory minimum client version (null when unset; NOT enforced by the server). #RELAY_MIN_CLIENT_VERSION= + +# Invite-slot controls (NA-0678). The slot cap bounds STORAGE; the create-rate +# bucket bounds DENIAL. They are NOT substitutes: on an open relay a cap alone +# lets any anonymous caller fill every slot in one burst and deny invite +# creation to everyone until those slots expire. +# Max live invite slots (default 256, ceiling 4096). +#MAX_INVITE_SLOTS=256 +# Max bytes per stored invite bundle or signature (default 16384, ceiling 65536). +#MAX_INVITE_BUNDLE_BYTES=16384 +# Max invite lifetime in seconds (default 259200 = 72 h, ceiling 2592000 = 30 days). +#MAX_INVITE_EXPIRY_SECS=259200 +# GLOBAL invite-create burst before rate limiting (default 32). Global rather +# than per-route because an invite has no route token until it exists. +#INVITE_CREATE_BURST=32 +# Global invite-create token refill per second; 0 disables refill (default 1). +#INVITE_CREATE_REFILL_PER_SEC=1 diff --git a/src/lib.rs b/src/lib.rs index 642c639..2e1bd65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,10 @@ pub use store::{ RETENTION_TTL_SECS_DEFAULT, }; -use store::{now_unix_secs, AckOutcome, EnqueueOutcome, PullMode, PullOutcome, Store}; +use store::{ + now_unix_secs, AckOutcome, EnqueueOutcome, InviteCreateOutcome, InviteRedeemOutcome, + InviteRevokeOutcome, PullMode, PullOutcome, SlotReject, Store, +}; type RateBuckets = HashMap; @@ -77,6 +80,37 @@ pub struct Limits { pub max_queue_depth: usize, } +// NA-0678 (D614 §2e) invite-slot resource controls, in the existing idiom: +// ceiling + zero-rejects + ERR_INVALID_CONFIG_*. +// +// The slot cap and the create-rate bucket are BOTH required and are NOT +// substitutes: the cap bounds STORAGE, the bucket bounds DENIAL. Operator +// ruling (D614 F6): the availability of invite-create is a security property; +// slot-cap-only is a DoS. On an open relay -- the posture this slice's spam +// gate exists for -- any anonymous caller could otherwise fill the cap in one +// burst and deny invite creation to everyone until the slots expire. +pub const MAX_INVITE_SLOTS_DEFAULT: usize = 256; +pub const MAX_INVITE_SLOTS_CEILING: usize = 4_096; +pub const MAX_INVITE_BUNDLE_BYTES_DEFAULT: usize = 16 * 1024; +pub const MAX_INVITE_BUNDLE_BYTES_CEILING: usize = 64 * 1024; +// Design §8 Q1: 72 h default. Ceiling matches the retention ceiling (30 days). +pub const MAX_INVITE_EXPIRY_SECS_DEFAULT: usize = 259_200; +pub const MAX_INVITE_EXPIRY_SECS_CEILING: usize = 2_592_000; +pub const INVITE_CREATE_BURST_DEFAULT: usize = 32; +pub const INVITE_CREATE_BURST_CEILING: usize = 4_096; +pub const INVITE_CREATE_REFILL_PER_SEC_DEFAULT: usize = 1; + +// The capability and the revoke token ride in the JSON BODY of their POSTs, never +// in a path or query parameter: D-0008 recorded that URI-carried secrets leak +// through proxy logs, shell history and traces, D-0009 moved the route token to a +// header, and D-0010 retired the URI form outright. `invite_id` is the same class +// of secret -- it IS the mailbox route key. +// +// The handshake TICKET is the exception and must be a header: it rides on +// `/v1/push`, whose body is the opaque handshake payload and cannot be +// repurposed. That matches the `x-qsl-route-token` precedent exactly. +const INVITE_TICKET_HEADER: &str = "x-qsl-invite-ticket"; + pub const MAX_BODY_BYTES_CEILING: usize = 1024 * 1024; pub const MAX_QUEUE_DEPTH_CEILING: usize = 257; pub const MAX_ROUTE_COUNT_CEILING: usize = 256; @@ -117,6 +151,61 @@ impl Limits { } } +/// NA-0678 invite-slot limits. Separate from `Limits`/`ResourceControls` so the +/// existing relay contract's knobs stay exactly what they were. +#[derive(Clone, Copy, Debug)] +pub struct InviteLimits { + pub max_slots: usize, + pub max_bundle_bytes: usize, + pub max_expiry_secs: usize, + pub create_burst: usize, + pub create_refill_per_sec: usize, +} + +impl Default for InviteLimits { + fn default() -> Self { + Self { + max_slots: MAX_INVITE_SLOTS_DEFAULT, + max_bundle_bytes: MAX_INVITE_BUNDLE_BYTES_DEFAULT, + max_expiry_secs: MAX_INVITE_EXPIRY_SECS_DEFAULT, + create_burst: INVITE_CREATE_BURST_DEFAULT, + create_refill_per_sec: INVITE_CREATE_REFILL_PER_SEC_DEFAULT, + } + } +} + +impl InviteLimits { + pub fn new( + max_slots: usize, + max_bundle_bytes: usize, + max_expiry_secs: usize, + create_burst: usize, + create_refill_per_sec: usize, + ) -> Result { + Ok(Self { + max_slots: limit_or_error("MAX_INVITE_SLOTS", max_slots, MAX_INVITE_SLOTS_CEILING)?, + max_bundle_bytes: limit_or_error( + "MAX_INVITE_BUNDLE_BYTES", + max_bundle_bytes, + MAX_INVITE_BUNDLE_BYTES_CEILING, + )?, + max_expiry_secs: limit_or_error( + "MAX_INVITE_EXPIRY_SECS", + max_expiry_secs, + MAX_INVITE_EXPIRY_SECS_CEILING, + )?, + create_burst: limit_or_error( + "INVITE_CREATE_BURST", + create_burst, + INVITE_CREATE_BURST_CEILING, + )?, + // A refill of 0 is legal here for the same reason it is on the push + // bucket: deterministic no-refill operation. + create_refill_per_sec: create_refill_per_sec.min(MAX_PUSH_RATE_REFILL_PER_SEC_CEILING), + }) + } +} + #[derive(Clone, Copy, Debug)] pub struct ResourceControls { pub max_route_count: usize, @@ -261,6 +350,10 @@ pub struct AppState { // path Store::open applies). retention_ttl_secs: usize, server_info: ServerInfoCfg, + invite_limits: InviteLimits, + // GLOBAL, not per-route: invite creation has no route token yet, so the + // per-route push bucket structurally cannot cover it (D614 C8/F6). + invite_create_rate: Arc>, } impl AppState { @@ -331,8 +424,32 @@ impl AppState { relay_token: Option, store_cfg: StoreConfig, server_info: ServerInfoCfg, + ) -> Result { + Self::new_full( + limits, + controls, + relay_token, + store_cfg, + server_info, + InviteLimits::default(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_full( + limits: Limits, + controls: ResourceControls, + relay_token: Option, + store_cfg: StoreConfig, + server_info: ServerInfoCfg, + invite_limits: InviteLimits, ) -> Result { let retention_ttl_secs = retention_ttl_or_error(store_cfg.retention_ttl_secs)?; + let invite_bucket_controls = ResourceControls { + push_rate_burst: invite_limits.create_burst, + push_rate_refill_per_sec: invite_limits.create_refill_per_sec, + ..ResourceControls::default() + }; Ok(Self { store: Store::open(&store_cfg)?, push_rates: Arc::new(Mutex::new(HashMap::new())), @@ -341,6 +458,11 @@ impl AppState { relay_token, retention_ttl_secs, server_info, + invite_limits, + invite_create_rate: Arc::new(Mutex::new(PushRateBucket::new( + Instant::now(), + invite_bucket_controls, + ))), }) } @@ -420,15 +542,350 @@ struct AckResp { const ROUTE_TOKEN_HEADER: &str = "x-qsl-route-token"; +// NA-0678: exactly THREE invite routes. There is deliberately no +// `/v1/invite/mint` -- the client mints the capability and uploads only its +// hash, so no relay-side path ever holds a capability in plaintext before a +// redeemer presents one (D614 F1). pub fn app(state: AppState) -> Router { Router::new() .route("/v1/push", post(push_message)) .route("/v1/pull", get(pull_message)) .route("/v1/pull/ack", post(ack_messages)) .route("/v1/server-info", get(server_info)) + .route("/v1/invite/create", post(invite_create)) + .route("/v1/invite/redeem", post(invite_redeem)) + .route("/v1/invite/revoke", post(invite_revoke)) .with_state(state) } +#[derive(serde::Deserialize)] +struct InviteCreateReq { + invite_id: String, + cap_hash: String, + expiry: i64, + bundle_b64: String, + invite_sig_b64: String, +} + +#[derive(Serialize)] +struct InviteCreateResp { + revoke_token: String, +} + +#[derive(serde::Deserialize)] +struct InviteRedeemReq { + invite_id: String, + cap: String, +} + +#[derive(Serialize)] +struct InviteRedeemResp { + bundle_b64: String, + invite_sig_b64: String, + ticket: String, +} + +#[derive(serde::Deserialize)] +struct InviteRevokeReq { + invite_id: String, + revoke_token: String, +} + +#[derive(Serialize)] +struct InviteRevokeResp { + revoked: bool, +} + +/// URL-safe base64 without padding, implemented here rather than pulled in: +/// this crate has no base64 dependency and D614 forbids dependency motion. +/// The relay never interprets what it decodes -- these bytes are opaque. +fn b64_decode(s: &str) -> Option> { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + } + let s = s.trim_end_matches('='); + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut acc: u32 = 0; + let mut bits = 0u32; + for c in s.bytes() { + acc = (acc << 6) | u32::from(val(c)?); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xff) as u8); + } + } + // Reject trailing garbage: leftover bits must be zero padding, never data. + if bits >= 6 || (acc & ((1 << bits) - 1)) != 0 { + return None; + } + Some(out) +} + +fn b64_encode(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(T[((n >> 6) & 63) as usize] as char); + } + if chunk.len() > 2 { + out.push(T[(n & 63) as usize] as char); + } + } + out +} + +/// 128-bit CSPRNG token, rendered hex. Uses `Uuid::new_v4`, which this crate +/// already depends on and which draws from the OS CSPRNG -- no new dependency +/// (D614 §7 forbids dependency motion). +fn random_token_128() -> String { + let a = Uuid::new_v4(); + a.simple().to_string() +} + +fn sha256_hex(s: &str) -> String { + let digest = Sha256::digest(s.as_bytes()); + let mut out = String::with_capacity(64); + for b in digest { + out.push_str(&format!("{b:02x}")); + } + out +} + +async fn invite_create( + State(st): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if !auth_ok(&headers, st.relay_token.as_deref()) { + return (StatusCode::UNAUTHORIZED, "ERR_UNAUTHORIZED").into_response(); + } + // The global create-rate gate runs BEFORE any parsing or storage work, so a + // flood costs the relay a lock and nothing else. + { + let mono_now = Instant::now(); + let Ok(mut bucket) = st.invite_create_rate.lock() else { + return (StatusCode::INTERNAL_SERVER_ERROR, "ERR_LOCK_POISON").into_response(); + }; + let controls = ResourceControls { + push_rate_burst: st.invite_limits.create_burst, + push_rate_refill_per_sec: st.invite_limits.create_refill_per_sec, + ..ResourceControls::default() + }; + if !bucket.try_consume(controls, mono_now) { + info!( + "event=invite_rate_limited burst={} refill_per_sec={}", + st.invite_limits.create_burst, st.invite_limits.create_refill_per_sec + ); + return (StatusCode::TOO_MANY_REQUESTS, "ERR_RATE_LIMITED").into_response(); + } + } + let Ok(req) = serde_json::from_slice::(&body) else { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + }; + if req.invite_id.trim().is_empty() || req.cap_hash.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + } + let (Some(bundle), Some(invite_sig)) = + (b64_decode(&req.bundle_b64), b64_decode(&req.invite_sig_b64)) + else { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + }; + if bundle.is_empty() || invite_sig.is_empty() { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + } + if bundle.len() > st.invite_limits.max_bundle_bytes + || invite_sig.len() > st.invite_limits.max_bundle_bytes + { + return (StatusCode::PAYLOAD_TOO_LARGE, "ERR_INVITE_TOO_LARGE").into_response(); + } + let now = now_unix_secs(); + if req.expiry <= now { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_EXPIRED").into_response(); + } + // Clamp rather than reject: an over-long expiry is the client asking for + // more than this relay offers, and the relay's ceiling governs. + let max_expiry = now.saturating_add(st.invite_limits.max_expiry_secs as i64); + let expiry = req.expiry.min(max_expiry); + + let slot_key = route_key_for(&req.invite_id); + let log_id = channel_log_id(&req.invite_id); + let revoke_token = random_token_128(); + let revoke_hash = sha256_hex(&revoke_token); + let cap_hash = req.cap_hash.clone(); + let max_slots = st.invite_limits.max_slots; + + let outcome = { + let store = st.store.clone(); + let (sk, lid) = (slot_key.clone(), log_id.clone()); + match run_store(move || { + store.invite_create( + &sk, + &lid, + &cap_hash, + &revoke_hash, + &bundle, + &invite_sig, + expiry, + now, + max_slots, + ) + }) + .await + { + Ok(v) => v, + Err(code) => return (StatusCode::INTERNAL_SERVER_ERROR, code).into_response(), + } + }; + match outcome { + InviteCreateOutcome::Created => { + info!( + "event=invite_created channel_id={} expiry={}", + log_id, expiry + ); + (StatusCode::OK, Json(InviteCreateResp { revoke_token })).into_response() + } + InviteCreateOutcome::Duplicate => { + (StatusCode::CONFLICT, "ERR_INVITE_DUPLICATE").into_response() + } + InviteCreateOutcome::CapFull { live_slots } => { + info!( + "event=invite_cap_full live_slots={} max={}", + live_slots, max_slots + ); + (StatusCode::TOO_MANY_REQUESTS, "ERR_INVITE_CAP_FULL").into_response() + } + } +} + +async fn invite_redeem( + State(st): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if !auth_ok(&headers, st.relay_token.as_deref()) { + return (StatusCode::UNAUTHORIZED, "ERR_UNAUTHORIZED").into_response(); + } + let Ok(req) = serde_json::from_slice::(&body) else { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + }; + if req.invite_id.trim().is_empty() || req.cap.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + } + let slot_key = route_key_for(&req.invite_id); + let log_id = channel_log_id(&req.invite_id); + let now = now_unix_secs(); + let ticket = random_token_128(); + let ticket_hash = sha256_hex(&ticket); + // Hash the presented capability once, here, then hand the comparison to the + // store so it happens INSIDE the consume transaction. `ct_eq_secret` folds + // over a fixed 32-byte digest with no data-dependent early return (D-0014); + // reusing it introduces no new primitive. + let presented_hash = sha256_hex(&req.cap); + + let outcome = { + let store = st.store.clone(); + let (sk, th) = (slot_key.clone(), ticket_hash.clone()); + match run_store(move || { + store.invite_redeem(&sk, now, &th, |stored| { + ct_eq_secret(&presented_hash, stored) + }) + }) + .await + { + Ok(v) => v, + Err(code) => return (StatusCode::INTERNAL_SERVER_ERROR, code).into_response(), + } + }; + match outcome { + InviteRedeemOutcome::Redeemed { bundle, invite_sig } => { + info!("event=invite_redeemed channel_id={}", log_id); + ( + StatusCode::OK, + Json(InviteRedeemResp { + bundle_b64: b64_encode(&bundle), + invite_sig_b64: b64_encode(&invite_sig), + ticket, + }), + ) + .into_response() + } + InviteRedeemOutcome::NotFound => { + (StatusCode::NOT_FOUND, "ERR_INVITE_NOT_FOUND").into_response() + } + InviteRedeemOutcome::Revoked => (StatusCode::GONE, "ERR_INVITE_REVOKED").into_response(), + InviteRedeemOutcome::Expired => (StatusCode::GONE, "ERR_INVITE_EXPIRED").into_response(), + InviteRedeemOutcome::AlreadyUsed => { + (StatusCode::CONFLICT, "ERR_INVITE_ALREADY_USED").into_response() + } + InviteRedeemOutcome::CapInvalid => { + (StatusCode::FORBIDDEN, "ERR_INVITE_CAP_INVALID").into_response() + } + } +} + +async fn invite_revoke( + State(st): State, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if !auth_ok(&headers, st.relay_token.as_deref()) { + return (StatusCode::UNAUTHORIZED, "ERR_UNAUTHORIZED").into_response(); + } + let Ok(req) = serde_json::from_slice::(&body) else { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + }; + if req.invite_id.trim().is_empty() || req.revoke_token.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "ERR_INVITE_BAD_BODY").into_response(); + } + let slot_key = route_key_for(&req.invite_id); + let log_id = channel_log_id(&req.invite_id); + let now = now_unix_secs(); + let presented_hash = sha256_hex(&req.revoke_token); + + let outcome = { + let store = st.store.clone(); + let sk = slot_key.clone(); + match run_store(move || { + store.invite_revoke(&sk, now, |stored| ct_eq_secret(&presented_hash, stored)) + }) + .await + { + Ok(v) => v, + Err(code) => return (StatusCode::INTERNAL_SERVER_ERROR, code).into_response(), + } + }; + match outcome { + // Idempotent: a second revoke is a success, not an error. + InviteRevokeOutcome::Revoked | InviteRevokeOutcome::AlreadyRevoked => { + info!("event=invite_revoked channel_id={}", log_id); + (StatusCode::OK, Json(InviteRevokeResp { revoked: true })).into_response() + } + InviteRevokeOutcome::NotFound => { + (StatusCode::NOT_FOUND, "ERR_INVITE_NOT_FOUND").into_response() + } + InviteRevokeOutcome::TokenInvalid => { + (StatusCode::FORBIDDEN, "ERR_INVITE_REVOKE_INVALID").into_response() + } + } +} + // NA-0652 capability document (DOC-SRV-006). Served behind the same bearer // gate as the relay routes; on a bearer relay an unauthorized request gets the // FIXED two-key probe — identical for a missing and a wrong token, so the @@ -457,11 +914,18 @@ async fn server_info(State(st): State, headers: HeaderMap) -> impl Int "server": "qsl-server", "version": env!("CARGO_PKG_VERSION"), "name": st.server_info.name.clone().unwrap_or_default(), - "api": ["push_v1", "pull_v1", "pull_ack_lease_v1"], + // ADDITIVE per DOC-SRV-006 rule 1: nothing removed, renamed or + // repurposed. `invite_v1` announces the NA-0678 slot routes. + "api": ["push_v1", "pull_v1", "pull_ack_lease_v1", "invite_v1"], "auth": { "mode": auth_mode }, "limits": { "max_body_bytes": st.limits.max_body_bytes, "max_queue_depth": st.limits.max_queue_depth, + "max_invite_bundle_bytes": st.invite_limits.max_bundle_bytes, + }, + "invite": { + "max_expiry_secs": st.invite_limits.max_expiry_secs, + "max_slots": st.invite_limits.max_slots, }, "retention": { "ttl_secs": st.retention_ttl_secs }, "directory": { "mode": "none" }, @@ -638,8 +1102,24 @@ async fn push_message( let payload = body.to_vec(); let max_depth = st.limits.max_queue_depth; let max_routes = st.controls.max_route_count; + // NA-0678: the handshake ticket, if one was presented. Hashed here and + // compared constant-time inside the store transaction. For every route + // that is not an invite slot this value is never consulted. + let ticket_hash = headers + .get(INVITE_TICKET_HEADER) + .and_then(|v| v.to_str().ok()) + .map(|v| v.trim()) + .filter(|v| !v.is_empty()) + .map(sha256_hex); match run_store(move || { - store.enqueue(&key, &lid, &mid, &payload, now, max_depth, max_routes) + let verify = ticket_hash + .as_ref() + .map(|h| move |stored: &str| ct_eq_secret(h, stored)); + let verify_ref: Option<&dyn Fn(&str) -> bool> = + verify.as_ref().map(|f| f as &dyn Fn(&str) -> bool); + store.enqueue( + &key, &lid, &mid, &payload, now, max_depth, max_routes, verify_ref, + ) }) .await { @@ -664,6 +1144,17 @@ async fn push_message( st.drop_rate_bucket(&route_key); return (StatusCode::TOO_MANY_REQUESTS, "ERR_ROUTE_CAP").into_response(); } + // Reachable ONLY for a route the invite system created. Every other + // route takes the paths above, unchanged. + EnqueueOutcome::SlotRejected(reason) => { + info!("event=invite_slot_rejected channel_id={}", log_id); + let (status, code) = match reason { + SlotReject::Expired => (StatusCode::GONE, "ERR_INVITE_EXPIRED"), + SlotReject::Revoked => (StatusCode::GONE, "ERR_INVITE_REVOKED"), + SlotReject::TicketInvalid => (StatusCode::FORBIDDEN, "ERR_INVITE_TICKET_INVALID"), + }; + return (status, code).into_response(); + } } // Never log payload; metadata only. diff --git a/src/main.rs b/src/main.rs index f896794..5c50d6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,12 @@ use std::{env, net::SocketAddr, time::Duration}; use clap::Parser; use qsl_server::{ - app, pull_lease_or_error, retention_ttl_or_error, AppState, Limits, ResourceControls, - StoreConfig, MAX_BODY_BYTES_CEILING, MAX_PUSH_RATE_BURST_CEILING, MAX_QUEUE_DEPTH_CEILING, - MAX_ROUTE_COUNT_CEILING, PULL_LEASE_SECS_DEFAULT, RETENTION_TTL_SECS_DEFAULT, + app, pull_lease_or_error, retention_ttl_or_error, AppState, InviteLimits, Limits, + ResourceControls, ServerInfoCfg, StoreConfig, INVITE_CREATE_BURST_DEFAULT, + INVITE_CREATE_REFILL_PER_SEC_DEFAULT, MAX_BODY_BYTES_CEILING, MAX_INVITE_BUNDLE_BYTES_DEFAULT, + MAX_INVITE_EXPIRY_SECS_DEFAULT, MAX_INVITE_SLOTS_DEFAULT, MAX_PUSH_RATE_BURST_CEILING, + MAX_QUEUE_DEPTH_CEILING, MAX_ROUTE_COUNT_CEILING, PULL_LEASE_SECS_DEFAULT, + RETENTION_TTL_SECS_DEFAULT, }; use tokio::net::TcpListener; use tracing::info; @@ -47,6 +50,21 @@ struct Cli { /// Ack-mode pull lease (visibility timeout) in seconds (env: PULL_LEASE_SECS, default: 60) #[arg(long)] pull_lease_secs: Option, + /// Max live invite slots (env: MAX_INVITE_SLOTS, default: 256) + #[arg(long)] + max_invite_slots: Option, + /// Max bytes per stored invite bundle or signature (env: MAX_INVITE_BUNDLE_BYTES, default: 16384) + #[arg(long)] + max_invite_bundle_bytes: Option, + /// Max invite lifetime in seconds (env: MAX_INVITE_EXPIRY_SECS, default: 259200) + #[arg(long)] + max_invite_expiry_secs: Option, + /// Global invite-create burst before rate limiting (env: INVITE_CREATE_BURST, default: 32) + #[arg(long)] + invite_create_burst: Option, + /// Global invite-create token refill per second; 0 disables refill (env: INVITE_CREATE_REFILL_PER_SEC, default: 1) + #[arg(long)] + invite_create_refill_per_sec: Option, } #[derive(Clone, Debug)] @@ -63,6 +81,11 @@ struct EnvVals { store_path: Option, retention_ttl_secs: Option, pull_lease_secs: Option, + max_invite_slots: Option, + max_invite_bundle_bytes: Option, + max_invite_expiry_secs: Option, + invite_create_burst: Option, + invite_create_refill_per_sec: Option, } impl EnvVals { @@ -79,6 +102,11 @@ impl EnvVals { store_path: env_opt("STORE_PATH"), retention_ttl_secs: env_usize("RETENTION_TTL_SECS")?, pull_lease_secs: env_usize("PULL_LEASE_SECS")?, + max_invite_slots: env_usize("MAX_INVITE_SLOTS")?, + max_invite_bundle_bytes: env_usize("MAX_INVITE_BUNDLE_BYTES")?, + max_invite_expiry_secs: env_usize("MAX_INVITE_EXPIRY_SECS")?, + invite_create_burst: env_usize("INVITE_CREATE_BURST")?, + invite_create_refill_per_sec: env_usize("INVITE_CREATE_REFILL_PER_SEC")?, }) } } @@ -90,6 +118,7 @@ struct Config { limits: Limits, controls: ResourceControls, store: StoreConfig, + invites: InviteLimits, deprecated_route_idle_ttl: bool, } @@ -159,6 +188,23 @@ fn resolve_config(cli: Cli, env: EnvVals) -> Result { .or(env.pull_lease_secs) .unwrap_or(PULL_LEASE_SECS_DEFAULT), )?; + let invites = InviteLimits::new( + cli.max_invite_slots + .or(env.max_invite_slots) + .unwrap_or(MAX_INVITE_SLOTS_DEFAULT), + cli.max_invite_bundle_bytes + .or(env.max_invite_bundle_bytes) + .unwrap_or(MAX_INVITE_BUNDLE_BYTES_DEFAULT), + cli.max_invite_expiry_secs + .or(env.max_invite_expiry_secs) + .unwrap_or(MAX_INVITE_EXPIRY_SECS_DEFAULT), + cli.invite_create_burst + .or(env.invite_create_burst) + .unwrap_or(INVITE_CREATE_BURST_DEFAULT), + cli.invite_create_refill_per_sec + .or(env.invite_create_refill_per_sec) + .unwrap_or(INVITE_CREATE_REFILL_PER_SEC_DEFAULT), + )?; let deprecated_route_idle_ttl = cli.route_idle_ttl_ms.is_some() || env.route_idle_ttl_ms_present; Ok(Config { @@ -175,6 +221,7 @@ fn resolve_config(cli: Cli, env: EnvVals) -> Result { retention_ttl_secs, pull_lease_secs, }, + invites, deprecated_route_idle_ttl, }) } @@ -227,7 +274,14 @@ async fn main() { } }; - let state = match AppState::new_with_controls_and_store(cfg.limits, cfg.controls, cfg.store) { + let state = match AppState::new_full( + cfg.limits, + cfg.controls, + std::env::var("RELAY_TOKEN").ok().filter(|v| !v.is_empty()), + cfg.store, + ServerInfoCfg::from_env(), + cfg.invites, + ) { Ok(v) => v, Err(code) => { tracing::error!("{code}"); @@ -270,6 +324,11 @@ mod cli_tests { store_path: Some(":memory:".to_string()), retention_ttl_secs: None, pull_lease_secs: None, + max_invite_slots: None, + max_invite_bundle_bytes: None, + max_invite_expiry_secs: None, + invite_create_burst: None, + invite_create_refill_per_sec: None, } } @@ -286,6 +345,11 @@ mod cli_tests { store_path: None, retention_ttl_secs: None, pull_lease_secs: None, + max_invite_slots: None, + max_invite_bundle_bytes: None, + max_invite_expiry_secs: None, + invite_create_burst: None, + invite_create_refill_per_sec: None, } } diff --git a/src/store.rs b/src/store.rs index 7052304..bc4df32 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,4 +1,4 @@ -use rusqlite::{params, params_from_iter, Connection}; +use rusqlite::{params, params_from_iter, Connection, OptionalExtension}; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -7,11 +7,30 @@ pub const MAX_RETENTION_TTL_SECS_CEILING: usize = 2_592_000; // 30 days pub const PULL_LEASE_SECS_DEFAULT: usize = 60; pub const MAX_PULL_LEASE_SECS_CEILING: usize = 3_600; -const SCHEMA_VERSION: i64 = 1; +// NA-0678 (D614 F5): bumped to 2 for the `invites` table. ⚠ The bump is only +// meaningful because the migration now ADVANCES the stored value -- see +// `write_schema_version`. Before this lane the version was written with +// `INSERT OR IGNORE`, a no-op on an existing key, so a forward-migrated store +// kept reporting the version it was created at and the downgrade guard below +// silently stopped tracking reality after the first schema change. Measured, +// not inferred: a SCHEMA_VERSION=2 binary opened a v1 store, created its new +// table, and left `meta.schema_version = '1'`. +const SCHEMA_VERSION: i64 = 2; // Bounds a single ack request's IN-list; well under SQLite's variable limit. pub const MAX_ACK_IDS: usize = 4_096; +// NA-0678 invite-slot states. Consumed and revoked slots are TOMBSTONED until +// expiry rather than deleted: the failure taxonomy requires +// `invite-already-used` and `invite-not-found` to stay DISTINCT causes, and a +// deleted slot reports "never existed" when the truth is "someone got here +// first" -- which is precisely the interception signal the design exists to +// surface. The bundle and signature blobs are cleared at consumption, so a +// tombstone carries no identity material. +pub(crate) const INVITE_ACTIVE: i64 = 0; +pub(crate) const INVITE_CONSUMED: i64 = 1; +pub(crate) const INVITE_REVOKED: i64 = 2; + /// Durable-store configuration. `path` accepts a filesystem path or the /// literal `:memory:` for explicitly ephemeral stores (tests, dev runs). #[derive(Clone, Debug)] @@ -58,6 +77,8 @@ pub struct SweepStats { pub expired_routes: Vec<(String, usize)>, // route keys whose route row was removed (for rate-bucket pruning) pub removed_route_keys: Vec, + // NA-0678: invite slots (live and tombstoned) removed at their own expiry + pub expired_invites: usize, } #[derive(Debug)] @@ -65,6 +86,7 @@ pub(crate) enum EnqueueOutcome { Accepted, Overloaded { depth: usize }, RouteCap { live_routes: usize }, + SlotRejected(SlotReject), } #[derive(Debug, Clone, Copy)] @@ -97,6 +119,55 @@ pub(crate) struct RouteStatus { pub sweep: SweepStats, } +#[derive(Debug)] +pub(crate) enum InviteCreateOutcome { + Created, + Duplicate, + CapFull { live_slots: usize }, +} + +#[derive(Debug)] +pub(crate) enum InviteRedeemOutcome { + Redeemed { + bundle: Vec, + invite_sig: Vec, + }, + NotFound, + Revoked, + Expired, + AlreadyUsed, + CapInvalid, +} + +#[derive(Debug)] +pub(crate) enum InviteRevokeOutcome { + Revoked, + AlreadyRevoked, + NotFound, + TokenInvalid, +} + +/// The columns a redemption reads. A named struct rather than a five-tuple so +/// the destructure below says what each field is. +struct InviteRow { + state: i64, + expiry: i64, + cap_hash: String, + bundle: Vec, + invite_sig: Vec, +} + +/// Why a push into a KNOWN invite slot was refused. Pushes to routes that are +/// not slots never reach this type -- that is the whole of the compatibility +/// guarantee (D614 C3): the existing relay contract is untouched for every +/// route the invite system did not create. +#[derive(Debug)] +pub(crate) enum SlotReject { + Expired, + Revoked, + TicketInvalid, +} + pub(crate) fn now_unix_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -115,6 +186,18 @@ fn map_err(e: rusqlite::Error) -> String { format!("ERR_STORE {e}") } +/// Write the schema marker with an UPSERT rather than `INSERT OR IGNORE`, so a +/// forward migration actually advances it (NA-0678, D614 F5). +fn write_schema_version(conn: &Connection, version: i64) -> Result<(), String> { + conn.execute( + "INSERT INTO meta(key, value) VALUES('schema_version', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![version.to_string()], + ) + .map_err(map_err)?; + Ok(()) +} + impl Store { pub(crate) fn open(cfg: &StoreConfig) -> Result { let retention_ttl_secs = retention_ttl_or_error(cfg.retention_ttl_secs)? as i64; @@ -148,27 +231,53 @@ impl Store { leased_until INTEGER ); CREATE INDEX IF NOT EXISTS idx_messages_route_seq ON messages(route_key, seq); - CREATE INDEX IF NOT EXISTS idx_messages_enqueued ON messages(enqueued_at);", - ) - .map_err(map_err)?; - conn.execute( - "INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?1)", - params![SCHEMA_VERSION.to_string()], + CREATE INDEX IF NOT EXISTS idx_messages_enqueued ON messages(enqueued_at); + CREATE TABLE IF NOT EXISTS invites ( + slot_key TEXT PRIMARY KEY, + log_id TEXT NOT NULL, + cap_hash TEXT NOT NULL, + revoke_hash TEXT NOT NULL, + bundle BLOB NOT NULL, + invite_sig BLOB NOT NULL, + expiry INTEGER NOT NULL, + created_at INTEGER NOT NULL, + state INTEGER NOT NULL, + consumed_at INTEGER, + ticket_hash TEXT + ); + CREATE INDEX IF NOT EXISTS idx_invites_expiry ON invites(expiry);", ) .map_err(map_err)?; - let stored: String = conn + // Read BEFORE writing: `INSERT OR IGNORE` cannot distinguish "new store" + // from "existing store at an older version", which is exactly how the + // pre-NA-0678 guard went inert. + let stored: Option = conn .query_row( "SELECT value FROM meta WHERE key='schema_version'", [], |row| row.get(0), ) + .optional() .map_err(map_err)?; - let stored: i64 = stored - .parse() - .map_err(|_| "ERR_STORE_VERSION".to_string())?; + let stored: i64 = match stored { + None => { + write_schema_version(&conn, SCHEMA_VERSION)?; + SCHEMA_VERSION + } + Some(raw) => raw.parse().map_err(|_| "ERR_STORE_VERSION".to_string())?, + }; + // Fail closed on a store written by a NEWER binary: its schema may carry + // columns and invariants this build does not know about. if stored > SCHEMA_VERSION { return Err("ERR_STORE_VERSION".to_string()); } + // Forward migration: the CREATE TABLE IF NOT EXISTS statements above have + // already brought an older store up to date, so record that it happened. + // Without this the marker stays at the version the file was CREATED at + // and the guard above can never fire for a rollback. + if stored < SCHEMA_VERSION { + write_schema_version(&conn, SCHEMA_VERSION)?; + } Ok(Self { conn: Arc::new(Mutex::new(conn)), retention_ttl_secs, @@ -227,6 +336,13 @@ impl Store { ) .map_err(map_err)?; } + // Invite slots expire on their OWN clock, not the retention TTL: an + // invite's lifetime is set by its creator (design §8 Q1, 72 h default). + // Tombstones live until that moment and are swept with the slot, which + // is what bounds the tombstone's cost. + stats.expired_invites = conn + .execute("DELETE FROM invites WHERE expiry <= ?1", params![now]) + .map_err(map_err)?; Ok(stats) } @@ -274,6 +390,207 @@ impl Store { }) } + /// Publish an invite slot. `cap_hash` and `revoke_hash` arrive ALREADY + /// hashed by the caller -- the relay never holds either secret in plaintext + /// (D614 F1: the client mints the capability; there is no mint endpoint). + /// `bundle` and `invite_sig` are stored verbatim and are never parsed. + #[allow(clippy::too_many_arguments)] + pub(crate) fn invite_create( + &self, + slot_key: &str, + log_id: &str, + cap_hash: &str, + revoke_hash: &str, + bundle: &[u8], + invite_sig: &[u8], + expiry: i64, + now: i64, + max_slots: usize, + ) -> Result { + let mut guard = self + .conn + .lock() + .map_err(|_| "ERR_LOCK_POISON".to_string())?; + let tx = guard.transaction().map_err(map_err)?; + Self::sweep_expired(&tx, self.retention_ttl_secs, now)?; + let exists: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM invites WHERE slot_key = ?1)", + params![slot_key], + |row| row.get(0), + ) + .map_err(map_err)?; + if exists { + return Ok(InviteCreateOutcome::Duplicate); + } + let live_slots: i64 = tx + .query_row("SELECT COUNT(*) FROM invites", [], |row| row.get(0)) + .map_err(map_err)?; + if live_slots as usize >= max_slots { + // Reject, NEVER evict. An eviction path would let an attacker + // delete other people's invites -- a worse failure than the denial + // it would relieve (D614 F6). + return Ok(InviteCreateOutcome::CapFull { + live_slots: live_slots as usize, + }); + } + tx.execute( + "INSERT INTO invites(slot_key, log_id, cap_hash, revoke_hash, bundle, + invite_sig, expiry, created_at, state, consumed_at, ticket_hash) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, NULL)", + params![ + slot_key, + log_id, + cap_hash, + revoke_hash, + bundle, + invite_sig, + expiry, + now, + INVITE_ACTIVE + ], + ) + .map_err(map_err)?; + tx.commit().map_err(map_err)?; + Ok(InviteCreateOutcome::Created) + } + + /// Redeem a slot: verify the capability and consume it ATOMICALLY, then + /// issue the one-shot handshake ticket (D614 F3). + /// + /// `verify_cap` receives the STORED hash and performs the constant-time + /// comparison in the caller -- the crypto stays in one place (`ct_eq_secret`) + /// while the check and the consume stay inside one transaction, so a lost + /// race cannot yield two winners. The single mutex-wrapped connection makes + /// that exact rather than merely probable. + /// + /// Cause order is deliberate: not-found → revoked → expired → already-used → + /// cap-invalid. Reaching this route at all requires knowing `invite_id`, a + /// 128-bit secret that travels only inside the invite code -- so a caller + /// who can address the slot already holds the capability, and reporting the + /// slot's real state to them is information they were given, not a leak. The + /// legitimate holder gets the most useful cause; the design's taxonomy + /// requires exactly that. + pub(crate) fn invite_redeem( + &self, + slot_key: &str, + now: i64, + ticket_hash: &str, + verify_cap: F, + ) -> Result + where + F: Fn(&str) -> bool, + { + let mut guard = self + .conn + .lock() + .map_err(|_| "ERR_LOCK_POISON".to_string())?; + let tx = guard.transaction().map_err(map_err)?; + let row: Option = tx + .query_row( + "SELECT state, expiry, cap_hash, bundle, invite_sig + FROM invites WHERE slot_key = ?1", + params![slot_key], + |r| { + Ok(InviteRow { + state: r.get(0)?, + expiry: r.get(1)?, + cap_hash: r.get(2)?, + bundle: r.get(3)?, + invite_sig: r.get(4)?, + }) + }, + ) + .optional() + .map_err(map_err)?; + let Some(InviteRow { + state, + expiry, + cap_hash, + bundle, + invite_sig, + }) = row + else { + return Ok(InviteRedeemOutcome::NotFound); + }; + if state == INVITE_REVOKED { + return Ok(InviteRedeemOutcome::Revoked); + } + if expiry <= now { + return Ok(InviteRedeemOutcome::Expired); + } + if state == INVITE_CONSUMED { + return Ok(InviteRedeemOutcome::AlreadyUsed); + } + if !verify_cap(&cap_hash) { + return Ok(InviteRedeemOutcome::CapInvalid); + } + // Compare-and-set: the WHERE clause re-asserts ACTIVE, so a concurrent + // winner leaves this UPDATE matching zero rows. + let updated = tx + .execute( + "UPDATE invites + SET state = ?2, consumed_at = ?3, ticket_hash = ?4, + bundle = x'', invite_sig = x'' + WHERE slot_key = ?1 AND state = ?5", + params![slot_key, INVITE_CONSUMED, now, ticket_hash, INVITE_ACTIVE], + ) + .map_err(map_err)?; + if updated == 0 { + return Ok(InviteRedeemOutcome::AlreadyUsed); + } + tx.commit().map_err(map_err)?; + Ok(InviteRedeemOutcome::Redeemed { bundle, invite_sig }) + } + + /// Kill a slot. Idempotent, and authorized by the `revoke_token` issued once + /// at creation (D614 F2) -- without it, an open relay would let anyone who + /// has merely SEEN an invite code destroy it. + pub(crate) fn invite_revoke( + &self, + slot_key: &str, + now: i64, + verify_revoke: F, + ) -> Result + where + F: Fn(&str) -> bool, + { + let mut guard = self + .conn + .lock() + .map_err(|_| "ERR_LOCK_POISON".to_string())?; + let tx = guard.transaction().map_err(map_err)?; + let row: Option<(i64, String)> = tx + .query_row( + "SELECT state, revoke_hash FROM invites WHERE slot_key = ?1", + params![slot_key], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(map_err)?; + let Some((state, revoke_hash)) = row else { + return Ok(InviteRevokeOutcome::NotFound); + }; + // The credential is checked BEFORE any state is reported, because unlike + // redemption a revoke needs a secret the invite code does not carry. + if !verify_revoke(&revoke_hash) { + return Ok(InviteRevokeOutcome::TokenInvalid); + } + if state == INVITE_REVOKED { + return Ok(InviteRevokeOutcome::AlreadyRevoked); + } + tx.execute( + "UPDATE invites + SET state = ?2, bundle = x'', invite_sig = x'', ticket_hash = NULL + WHERE slot_key = ?1", + params![slot_key, INVITE_REVOKED], + ) + .map_err(map_err)?; + let _ = now; + tx.commit().map_err(map_err)?; + Ok(InviteRevokeOutcome::Revoked) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn enqueue( &self, @@ -284,12 +601,54 @@ impl Store { now: i64, max_queue_depth: usize, max_route_count: usize, + ticket: Option<&dyn Fn(&str) -> bool>, ) -> Result { let mut guard = self .conn .lock() .map_err(|_| "ERR_LOCK_POISON".to_string())?; let tx = guard.transaction().map_err(map_err)?; + // NA-0678 slot admission (D614 C3/§2c). ONE indexed lookup that MISSES + // for every route the invite system did not create -- which is every + // route that exists today. A miss takes the `None` arm and the rest of + // this function is byte-for-byte the pre-lane behaviour, which is the + // compatibility guarantee the whole epic's ordering rests on. + // + // Admission lives HERE, inside the same transaction as the message + // insert, so the one-shot ticket is genuinely one-shot: checking it in + // an earlier transaction would leave a race between two concurrent + // pushes presenting the same ticket. + let slot: Option<(i64, i64, Option)> = tx + .query_row( + "SELECT state, expiry, ticket_hash FROM invites WHERE slot_key = ?1", + params![route_key], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .map_err(map_err)?; + if let Some((state, expiry, ticket_hash)) = slot { + if state == INVITE_REVOKED { + return Ok(EnqueueOutcome::SlotRejected(SlotReject::Revoked)); + } + if expiry <= now { + return Ok(EnqueueOutcome::SlotRejected(SlotReject::Expired)); + } + // A live ticket exists only between redemption and the handshake it + // authorizes. No ticket, no match, or no presented ticket -> refuse. + let admitted = match (ticket_hash.as_deref(), ticket) { + (Some(stored), Some(verify)) => verify(stored), + _ => false, + }; + if !admitted { + return Ok(EnqueueOutcome::SlotRejected(SlotReject::TicketInvalid)); + } + // Burn it: one handshake per redemption. + tx.execute( + "UPDATE invites SET ticket_hash = NULL WHERE slot_key = ?1", + params![route_key], + ) + .map_err(map_err)?; + } let route_exists: bool = tx .query_row( "SELECT EXISTS(SELECT 1 FROM routes WHERE route_key = ?1)", diff --git a/tests/na0642_durability_restart.rs b/tests/na0642_durability_restart.rs index ebd0d1c..9ddb353 100644 --- a/tests/na0642_durability_restart.rs +++ b/tests/na0642_durability_restart.rs @@ -1,9 +1,23 @@ -// NA-0642 restart-durability proof, operator-required form: the relay process -// is HARD-KILLED (SIGKILL via Child::kill on unix — no graceful shutdown, no -// flush-on-exit) between the push 200 and the restart, so surviving the -// restart demonstrates the synchronous=FULL fsync guarantee, not a graceful -// flush. Same discipline for the crash-between-pull-and-ack case: the server -// dies mid-lease and the leased message must reappear after lease expiry. +// NA-0642 restart-durability proof: the relay process is HARD-KILLED (SIGKILL +// via Child::kill on unix — no graceful shutdown, no flush-on-exit) between the +// push 200 and the restart, so surviving the restart demonstrates PROCESS-CRASH +// durability, not a graceful flush. Same discipline for the +// crash-between-pull-and-ack case: the server dies mid-lease and the leased +// message must reappear after lease expiry. +// +// ⚠ COMMENT CORRECTED at NA-0678 (D614 C2/F4). This header previously claimed +// the file demonstrates "the synchronous=FULL fsync guarantee". It cannot, and +// the correction matters because that claim was cited as the project's proof of +// "a 200 means fsynced". SIGKILL destroys a PROCESS, not the OS page cache: +// writes that reached the kernel survive a process kill and are visible to the +// next process that opens the file, so synchronous=FULL and synchronous=OFF are +// INDISTINGUISHABLE to any process-kill test. Measured, not argued: this suite +// passes 3/3 with the pragma set to OFF. +// +// The tests below are correct and valuable for what they actually prove, and +// are deliberately unchanged. The fsync claim is discharged instead by +// tests/na0678_invite_durability.rs, which counts real fsync syscalls and +// asserts the fsync precedes the 200 on the wire. use serde::Deserialize; use std::{ diff --git a/tests/na0652_server_info.rs b/tests/na0652_server_info.rs index ed679fb..0e719a0 100644 --- a/tests/na0652_server_info.rs +++ b/tests/na0652_server_info.rs @@ -176,9 +176,12 @@ async fn document_values_track_injected_config() { assert_eq!(doc["attachments"]["service_url"], "https://attach.example"); assert_eq!(doc["min_client_version"], "0.9.0"); assert_eq!(doc["version"], env!("CARGO_PKG_VERSION")); + // NA-0678: `invite_v1` appended. This guard is EXACT by design -- it is + // meant to fail on any change to the advertised API set, and it moves in + // the same commit as the change (D614 §2d). assert_eq!( doc["api"], - serde_json::json!(["push_v1", "pull_v1", "pull_ack_lease_v1"]) + serde_json::json!(["push_v1", "pull_v1", "pull_ack_lease_v1", "invite_v1"]) ); assert_eq!(doc["directory"]["mode"], "none"); assert_eq!(doc["kt"]["mode"], "none"); @@ -227,6 +230,9 @@ async fn full_document_top_level_field_set_is_exact() { "attachments", "auth", "directory", + // NA-0678: the invite capability block. Additive -- every pre-existing + // key is still present and unrenamed. + "invite", "kt", "limits", "min_client_version", diff --git a/tests/na0678_invite_durability.rs b/tests/na0678_invite_durability.rs new file mode 100644 index 0000000..0503d21 --- /dev/null +++ b/tests/na0678_invite_durability.rs @@ -0,0 +1,204 @@ +// NA-0678 (D614 F4): the "200 means fsynced" claim, proved by an instrument that +// can actually observe fsync. +// +// ⚠ WHY THIS FILE EXISTS AT ALL. `tests/na0642_durability_restart.rs` is widely +// cited as the proof of that claim. It cannot be: it SIGKILLs the relay and +// restarts it, and SIGKILL destroys a PROCESS, not the OS page cache. Writes +// that reached the kernel survive a process kill and are visible to the next +// process that opens the file, so `synchronous=FULL` and `synchronous=OFF` are +// INDISTINGUISHABLE to any process-kill test. Measured during the D614 census: +// that suite passes 3/3 with the pragma set to OFF. +// +// That test is still valuable and is deliberately kept -- it proves PROCESS-crash +// durability, which is a real property. It simply does not prove this one. +// +// The instrument here counts real `fsync`/`fdatasync` syscalls attributable to an +// accepted invite create, and asserts the ordering that makes the claim mean +// something: the fsync must complete BEFORE the 200 reaches the socket. +// +// ⚠ SKIP DISCIPLINE. When `strace` is unavailable this test SKIPS WITH A STATED +// REASON naming what it could not examine. A silent skip is a vacuous pass -- +// indistinguishable from a passing gate -- and is exactly the defect class the +// standing rules exist to prevent. The `synchronous=OFF` negative arm cannot be +// built from inside the test binary (it needs a differently-compiled relay), so +// it is discharged by a recorded local run in the lane's as-built evidence. + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::Duration; + +fn have_strace() -> bool { + Command::new("strace") + .arg("-V") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn sha256_hex(s: &str) -> String { + use sha2::{Digest, Sha256}; + let d = Sha256::digest(s.as_bytes()); + let mut o = String::new(); + for b in d { + o.push_str(&format!("{b:02x}")); + } + o +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!( + "na0678-dur-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&d).unwrap_or_else(|e| panic!("{e}")); + d +} + +/// Run the real relay binary under strace, perform `n` invite creates, and +/// return (fsync syscalls observed, whether an fsync preceded the last 200). +fn measure(n: usize, tag: &str) -> (usize, bool) { + let dir = temp_dir(tag); + let trace = dir.join("trace.log"); + let store = dir.join("relay.db"); + + let mut child = Command::new("strace") + .args([ + "-f", + "-tt", + "-e", + "trace=fsync,fdatasync,write,writev,sendto", + "-o", + ]) + .arg(&trace) + .arg(env!("CARGO_BIN_EXE_qsl-server")) + .env_clear() + .env("RUST_LOG", "info") + .env("BIND_ADDR", "127.0.0.1") + .env("PORT", "0") + .env("STORE_PATH", &store) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap_or_else(|e| panic!("spawn strace: {e}")); + + let stdout = child.stdout.take().unwrap_or_else(|| panic!("no stdout")); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + let mut sent = false; + for line in reader.lines() { + let Ok(line) = line else { break }; + if !sent { + if let Some(i) = line.find("listening on ") { + let _ = tx.send(line[i + "listening on ".len()..].trim().to_string()); + sent = true; + } + } + } + }); + let addr = rx + .recv_timeout(Duration::from_secs(20)) + .unwrap_or_else(|e| panic!("relay never reported a listen address: {e}")); + + let before = std::fs::read_to_string(&trace).unwrap_or_default(); + let before_fsync = before.matches("fsync(").count() + before.matches("fdatasync(").count(); + + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + + 3600; + for i in 0..n { + let body = format!( + r#"{{"invite_id":"dur-{i}","cap_hash":"{}","expiry":{expiry},"bundle_b64":"QUJD","invite_sig_b64":"WFla"}}"#, + sha256_hex("cap") + ); + let out = Command::new("curl") + .args([ + "-sS", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-X", + "POST", + &format!("http://{addr}/v1/invite/create"), + "-H", + "Content-Type: application/json", + "--data-binary", + &body, + ]) + .output() + .unwrap_or_else(|e| panic!("curl: {e}")); + let code = String::from_utf8_lossy(&out.stdout).trim().to_string(); + assert_eq!(code, "200", "invite create must be accepted (got {code})"); + } + std::thread::sleep(Duration::from_millis(600)); + let _ = child.kill(); + let _ = child.wait(); + + let after = std::fs::read_to_string(&trace).unwrap_or_default(); + let after_fsync = after.matches("fsync(").count() + after.matches("fdatasync(").count(); + + // Ordering: the LAST fsync must appear before the LAST 200-carrying write. + let lines: Vec<&str> = after.lines().collect(); + let last_fsync = lines + .iter() + .rposition(|l| l.contains("fsync(") || l.contains("fdatasync(")); + let last_200 = lines.iter().rposition(|l| l.contains("HTTP/1.1 200 OK")); + let ordered = match (last_fsync, last_200) { + (Some(f), Some(r)) => f < r, + // No 200 line visible in the trace (buffering) -> report unordered rather + // than claiming a property we did not observe. + _ => false, + }; + let _ = std::fs::remove_dir_all(&dir); + (after_fsync.saturating_sub(before_fsync), ordered) +} + +#[test] +fn accepted_invite_create_is_fsynced_before_the_200() { + if !have_strace() { + // NOT a silent skip. Name the tool, the property, and where the missing + // coverage is discharged instead. + println!( + "SKIP na0678 durability instrument: `strace` is not available on this host, \ + so NO fsync syscall could be observed. NOT EXAMINED: whether an accepted \ + POST /v1/invite/create performs an fsync before its 200 reaches the socket. \ + This property is discharged by the recorded both-arms local run in the \ + lane's as-built evidence (D614 F4); it is NOT proven by this run." + ); + return; + } + + let (zero_arm, _) = measure(0, "zero"); + let (five_arm, ordered) = measure(5, "five"); + + // The instrument must be able to return a LOW number too, or a high count + // proves nothing about attribution. + assert_eq!( + zero_arm, 0, + "with no creates, no create-attributable fsync may be counted (got {zero_arm})" + ); + assert!( + five_arm >= 5, + "five accepted creates must produce at least five fsyncs (got {five_arm})" + ); + assert!( + ordered, + "the fsync must complete BEFORE the 200 reaches the socket -- otherwise \ + '200 means durable' is false" + ); + println!( + "na0678 durability: EXAMINED 0-create and 5-create runs under strace; \ + fsync delta 0 and {five_arm}; fsync-before-200 ordering observed." + ); +} diff --git a/tests/na0678_invite_slots.rs b/tests/na0678_invite_slots.rs new file mode 100644 index 0000000..6b17122 --- /dev/null +++ b/tests/na0678_invite_slots.rs @@ -0,0 +1,755 @@ +// NA-0678 invite-slot contract (D614; DOC-SRV-007). The relay half of the +// messaging epic's Slice 1. +// +// What this file proves, and the shape of each proof: +// - the lifecycle, with the TOMBSTONE distinction: a second redemption is +// ALREADY_USED, never NOT_FOUND. A deleted slot would report "never existed" +// when the truth is "someone got here first" -- the interception signal. +// - the capability compare rejects a SAME-LENGTH wrong capability. The D-0014 +// lesson: a different-length wrong value can be rejected on length alone, so +// it proves nothing about the fold. Note this proves the ANSWER is right, not +// that the comparison runs in constant TIME -- that property is structural and +// read-verified, and no timing claim is made here. +// - atomic consume under real concurrency: exactly one winner. +// - the C3 non-regression: pushes to routes that are NOT slots behave exactly +// as before, which is what lets Slice 2 exist before the client is rewritten. +// - opacity: bundle and signature are stored and returned byte-identical for +// input that is not valid anything, and neither appears in logs. +// - both auth modes on every new route. +// - the create-rate bucket and the slot cap, which are NOT substitutes. +// - no /v1/invite/mint route exists. + +use std::sync::{Arc, Mutex}; + +use qsl_server::{ + app, AppState, InviteLimits, Limits, ResourceControls, ServerInfoCfg, StoreConfig, +}; +use reqwest::StatusCode as ReqStatus; +use serde_json::Value; +use tokio::net::TcpListener; +use tracing::subscriber::set_default; + +const ROUTE_TOKEN_HEADER: &str = "X-QSL-Route-Token"; +const TICKET_HEADER: &str = "X-QSL-Invite-Ticket"; + +#[derive(Clone)] +struct SharedWriter(Arc>>); + +impl std::io::Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(|e| panic!("{e}")) + .extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +async fn spawn( + relay_token: Option, + invites: InviteLimits, +) -> (String, tokio::task::JoinHandle<()>) { + let state = AppState::new_full( + Limits::new(1024 * 1024, 16).unwrap_or_else(|e| panic!("{e}")), + ResourceControls::new(64, 64, 64).unwrap_or_else(|e| panic!("{e}")), + relay_token, + StoreConfig::default(), + ServerInfoCfg::default(), + invites, + ) + .unwrap_or_else(|e| panic!("{e}")); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .unwrap_or_else(|e| panic!("{e}")); + let addr = listener.local_addr().unwrap_or_else(|e| panic!("{e}")); + let handle = tokio::spawn(async move { + axum::serve(listener, app(state)) + .await + .unwrap_or_else(|e| panic!("{e}")); + }); + (format!("http://{addr}"), handle) +} + +async fn spawn_open() -> (String, tokio::task::JoinHandle<()>) { + spawn(None, InviteLimits::default()).await +} + +fn b64(bytes: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(T[((n >> 6) & 63) as usize] as char); + } + if chunk.len() > 2 { + out.push(T[(n & 63) as usize] as char); + } + } + out +} + +fn unb64(s: &str) -> Vec { + fn val(c: u8) -> u8 { + match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'-' => 62, + b'_' => 63, + _ => panic!("bad b64 byte"), + } + } + let mut out = Vec::new(); + let (mut acc, mut bits) = (0u32, 0u32); + for c in s.bytes() { + acc = (acc << 6) | u32::from(val(c)); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xff) as u8); + } + } + out +} + +fn sha256_hex(s: &str) -> String { + use sha2::{Digest, Sha256}; + let d = Sha256::digest(s.as_bytes()); + let mut o = String::new(); + for b in d { + o.push_str(&format!("{b:02x}")); + } + o +} + +fn future_expiry() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + + 3600 +} + +async fn create_invite( + client: &reqwest::Client, + base: &str, + invite_id: &str, + cap: &str, + bundle: &[u8], + sig: &[u8], + expiry: i64, +) -> reqwest::Response { + client + .post(format!("{base}/v1/invite/create")) + .json(&serde_json::json!({ + "invite_id": invite_id, + "cap_hash": sha256_hex(cap), + "expiry": expiry, + "bundle_b64": b64(bundle), + "invite_sig_b64": b64(sig), + })) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) +} + +async fn redeem( + client: &reqwest::Client, + base: &str, + invite_id: &str, + cap: &str, +) -> reqwest::Response { + client + .post(format!("{base}/v1/invite/redeem")) + .json(&serde_json::json!({ "invite_id": invite_id, "cap": cap })) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) +} + +// ---------------------------------------------------------------- lifecycle + +#[tokio::test] +async fn lifecycle_create_redeem_consume_and_second_redeem_is_already_used() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let bundle = b"OPAQUE-BUNDLE-BYTES".to_vec(); + let sig = b"OPAQUE-SIGNATURE-BYTES".to_vec(); + + let created = create_invite( + &c, + &base, + "inv-lifecycle", + "cap-secret-1", + &bundle, + &sig, + future_expiry(), + ) + .await; + assert_eq!(created.status(), ReqStatus::OK); + let cdoc: Value = created.json().await.unwrap_or_else(|e| panic!("{e}")); + assert!( + cdoc["revoke_token"].as_str().is_some_and(|t| t.len() >= 32), + "create must return a revoke_token (F2)" + ); + + let r1 = redeem(&c, &base, "inv-lifecycle", "cap-secret-1").await; + assert_eq!(r1.status(), ReqStatus::OK); + let d: Value = r1.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(unb64(d["bundle_b64"].as_str().unwrap()), bundle); + assert_eq!(unb64(d["invite_sig_b64"].as_str().unwrap()), sig); + assert!( + d["ticket"].as_str().is_some_and(|t| t.len() >= 32), + "redeem must issue a one-shot handshake ticket (F3)" + ); + + // THE TOMBSTONE DISTINCTION. A deleted slot would answer NOT_FOUND here. + let r2 = redeem(&c, &base, "inv-lifecycle", "cap-secret-1").await; + assert_eq!(r2.status(), ReqStatus::CONFLICT); + assert_eq!( + r2.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_ALREADY_USED", + "a consumed slot must be distinguishable from one that never existed" + ); + h.abort(); +} + +#[tokio::test] +async fn unknown_invite_is_not_found_not_already_used() { + // The negative half of the tombstone claim: the two causes really are + // different, so the assertion above is not vacuous. + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let r = redeem(&c, &base, "inv-never-existed", "cap-x").await; + assert_eq!(r.status(), ReqStatus::NOT_FOUND); + assert_eq!( + r.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_NOT_FOUND" + ); + h.abort(); +} + +#[tokio::test] +async fn expired_invite_dies_and_returns_no_bundle() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + // Create with a 1-second life, then let it lapse. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let created = create_invite(&c, &base, "inv-exp", "cap-exp", b"B", b"S", now + 1).await; + assert_eq!(created.status(), ReqStatus::OK); + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + let r = redeem(&c, &base, "inv-exp", "cap-exp").await; + assert_eq!(r.status(), ReqStatus::GONE); + let body = r.text().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(body, "ERR_INVITE_EXPIRED"); + assert!( + !body.contains('B'), + "no bundle may leak on the expired path" + ); + h.abort(); +} + +// ---------------------------------------------------------------- capability + +#[tokio::test] +async fn same_length_wrong_capability_rejects_with_no_mutation() { + // "cap-secret-1" vs "cap-secret-X": SAME LENGTH. A different-length value + // could be rejected on length alone and would prove nothing about the fold. + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + create_invite( + &c, + &base, + "inv-ct", + "cap-secret-1", + b"BUNDLE", + b"SIG", + future_expiry(), + ) + .await; + + let bad = redeem(&c, &base, "inv-ct", "cap-secret-X").await; + assert_eq!(bad.status(), ReqStatus::FORBIDDEN); + assert_eq!( + bad.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_CAP_INVALID" + ); + + // NO MUTATION: the real capability still works, so the failed attempt did + // not consume the slot. + let good = redeem(&c, &base, "inv-ct", "cap-secret-1").await; + assert_eq!(good.status(), ReqStatus::OK); + h.abort(); +} + +#[tokio::test] +async fn same_length_wrong_revoke_token_rejects_with_no_mutation() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let created = create_invite( + &c, + &base, + "inv-rev", + "cap-rev", + b"BUNDLE", + b"SIG", + future_expiry(), + ) + .await; + let cdoc: Value = created.json().await.unwrap_or_else(|e| panic!("{e}")); + let real = cdoc["revoke_token"].as_str().unwrap().to_string(); + // Flip one character, preserving length. + let mut wrong: Vec = real.chars().collect(); + wrong[0] = if wrong[0] == 'a' { 'b' } else { 'a' }; + let wrong: String = wrong.into_iter().collect(); + assert_eq!(wrong.len(), real.len()); + + let bad = reqwest::Client::new() + .post(format!("{base}/v1/invite/revoke")) + .json(&serde_json::json!({ "invite_id": "inv-rev", "revoke_token": wrong })) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(bad.status(), ReqStatus::FORBIDDEN); + assert_eq!( + bad.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_REVOKE_INVALID" + ); + + // Not mutated: the slot is still redeemable. + assert_eq!( + redeem(&c, &base, "inv-rev", "cap-rev").await.status(), + ReqStatus::OK + ); + h.abort(); +} + +#[tokio::test] +async fn revoke_kills_the_slot_and_is_idempotent() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let created = create_invite( + &c, + &base, + "inv-kill", + "cap-kill", + b"BUNDLE", + b"SIG", + future_expiry(), + ) + .await; + let cdoc: Value = created.json().await.unwrap_or_else(|e| panic!("{e}")); + let tok = cdoc["revoke_token"].as_str().unwrap().to_string(); + + for _ in 0..2 { + let r = c + .post(format!("{base}/v1/invite/revoke")) + .json(&serde_json::json!({ "invite_id": "inv-kill", "revoke_token": tok })) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(r.status(), ReqStatus::OK, "revoke must be idempotent"); + let d: Value = r.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(d["revoked"], true); + } + + let after = redeem(&c, &base, "inv-kill", "cap-kill").await; + assert_eq!(after.status(), ReqStatus::GONE); + assert_eq!( + after.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_REVOKED" + ); + h.abort(); +} + +// ---------------------------------------------------------------- atomicity + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_redemption_yields_exactly_one_winner() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + create_invite( + &c, + &base, + "inv-race", + "cap-race", + b"BUNDLE", + b"SIG", + future_expiry(), + ) + .await; + + let mut tasks = Vec::new(); + for _ in 0..12 { + let (b, cl) = (base.clone(), c.clone()); + tasks.push(tokio::spawn(async move { + redeem(&cl, &b, "inv-race", "cap-race").await.status() + })); + } + let mut ok = 0usize; + let mut used = 0usize; + for t in tasks { + match t.await.unwrap_or_else(|e| panic!("{e}")) { + ReqStatus::OK => ok += 1, + ReqStatus::CONFLICT => used += 1, + other => panic!("unexpected status {other}"), + } + } + assert_eq!(ok, 1, "compare-and-set must yield exactly one winner"); + assert_eq!(used, 11, "every loser must see ALREADY_USED"); + h.abort(); +} + +// ------------------------------------------------- handshake ticket / C3 + +#[tokio::test] +async fn handshake_needs_the_ticket_and_the_ticket_is_one_shot() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + create_invite( + &c, + &base, + "inv-hs", + "cap-hs", + b"BUNDLE", + b"SIG", + future_expiry(), + ) + .await; + + // Without a ticket: refused, even though the pusher knows invite_id. + let no_ticket = c + .post(format!("{base}/v1/push")) + .header(ROUTE_TOKEN_HEADER, "inv-hs") + .body(b"handshake".to_vec()) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(no_ticket.status(), ReqStatus::FORBIDDEN); + assert_eq!( + no_ticket.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_TICKET_INVALID" + ); + + let d: Value = redeem(&c, &base, "inv-hs", "cap-hs") + .await + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + let ticket = d["ticket"].as_str().unwrap().to_string(); + + let ok = c + .post(format!("{base}/v1/push")) + .header(ROUTE_TOKEN_HEADER, "inv-hs") + .header(TICKET_HEADER, &ticket) + .body(b"handshake".to_vec()) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(ok.status(), ReqStatus::OK); + + // ONE SHOT: the same ticket a second time is refused. + let replay = c + .post(format!("{base}/v1/push")) + .header(ROUTE_TOKEN_HEADER, "inv-hs") + .header(TICKET_HEADER, &ticket) + .body(b"handshake-again".to_vec()) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(replay.status(), ReqStatus::FORBIDDEN); + + // Alice can still PULL her slot -- pull is deliberately ungated. + let pull = c + .get(format!("{base}/v1/pull?max=4")) + .header(ROUTE_TOKEN_HEADER, "inv-hs") + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(pull.status(), ReqStatus::OK); + h.abort(); +} + +#[tokio::test] +async fn non_slot_routes_are_completely_unaffected() { + // THE C3 GUARANTEE. A route the invite system never created must behave + // exactly as it did before this lane -- no ticket, no gate, no change. + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let push = c + .post(format!("{base}/v1/push")) + .header(ROUTE_TOKEN_HEADER, "ordinary-route") + .body(b"ordinary".to_vec()) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(push.status(), ReqStatus::OK, "no ticket required off-slot"); + + let pull = c + .get(format!("{base}/v1/pull?max=1")) + .header(ROUTE_TOKEN_HEADER, "ordinary-route") + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(pull.status(), ReqStatus::OK); + let d: Value = pull.json().await.unwrap_or_else(|e| panic!("{e}")); + assert_eq!(unb64(&b64(b"ordinary")), b"ordinary".to_vec()); + assert_eq!(d["items"][0]["data"].as_array().unwrap().len(), 8); + h.abort(); +} + +// ---------------------------------------------------------------- opacity + +#[tokio::test] +async fn bundle_is_opaque_bytes_in_bytes_out_and_never_logged() { + let buf = Arc::new(Mutex::new(Vec::new())); + let w = SharedWriter(buf.clone()); + let sub = tracing_subscriber::fmt() + .with_ansi(false) + .with_writer(move || w.clone()) + .finish(); + let _g = set_default(sub); + + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + // Deliberately NOT valid anything: no TLV, no UTF-8, no structure. The relay + // must neither parse nor care. + let junk: Vec = (0..=255u8).rev().collect(); + let sig: Vec = vec![0xFF, 0x00, 0xFE, 0x01]; + create_invite( + &c, + &base, + "inv-opaque", + "cap-op", + &junk, + &sig, + future_expiry(), + ) + .await; + let d: Value = redeem(&c, &base, "inv-opaque", "cap-op") + .await + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!( + unb64(d["bundle_b64"].as_str().unwrap()), + junk, + "byte-identical" + ); + assert_eq!(unb64(d["invite_sig_b64"].as_str().unwrap()), sig); + + h.abort(); + let logged = String::from_utf8_lossy(&buf.lock().unwrap_or_else(|e| panic!("{e}"))).to_string(); + assert!( + !logged.contains("inv-opaque"), + "raw invite_id must not be logged" + ); + assert!(!logged.contains(&b64(&junk)), "bundle must not be logged"); + assert!(logged.contains("channel_id="), "redacted id must be logged"); +} + +// ---------------------------------------------------------------- auth modes + +#[tokio::test] +async fn every_invite_route_is_gated_on_a_bearer_relay() { + let (base, h) = spawn(Some("topsecret".to_string()), InviteLimits::default()).await; + let c = reqwest::Client::new(); + for (path, body) in [ + ( + "/v1/invite/create", + serde_json::json!({"invite_id":"x","cap_hash":"y","expiry":future_expiry(),"bundle_b64":"QQ","invite_sig_b64":"QQ"}), + ), + ( + "/v1/invite/redeem", + serde_json::json!({"invite_id":"x","cap":"y"}), + ), + ( + "/v1/invite/revoke", + serde_json::json!({"invite_id":"x","revoke_token":"y"}), + ), + ] { + let r = c + .post(format!("{base}{path}")) + .json(&body) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(r.status(), ReqStatus::UNAUTHORIZED, "{path} must be gated"); + // Plain ERR_UNAUTHORIZED -- the new routes do NOT adopt the server-info + // probe body (DOC-SRV-006 rule 4). + assert_eq!( + r.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_UNAUTHORIZED", + "{path} must not adopt the capability-probe body" + ); + } + // With the token, create works. + let ok = c + .post(format!("{base}/v1/invite/create")) + .header("Authorization", "Bearer topsecret") + .json(&serde_json::json!({ + "invite_id":"inv-auth","cap_hash":sha256_hex("cap"),"expiry":future_expiry(), + "bundle_b64":b64(b"B"),"invite_sig_b64":b64(b"S")})) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!(ok.status(), ReqStatus::OK); + h.abort(); +} + +// ------------------------------------------------- rate bucket and slot cap + +#[tokio::test] +async fn create_rate_bucket_exhausts_and_creates_no_slot() { + // F6: availability of invite-create is a security property. The bucket and + // the cap are BOTH required and are not substitutes -- this is the bucket. + let limits = InviteLimits::new(256, 16 * 1024, 259_200, 2, 0).unwrap_or_else(|e| panic!("{e}")); + let (base, h) = spawn(None, limits).await; + let c = reqwest::Client::new(); + let e = future_expiry(); + assert_eq!( + create_invite(&c, &base, "r1", "c1", b"B", b"S", e) + .await + .status(), + ReqStatus::OK + ); + assert_eq!( + create_invite(&c, &base, "r2", "c2", b"B", b"S", e) + .await + .status(), + ReqStatus::OK + ); + + let limited = create_invite(&c, &base, "r3", "c3", b"B", b"S", e).await; + assert_eq!(limited.status(), ReqStatus::TOO_MANY_REQUESTS); + assert_eq!( + limited.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_RATE_LIMITED" + ); + // NO SLOT CREATED by the refused call. + assert_eq!( + redeem(&c, &base, "r3", "c3").await.status(), + ReqStatus::NOT_FOUND, + "a rate-limited create must not have stored anything" + ); + h.abort(); +} + +#[tokio::test] +async fn slot_cap_rejects_and_never_evicts() { + let limits = + InviteLimits::new(2, 16 * 1024, 259_200, 4096, 0).unwrap_or_else(|e| panic!("{e}")); + let (base, h) = spawn(None, limits).await; + let c = reqwest::Client::new(); + let e = future_expiry(); + assert_eq!( + create_invite(&c, &base, "cap1", "c1", b"B", b"S", e) + .await + .status(), + ReqStatus::OK + ); + assert_eq!( + create_invite(&c, &base, "cap2", "c2", b"B", b"S", e) + .await + .status(), + ReqStatus::OK + ); + + let full = create_invite(&c, &base, "cap3", "c3", b"B", b"S", e).await; + assert_eq!(full.status(), ReqStatus::TOO_MANY_REQUESTS); + assert_eq!( + full.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_CAP_FULL" + ); + // NEVER EVICTS: both earlier slots survive. An eviction path would let an + // attacker delete other people's invites -- worse than the denial. + assert_eq!( + redeem(&c, &base, "cap1", "c1").await.status(), + ReqStatus::OK + ); + assert_eq!( + redeem(&c, &base, "cap2", "c2").await.status(), + ReqStatus::OK + ); + h.abort(); +} + +#[tokio::test] +async fn oversize_bundle_is_refused() { + let limits = InviteLimits::new(256, 64, 259_200, 4096, 0).unwrap_or_else(|e| panic!("{e}")); + let (base, h) = spawn(None, limits).await; + let c = reqwest::Client::new(); + let big = vec![7u8; 65]; + let r = create_invite(&c, &base, "inv-big", "cap", &big, b"S", future_expiry()).await; + assert_eq!(r.status(), ReqStatus::PAYLOAD_TOO_LARGE); + assert_eq!( + r.text().await.unwrap_or_else(|e| panic!("{e}")), + "ERR_INVITE_TOO_LARGE" + ); + h.abort(); +} + +// ---------------------------------------------------------------- route set + +#[tokio::test] +async fn there_is_no_mint_route() { + // F1 deleted it. The client mints the capability and uploads only its hash, + // so no relay-side path ever holds a capability in plaintext. + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + for path in ["/v1/invite/mint", "/v1/invite/capability", "/v1/mint"] { + let r = c + .post(format!("{base}{path}")) + .json(&serde_json::json!({})) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")); + assert_eq!( + r.status(), + ReqStatus::NOT_FOUND, + "{path} must not exist -- F1 removed relay-side minting" + ); + } + h.abort(); +} + +#[tokio::test] +async fn server_info_advertises_invite_v1_additively() { + let (base, h) = spawn_open().await; + let c = reqwest::Client::new(); + let doc: Value = c + .get(format!("{base}/v1/server-info")) + .send() + .await + .unwrap_or_else(|e| panic!("{e}")) + .json() + .await + .unwrap_or_else(|e| panic!("{e}")); + let api = doc["api"].as_array().unwrap(); + assert!(api.iter().any(|v| v == "invite_v1")); + // Additive: every pre-existing entry survives, in order. + for (i, want) in ["push_v1", "pull_v1", "pull_ack_lease_v1"] + .iter() + .enumerate() + { + assert_eq!(&api[i], want, "pre-existing api entries must not move"); + } + assert!(doc["invite"]["max_expiry_secs"].is_number()); + assert!(doc["invite"]["max_slots"].is_number()); + assert!(doc["limits"]["max_invite_bundle_bytes"].is_number()); + h.abort(); +} diff --git a/tests/na0678_schema_version.rs b/tests/na0678_schema_version.rs new file mode 100644 index 0000000..03c2454 --- /dev/null +++ b/tests/na0678_schema_version.rs @@ -0,0 +1,132 @@ +// NA-0678 (D614 F5): the store's schema-version marker must track reality. +// +// The defect this closes, measured during the D614 census rather than inferred: +// the marker was written with `INSERT OR IGNORE`, a no-op on an existing key, so +// a forward migration never advanced it. A SCHEMA_VERSION=2 binary opened a v1 +// store, created its new table, and left `meta.schema_version = '1'`. The +// fail-closed downgrade guard D-0011 designed ("a store written by a NEWER +// binary must refuse to open") could therefore never fire after the first schema +// change -- and NA-0678 is the first schema change since D-0011. +// +// These tests are the guard's positive AND negative control: one proves the +// marker advances, the other proves the refusal still happens. A test that only +// checked the happy path would have passed against the broken code. + +use qsl_server::{AppState, InviteLimits, Limits, ResourceControls, ServerInfoCfg, StoreConfig}; +use rusqlite::Connection; + +fn temp_db(tag: &str) -> String { + let dir = std::env::temp_dir().join(format!( + "na0678-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}")); + dir.join("relay.db").to_string_lossy().into_owned() +} + +fn open_store(path: &str) -> Result { + AppState::new_full( + Limits::default(), + ResourceControls::default(), + None, + StoreConfig { + path: path.to_string(), + ..StoreConfig::default() + }, + ServerInfoCfg::default(), + InviteLimits::default(), + ) +} + +fn stored_version(path: &str) -> String { + let c = Connection::open(path).unwrap_or_else(|e| panic!("{e}")); + c.query_row( + "SELECT value FROM meta WHERE key='schema_version'", + [], + |r| r.get::<_, String>(0), + ) + .unwrap_or_else(|e| panic!("{e}")) +} + +#[test] +fn a_fresh_store_records_the_current_version() { + let path = temp_db("fresh"); + open_store(&path).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stored_version(&path), "2"); +} + +#[test] +fn a_forward_migrated_store_advances_its_marker() { + // Build a store that looks like one this binary's predecessor created: the + // pre-NA-0678 tables, and a marker reading "1". + let path = temp_db("migrate"); + { + let c = Connection::open(&path).unwrap_or_else(|e| panic!("{e}")); + c.execute_batch( + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE routes ( + route_key TEXT PRIMARY KEY, log_id TEXT NOT NULL, + created_at INTEGER NOT NULL, last_touched INTEGER NOT NULL); + CREATE TABLE messages ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, msg_id TEXT NOT NULL, + route_key TEXT NOT NULL REFERENCES routes(route_key) ON DELETE CASCADE, + body BLOB NOT NULL, enqueued_at INTEGER NOT NULL, leased_until INTEGER); + INSERT INTO meta(key, value) VALUES('schema_version', '1');", + ) + .unwrap_or_else(|e| panic!("{e}")); + } + assert_eq!( + stored_version(&path), + "1", + "precondition: the store starts at 1" + ); + + open_store(&path).unwrap_or_else(|e| panic!("{e}")); + + // THE FIX. Before NA-0678 this assertion failed while everything else about + // the migration succeeded -- the new table appeared and the marker did not + // move, which is exactly what made the defect invisible. + assert_eq!( + stored_version(&path), + "2", + "a forward migration must advance the marker, or the downgrade guard is inert" + ); + + // And the migration really did happen. + let c = Connection::open(&path).unwrap_or_else(|e| panic!("{e}")); + let has_invites: bool = c + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='invites')", + [], + |r| r.get(0), + ) + .unwrap_or_else(|e| panic!("{e}")); + assert!(has_invites, "the invites table must exist after migration"); +} + +#[test] +fn a_store_from_a_newer_binary_is_refused() { + // The negative control: the guard must still FIRE. Without this, the test + // above could pass against an implementation that simply stopped checking. + let path = temp_db("newer"); + { + let c = Connection::open(&path).unwrap_or_else(|e| panic!("{e}")); + c.execute_batch( + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + INSERT INTO meta(key, value) VALUES('schema_version', '99');", + ) + .unwrap_or_else(|e| panic!("{e}")); + } + // `AppState` is deliberately not `Debug` (it holds secrets), so match rather + // than `expect_err` -- adding a derive to satisfy a test would be the tail + // wagging the dog. + match open_store(&path) { + Err(e) => assert_eq!(e, "ERR_STORE_VERSION"), + Ok(_) => panic!("a store written by a newer binary must be refused"), + } +}