---
id: PRD-0007
title: "M6 — Server Hardening for Public Exposure"
status: done
owner: "Aaron Roney"
created: 2026-07-02
updated: 2026-07-04

depends_on:
- PRD-0006

principles:
- "Post-deploy the server faces an untrusted network: every frame is hostile until proven otherwise."
- "Identity-namespace integrity is the spine — no username or key is durably enrolled without a possession proof."
- "Fail closed and never panic on wire input; a single crafted frame must not corrupt state, exhaust memory, or crash the process."
- "Hardening, not new subsystems (DESIGN §19 stays deferred). Every fix ships a regression test (constitution §8)."

references:
- name: "Conclave design (identity/chain-of-trust §5, channels/invites/visibility §6, admin §7, presence/errors §10/§16)"
  url: "docs/DESIGN.md"
- name: "Post-v1 review findings (auth-crypto, acl-invites, concurrency-presence dimensions)"
  url: "docs/DESIGN.md"

acceptance_tests:
- id: uat-001
  name: "A failed or mismatched Auth after Register persists no user or machine row (possession precedes enrollment)"
  command: cargo nextest run -E 'test(/register_proof/)'
  uat_status: verified
- id: uat-002
  name: "Allowlisted admin usernames cannot be claimed over the wire by a non-matching key; the reserved name resolves only to the bound pubkey"
  command: cargo nextest run -E 'test(/admin_bootstrap/)'
  uat_status: verified
- id: uat-003
  name: "Concurrent joins on a single-use invite admit exactly one user; concurrent ACL mutations lose no entries"
  command: cargo nextest run -E 'test(/concurrent_/)'
  uat_status: verified
- id: uat-004
  name: "Deleting or renaming a channel invalidates its outstanding invites; UserRemove purges the name from all ACLs and created_by"
  command: cargo nextest run -E 'test(/invites_/) + test(/user_remove_purge/)'
  uat_status: verified
- id: uat-005
  name: "A huge invite --expires-in returns an error frame instead of overflow-panicking; the server process stays up"
  command: cargo nextest run -E 'test(/absurd_expiry/)'
  uat_status: verified
- id: uat-006
  name: "A non-member cannot distinguish a private/unlisted channel from a nonexistent one; who() works for a participant of a public channel"
  command: cargo nextest run -E 'test(/who_/)'
  uat_status: verified
- id: uat-007
  name: "Pre-auth connections time out; an over-cap (>16 MiB) frame is rejected before buffering the full payload"
  command: cargo nextest run -E 'test(/handshake_timeout/) + test(/oversized_frame/)'
  uat_status: verified
- id: uat-008
  name: "Full gate is green (cargo make uat)"
  command: cargo make uat
  uat_status: verified

tasks:
- id: T-001
  title: "Possession-proof all key enrollment (register + machine-add); roll back aborted handshakes"
  priority: 1
  status: done
  notes: "DONE (register path): the handshake verifies auth_pubkey==pubkey + identity::verify BEFORE hub.register (verify-first; a failed/mismatched/aborted registration persists nothing). Covered by test(/register_proof/). DONE (machine-add, #8): AdminOp::MachineAdd is self-service — it adds a key to the *caller's own* account only (no cross-account key-planting), and the enrolled key confers nothing until its holder proves possession at first-connect auth (challenge signature verified against the pubkey). Enrollment-time proof would add no security (a party able to enroll their own key can equally sign an enrollment challenge). Scoped+documented in hub.rs and covered by test(/machine_add_key_still_proves/)."
- id: T-002
  title: "Bind the admin allowlist to keys; reserve allowlisted usernames"
  priority: 1
  status: done
  notes: "DONE. The allowlist is now `AdminAllowlist` (username -> optional bound pubkey-b64). `serve --admin user=<pubkey-b64>` pins the name; Hub::register rejects claiming a pinned admin username with a non-matching key (typed AuthError::Reserved -> Unauthorized), and the pin also blocks re-claim after removal. Admin remains a per-username property, so all of the bound user's machines inherit it. Bare `--admin user` stays supported but logs a squat warning at startup. Covered by test(/admin_bootstrap/). Finding #7 (HIGH)."
- id: T-003
  title: "Atomic channel-record mutations (ACL RMW + invite redemption)"
  priority: 1
  status: done
  notes: "DONE, DB-native (no application mutex). ACL normalized from an embedded array into a `membership` table (unique `(channel, user)` index): concurrent joins INSERT distinct keys, so there is no shared-row contention and no lost updates (empirically the embedded SurrealKV backend does NOT reliably serialize concurrent same-row writes — it silently drops updates — so `array::union` + retry was insufficient; normalization sidesteps it). Invite redemption is a guarded atomic decrement `UPDATE invite SET uses_remaining = uses_remaining - 1 WHERE uses_remaining > 0 RETURN VALUE`, so exactly one concurrent redeemer of the last use wins; writes retry on a `TransactionWriteConflict` per SurrealDB's OCC contract. Membership cascades on channel delete/rename. Stress-verified 30/30 with retries off. Findings #2/#11 (HIGH). Ban/join TOCTOU (#30) now closed: `subscribe` re-checks the ban under the same lock as the subscription insert, so a ban racing a join cannot be bypassed (test `subscribe_re_checks_the_ban_atomically`)."
- id: T-004
  title: "Invite cascade on channel delete/rename; UserRemove ACL/created_by purge"
  priority: 1
  status: done
  notes: "DONE. delete_channel now also deletes the channel's invites (and, from T-003, its memberships); rename_channel migrates both invites and memberships to the new name — so a deleted channel's tokens cannot open a future same-named channel (#5). UserRemove now deletes every channel the user created (cascading those channels' memberships + invites) and purges the user's memberships elsewhere before deleting the user, so re-registering the freed name inherits neither channel-admin rights nor private access (#6). Findings #5 (HIGH), #6 (HIGH)."
- id: T-005
  title: "Eliminate network-reachable panics on the frame path"
  priority: 1
  status: done
  notes: "DONE. InviteCreate's expiry was `Utc::now() + Duration::seconds(secs)` which overflow-panics (`TimeDelta::seconds out of bounds`) on a large expires_in_secs — reachable from the wire and the CLI's --expires-in. Replaced with a checked `invite_expiry` helper (i64::try_from + TimeDelta::try_seconds + checked_add_signed) that returns a MalformedFrame error instead. Findings #10/#22 (MED)."
- id: T-006
  title: "Validate identity inputs (usernames, session handles)"
  priority: 2
  status: done
  notes: "DONE. New `SessionPath::validate_component` rejects an empty or `/`-bearing component (centralized in base, constitution §7). The handshake validates the Register username + machine name BEFORE the durable enroll and the Hello session handle before building the path — so a `/` can't corrupt `{user}/{machine}/{session}` or spoof from-attribution. Covered by base unit tests + `test(/path_separator/)`. Finding #9 (MED)."
- id: T-007
  title: "Close discovery / oracle leaks"
  priority: 2
  status: done
  notes: "DONE for #12/#13. who() is now visibility-aware: public presence is queryable by anyone (so a public-channel participant with no ACL entry can see it, #13); a private/unlisted channel returns the same NotFound to a non-member as an absent channel, so its existence never leaks (#12). join()/redeem_invite denials on a private channel likewise return NotFound (uniform with absent) rather than a distinguishable ChannelPrivate. #29 (InviteRevoke existence oracle, LOW) now closed: revoke returns a uniform ack whether or not the token exists and deletes only for the channel admin (`is_channel_admin`), so a non-admin cannot distinguish token existence (test `invite_revoke_gives_a_uniform_ack_and_no_delete_for_non_admins`). Findings #12, #13 (MED)."
- id: T-008
  title: "Pre-auth and backpressure DoS guards"
  priority: 2
  status: done
  notes: "DONE: handshake timeout (#15) — run_session wraps the handshake in a 10s timeout, so a silent/stalled pre-auth peer (invisible to the idle reaper) is dropped; and the WS frame cap (#17/#19) — ws_handler sets axum's max_message_size/max_frame_size to Constant::MAX_FRAME_SIZE (16 MiB) instead of tungstenite's 64 MiB default. Covered by test(/handshake_timeout/) + test(/oversized_frame/). REMAINING (deferred — need more design): #14 bounded per-session outbound queue with a slow-consumer drop/disconnect policy (changes the fan-out sender type), and #16 half-open reconnect fast-path (supersede a stale same-path session — conflicts with the current HandleCollision reject semantics; needs a live-vs-half-open distinction)."
---

# Summary

Close the security and robustness gaps that matter the instant `conclave serve` is reachable by
anyone but its operator. The centerpiece is identity-namespace integrity — no username or public
key is durably enrolled without proving possession — plus atomic membership mutations, elimination
of wire-reachable panics, discovery-leak closure, and pre-auth/backpressure DoS guards.

# Problem

v1 was built and proven on the happy path (M0–M5, `v0.1.0`). A post-v1 adversarial review
(42 agents, 28 confirmed findings) surfaced a cluster of network-facing defects that are latent
today only because the server has never been exposed. The most serious lets an **unauthenticated**
client durably squat usernames and enroll public keys it never proved it holds (`session.rs:132`
persists before the Auth proof at `:145`). Others allow concurrent single-use-invite double-redeem,
lost ACL entries, a reachable panic from a crafted `--expires-in`, private-channel existence
oracles, and unbounded pre-auth memory growth. All must be fixed before the deployment milestone
(PRD-0009) exposes the origin.

# Goals

1. No durable identity write without a verified possession proof (register and machine-add).
2. Admin allowlist bound to keys, not squattable names.
3. Atomic channel-record mutations: no lost ACLs, no invite over-redemption.
4. Referential integrity for invites/ACLs across channel delete/rename and user removal.
5. Zero network-reachable panics; uniform discovery errors; pre-auth timeout + frame-size + backpressure caps.

# Technical Approach

Reorder the handshake so `hub.register` runs only after `identity::verify` succeeds (or wrap
register+auth in a rollback path). Move channel-record read-modify-write behind a serialization
point — either an async `Mutex` held across the store round-trip, or (preferred, and the reason the
embedded SurrealDB SDK was chosen) a server-side conditional `UPDATE ... WHERE ... RETURN` inside a
transaction so redemption and ACL append are atomic. Add `Store` methods for invite cascade and
ACL/creator purge. Replace overflowing arithmetic with checked variants surfaced as
`ProtocolError`. Collapse `ChannelNotFound`/`NotMember` into one non-differentiating error for
non-members and re-gate `who()` on subscription. Enforce a handshake deadline (a pre-auth reaper
bucket), set tungstenite's max frame/message size to `Constant::MAX_FRAME_SIZE`, and bound the
per-session outbound channel with an explicit slow-consumer policy.

```
BEFORE:  Hello -> Challenge -> Register(persist!) -> [Auth verify]   <-- durable write before proof
AFTER:   Hello -> Challenge -> Register(stage) -> Auth verify -> commit | drop  <-- proof gates the write
```

# Assumptions

- M0–M5 are complete and green (`v0.1.0`); this is additive hardening on the existing server.
- TLS/edge termination and deployment are out of scope here (PRD-0009); fixes are transport-agnostic.

# Constraints

- No new subsystems and no store-and-forward/HA (DESIGN §19 stays deferred).
- Protocol changes, if any, remain append-only and forward-compatible (protocol version discipline).
- In-memory presence/subscription model is retained; fixes must not require durable presence.

# References to Code

- `src/server/session.rs` — handshake ordering (:112 timeout, :132 register, :145 verify, :170 validation).
- `src/server/hub.rs` — register/resolve (:119), join/ACL (:224), redeem_invite (:437), admin ops (:329/:396/:414), who (:266/:267), invite expiry (:367), half-open (:149).
- `src/server/wss.rs` — axum/tungstenite transport config (:82/:91/:96).
- `src/store.rs` — channel/invite/user repository (set_channel_acl :367 and new cascade/purge methods).

# Non-Goals (MVP)

- Durable channel bans, account recovery, sender signatures, E2E — all DESIGN §19 / v2+.
- Cross-machine possession-proof protocol for machine-add beyond a minimal challenge (may be scoped/documented if the full dance is deferred).
- Client-side, CLI, and deployment fixes (PRD-0008 / PRD-0009).

# History
(Entries appended during implementation go below this line.)

## 2026-07-03 — T-008 (#14, #16): outbound backpressure + half-open reconnect

- **#14 (unbounded outbound):** the per-session server→client queue was `mpsc::unbounded_channel`, so
  a slow/stalled consumer grew server memory without limit as fan-out kept enqueuing. Bounded it to
  `OUTBOUND_CAPACITY` (1024): `post`/`whisper` now `try_send` and force-drop (notify the session's
  `kill`) a consumer whose queue is full or closed; the session's own replies became best-effort
  `try_send` (the handshake queue is fresh so criticals never drop). Test:
  `fanout_drops_a_slow_consumer_instead_of_growing_its_queue`.
- **#16 (half-open reconnect delay):** an ungraceful drop left a stale session in the registry until
  the idle reaper (~75s), and a reconnect collided (`HandleCollision` → `Unauthorized`). `attach` now
  *supersedes* a prior session for the same path (force-drops it, takes over) — possession is proven
  at auth, so only the identity holder can take over its own handle. `detach` is guarded by session
  identity (`Arc::ptr_eq` on `kill`) so the superseded session's cleanup can't evict its replacement;
  `attach` is now infallible and the dead `HandleCollision` variant was removed. Test reworked:
  `server_reconnect_supersedes_a_stale_live_handle`.

Both T-008 findings done; gate green (159 tests).

## 2026-07-03 — low leaks (#29, #30) closed

The two LOW findings deferred under T-003/T-007 are now resolved. **#30 (ban/join TOCTOU):** `join`
checked the in-memory ban, then awaited store calls, then subscribed — a ban landing in that window
was bypassed. `subscribe` now re-checks the ban under the same lock as the subscription insert and
returns `false` (join surfaces the uniform `ChannelPrivate` denial). **#29 (revoke existence
oracle):** the old handler returned `Unauthorized` for an existing token vs `Ack` for an absent one,
letting a non-admin probe token existence; revoke now returns a uniform `Ack` regardless and deletes
only when the caller administers the channel. Gate green (161 tests).

## 2026-07-02 — T-001 (register path): verify-first handshake

The registration handshake now proves possession **before** any durable write. `session.rs`
previously called `hub.register` (durable `create_user` + `create_machine`) the instant a `Register`
frame arrived, then read and verified the `Auth` — so a bad signature, a mismatched key, or a
disconnect left the username squatted and the unproven key enrolled (finding #1, HIGH). The Auth is
now read and verified (`auth_pubkey == pubkey` + `identity::verify`) first; `hub.register` runs only
on success. Verify-first, so no rollback path is needed.

TDD: two `test(/register_proof/)` cases (bad signature, mismatched key) fail on the old ordering
with `username "aaron" is already taken` and pass after the reorder; full gate green (123 tests).
The disconnect-after-Register path is fixed by the same reorder but is not separately tested — a
deterministic version would race the server's frame processing (avoided per the no-sleep-races
convention). uat-001 reworded to match what is deterministically proven.

T-001 is `done`: register proves possession before any write, and `MachineAdd` is scoped to self-service enrollment with possession proven at first-connect auth (finding #8, documented + tested).

## 2026-07-02 — T-002: key-bound admin allowlist

The server-admin allowlist changed from `HashSet<String>` to `AdminAllowlist`
(`username -> Option<pubkey-b64>`). `serve --admin aaron=<pubkey>` pins the name to a key;
`Hub::register` now rejects any attempt to claim a pinned admin username with a non-matching key
(new typed `AuthError::Reserved`, mapping to `Unauthorized`) — closing the fresh-deploy admin-squat
(finding #7, HIGH). The pin also protects re-claims after `UserRemove`. Admin stays a per-username
property (`is_admin` is a key lookup), so every machine the bound user enrolls inherits admin, as
before. Bare `--admin aaron` still works but logs a squat warning at startup.

TDD: `test(/admin_bootstrap/)` — a non-matching key claiming a pinned admin name is rejected while
the bound key claims it and gets admin rights; it fails on the pre-enforcement build (attacker gets
`Established`) and passes after. Full gate green (124 tests).

## 2026-07-02 — T-003: atomic membership + invite redemption (DB-native)

The ACL double-spend/lost-update bugs (finding #2) are fixed **without an application mutex**, at
the DB layer, after empirically establishing that the embedded SurrealKV backend does **not**
reliably serialize concurrent writes to the *same* row — a 12-way concurrent `array::union` on one
channel row silently dropped an entry (no conflict error to retry on). Confirmed via web + the
pinned crate source: SurrealKV is optimistic-concurrency (`TransactionWriteConflict` →
`TransactionConflict`), and its own docs say the app must retry.

Fix, chosen with the user (option A — normalize):
- **Membership** moved from `ChannelRecord.acl: Vec<String>` to a `membership` table with a unique
  `(channel, user)` index. Concurrent joins `INSERT` **distinct keys** → no shared-row contention,
  no lost updates. Add is idempotent (`ON DUPLICATE KEY UPDATE`); membership cascades on channel
  delete and rename. `join`/`list_channels`/`who` now consult membership rows.
- **Invite uses** stay a single counter but use a **guarded atomic decrement**
  (`... SET uses_remaining = uses_remaining - 1 WHERE uses_remaining > 0 RETURN VALUE`): only one
  writer ever mutates, so concurrent redeemers of the last use are mutually exclusive. This path was
  reliable in stress testing (the guard means near-zero write contention).
- A bounded **retry-on-`conflict`** helper in the store covers any residual OCC conflict.

TDD: two concurrency tests — a 16-way single-use redeem admits exactly one; a 12-way concurrent
join preserves every membership. Both were flaky/failing before (the ACL one silently lost an
entry), and are now **30/30 clean with retries off**. Full gate green (127 tests). The ban/join
TOCTOU (#30) remains open.

## 2026-07-02 — T-004: referential integrity on delete/rename/user-removal

`delete_channel` now also drops the channel's invites (memberships were already cascaded in T-003),
and `rename_channel` migrates both invites and memberships to the new name — so a deleted channel's
outstanding tokens can no longer open a future same-named channel (#5). `UserRemove` now deletes
every channel the removed user created (cascading those channels' memberships + invites) and purges
the user's memberships elsewhere before deleting the account, so re-registering the freed username
inherits neither channel-admin rights nor private access (#6).

TDD: `test(/invites_/)` (invite dropped on delete, invite follows rename) + `test(/user_remove_purge/)`
(removed user's created channel deleted, membership elsewhere purged). Full gate green (130 tests).

## 2026-07-04 — status reconciled to done

All eight tasks were completed and all eight UATs verified during 2026-07-02/03 (shipped in
v0.2.0); the frontmatter `status` was never flipped from `active`. Reconciled — no code change.
