Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <token>` -> JSON `{ "items": [ { "id": "<msg_id>", "data": [<byte>, ...] }, ... ] }` (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`
Expand All @@ -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 <token>` 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)
Expand Down
1 change: 1 addition & 0 deletions TRACEABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading