diff --git a/PEER_AUTH_PLAN.md b/PEER_AUTH_PLAN.md index 75fddb7..0985716 100644 --- a/PEER_AUTH_PLAN.md +++ b/PEER_AUTH_PLAN.md @@ -1,583 +1,1422 @@ -# Rework peer authentication: keys, signed events, real identity +# Peer authentication, identity continuity, and download-source authorization ## Status -Proposal. Supersedes the line in `CALL_TO_PLAY_FIXES_PLAN.md` that said not to -introduce cryptographic peer identities: Call to Play's event/merge design -stays exactly as it is, but the identities it is keyed on become real. - -Nothing in this document is implemented yet. - -## 1. Where we are today - -Everything a peer claims about itself is a string it made up, and every trust -decision in the peer runtime is made on those strings. - -- **One shared TLS identity for the entire product.** `config.rs:48-51` - compiles `cert.pem` and `key.pem` from the repository root into the binary. - The QUIC server presents it (`services/server.rs:31`) and the client trusts - exactly it, under the server name `localhost` (`network.rs:70-78`). The - private key is in git. Any machine on the LAN can therefore terminate, - impersonate, and man-in-the-middle any peer connection: the transport - authenticates "some build of Lanspread", not "this peer". -- **`peer_id` is a self-asserted UUID.** `identity.rs:11-26` generates a - UUIDv7 and stores it in `/peer_id`. It is announced in mDNS TXT - records (`services/advertise.rs:96`), in `Hello`/`HelloAck`, and re-sent as a - field inside individual request bodies. -- **The peer table is mutated from unauthenticated input.** - `services/discovery.rs:165-182` upserts a peer record and its library - revision straight from mDNS TXT data, before any handshake. - `peer_db.rs:78-117` rebinds a known `peer_id` to whatever address the caller - supplies, and evicts whichever peer previously held that address. -- **Request envelopes carry the sender's identity as payload.** - `services/stream.rs:89-116` reads `peer_id` out of `Request::LibraryDelta`, - `Request::CallToPlayEvents` and `Request::Goodbye`. Concretely: - - `handle_goodbye` (`services/stream.rs:497-504`) removes any peer named in - the request and ignores the transport address entirely. Any LAN host can - evict any peer from anyone's peer list, repeatedly. - - `handle_library_delta` (`services/stream.rs:223`) attributes a library - delta to the named peer. - - `handle_call_to_play_events` (`services/stream.rs:124-138`) validates that - each event's `actor_id` equals the `peer_id` **from the same request body**, - which is a self-consistency check, not authentication. - - `note_peer_activity` (`services/stream.rs:178-185`) refreshes liveness for - whoever opened the stream. -- **Call to Play authority is a string comparison.** `HistoryIndex::build` - (`call_to_play.rs:246-321`) treats the earliest `Create` event's `actor_id` as - the creator and accepts `Start`, `Cancel`, and `AddTime` from that string. - A hostile peer can cancel or start anyone's call, chat as anyone, and — since - `MAX_EVENTS` (`call_to_play.rs:20`) is a single global 4096-event bound — fill - the shared history until legitimate local publishes fail with `HistoryFull`. -- **`ARCHITECTURE.md` already says this out loud** in the Call to Play - replication and streamed-install sections: current checks prevent accidental - identity mixing, not a hostile LAN peer. - -Two of those sentences are the reason for this plan. A LAN party is a -semi-trusted network: the people are fine, the network is a hotel/venue/dorm -segment with unknown machines on it, and "griefing the guy who cancelled my -match" is a five-line Python script today. - -## 2. Goals - -1. Every peer has a **long-lived cryptographic identity** it controls, and - `peer_id` is derived from that key rather than asserted. -2. The identity **survives app updates, reinstalls, and IP changes**, and is - stored with platform-appropriate protection at rest. -3. **The transport authenticates the peer**, so QUIC connections cannot be - impersonated or man-in-the-middled by another LAN host. -4. **Every control message is attributable** to the key that produced it, and - every trust decision in the runtime is made on the verified identity instead - of a payload field. -5. **Stored and forwarded objects carry their own signatures.** Call to Play - events are relayed by third parties in handshake snapshots, so channel - authentication alone is not enough for them. -6. The trust state is **visible and manageable by the user**: who is here, - which key that is, is this the same "Alice" as last time, block this peer. -7. Failure modes are honest: no silent identity regeneration, no silent - downgrade to a weaker store, no "verified" badge that means nothing. - -## 3. Non-goals - -- **No PKI, no CA, no accounts, no internet dependency.** Self-certifying keys - only; trust is first-use pinning plus explicit user action. -- **No content trust.** Game bytes stay sender-controlled; a peer that is - authenticated is not thereby a peer that ships honest archives. That needs - catalog-owned hashes (`NEXT_STEPS.md:37`, `ARCHITECTURE.md` streamed-install - section) and is a separate piece of work. This plan must not imply it is - solved. -- **No wire compatibility.** Per `CLAUDE.md`, there is one wire version. Each - wire-affecting phase bumps `PROTOCOL_VERSION`; older builds are simply out. -- **No anonymity or metadata privacy.** Peer keys, ids, and display names are - public on the segment by design. -- **No clock trust.** Wall-clock skew tolerance stays a documented assumption, - as it already is for Call to Play deadlines. - -## 4. Threat model - -Attacker sits on the same L2 segment, can send and receive arbitrary packets, -can run a modified Lanspread build, and knows everything in the git repository -(including today's `key.pem`). The attacker does not have local code execution -on a victim machine and is not an OS-level adversary on that machine. - -| # | Attack | Today | After | -|---|---|---|---| -| T1 | Impersonate peer X to peer Y | Trivial: copy `peer_id`, use the shipped cert | Requires X's private key | -| T2 | MITM a peer-to-peer QUIC connection | Trivial: shipped key, no pinning | Client pins the responder's key; MITM cannot present it | -| T3 | Evict peers with forged `Goodbye` | Trivial | Rejected: `Goodbye` must be signed by its subject | -| T4 | Rebind a peer's address to the attacker's host | Trivial via mDNS TXT | mDNS becomes an unauthenticated hint; only a verified handshake mutates the peer table | -| T5 | Cancel/Start/AddTime someone else's call | Trivial | Requires the root event author's key | -| T6 | Chat or RSVP as another player | Trivial | Requires that player's key | -| T7 | Replay a captured control message | Works | Freshness window, per-sender nonce cache, recipient binding | -| T8 | Exhaust Call to Play history so local publishes fail | Works (global 4096 cap) | Per-author quota inside the global cap | -| T9 | Sybil-flood the peer list | Works | Bounded known-peer count, new-peer admission rate limit, block list | -| T10 | Spoof a display name ("Alice") with a fresh key | Works, invisible | Name is signed but unverified: TOFU pin + explicit UI conflict warning | -| T11 | Serve corrupt or hostile game content | Works | **Unchanged — out of scope** | -| T12 | Steal a peer's identity key off a running machine | Plaintext file | OS secret store where available; documented fallback otherwise | - -T10 is worth stating precisely: cryptography can prove *the same key* as last -time. It cannot prove a human's name. The UI must present continuity ("this is -the Alice you played with yesterday") rather than authenticity of the name. - -## 5. Design - -### 5.1 Identity - -- One **Ed25519** keypair per installation ("device identity"). A LAN party - machine is one player; there is no separate user identity. -- `PeerId` = lowercase RFC 4648 base32, no padding, of the first 20 bytes of - `SHA-256(public_key)` → 32 characters. DNS-label safe, case-insensitive-safe - (important: it appears in mDNS instance names and TLS SNI), short enough to - print, 160-bit second-preimage / 80-bit collision resistance, which is ample - for a LAN. -- Self-certifying: every message that claims an id carries the public key, and - receivers recompute `derive_peer_id(key) == claimed_id`. There is no key - distribution problem and no key-exchange step. -- Display: full id grouped in blocks of four (`a3f2-9k1m-…`); short form = - first eight characters, always shown next to a display name. -- The same key signs both the TLS certificate and application objects. TLS 1.3 - signatures are already domain-separated by the TLS transcript prefix, and all - application signatures are domain-separated by an explicit context string - (5.4), so cross-protocol reuse is safe. Alternative if the TLS spike in - Phase 2 disappoints: ECDSA P-256 for the certificate, Ed25519 for objects, - bound by a signed statement in `Hello`. Extra moving part; only if needed. - -### 5.2 Identity storage - -New `lanspread-identity` crate owns an `IdentityStore` with a fixed resolution -order: - -1. `LANSPREAD_IDENTITY_SEED` (env, base64url seed) — deterministic identities - for tests, containers, and scripted scenarios. Never persisted. -2. `--identity-file` / `LANSPREAD_IDENTITY_FILE` — explicit path, for the - peer-cli and CI. -3. **OS secret store** via `keyring`: Windows Credential Manager (DPAPI), - macOS Keychain, Linux Secret Service. Service `network.paul.lanspread`, - account `peer-identity`. -4. **File fallback** `/identity.key`, mode `0600` on Unix, - user-only ACL on Windows, written tmp + atomic rename. - -Alongside it, a **non-secret** `/identity.json` always records -`{ version, peer_id, public_key, created_at, backend }`. That gives us: the UI -can show the identity and its protection level without touching the secret -store; a mismatch between the sidecar and the loaded key is detectable; and the -selected backend is auditable rather than guessed. - -Hard rules: - -- **Never silently generate a new identity when one exists but cannot be - read.** A locked keychain, a revoked ACL, or a corrupt file must surface a - loud, actionable error and a repair flow, because a new identity silently - invalidates every other peer's pin of us. This is the single most important - correctness rule in the storage layer. -- Seed material is `Zeroize`d, never logged, never sent to the frontend, and - never included in any event or error string. -- The backend actually used is reported to the UI. A file fallback says - "stored in a file on this machine" rather than pretending to be a keychain. -- Honest framing for the update-survival question: `` (`~/.lanspread` - by default, `state_paths.rs:10-24`) already survives binary updates, and so - does the current plaintext `peer_id`. The OS secret store buys protection at - rest and platform-consistent behavior — not update survival. Both are worth - having; only one of them is new. -- Export/import: passphrase-wrapped seed blob (PBKDF2-HMAC-SHA256, high - iteration count, XChaCha20-Poly1305 or AES-GCM from the crypto backend we - already link) so a user can move their identity to a new machine on purpose. -- Migration: the legacy `/peer_id` UUID file is deleted by the - existing pre-start migration phase (`migration.rs`). Nothing persistent is - keyed on it — the peer table is in-memory (`peer_db.rs`) and there are no - sqlite migrations — so the change is a clean break. - -### 5.3 Transport: authenticated QUIC - -Switch `s2n-quic` to the rustls provider on every platform and take control of -certificate verification. This is already available in the pinned versions: -`s2n-quic 1.83` exposes `provider-tls-rustls` → -`s2n_quic::provider::tls::rustls` (re-exported `s2n-quic-rustls 0.83`), whose -`Client`/`Server` are constructible from a full `rustls::ClientConfig` / -`ServerConfig` (rustls 0.23, aws-lc-rs backend). Dropping -`provider-tls-default` also drops the s2n-tls C build from the tree. - -- At startup each peer generates (once, cached in memory) a self-signed - certificate whose SPKI is its Ed25519 identity key, CN = `peer_id`, - SAN DNS = `.lanspread`. -- **Server** presents that certificate. No client certificates in Phase 2 (see - the rationale below). -- **Client** uses a custom `ServerCertVerifier` that ignores CA chains and - hostname policy and instead: extracts the SPKI, requires Ed25519, derives the - peer id, and requires it to equal the **expected** id. The expected id comes - from the connection request and is also encoded in SNI - (`.lanspread`), so pinning works whether clients stay - per-connection (`network.rs:67-80` today) or get pooled later. -- `connect_to_peer` grows a required `expected: PeerId` argument. There is no - "connect to an address and see who answers" path any more; discovery always - supplies the id it is chasing. -- Result: T1/T2 die at the transport. Everything the responder sends on that - connection — `HelloAck`, snapshots, manifests, chunk bytes, stream-install - frames — is authenticated by TLS to the pinned key, with no per-frame work. - -Why no client certificates: s2n-quic does not surface the peer certificate to -the application, so a client certificate would authenticate the channel to -nobody the app can name. Client authentication therefore happens one layer up -(5.4), where it is bound to the pinned responder identity. If s2n-quic later -exposes peer certificates, the two layers can collapse — worth a comment in the -code so the option is not forgotten. - -### 5.4 Signed envelopes for control messages - -Every `Request` and `Response` is wrapped: - -```rust -pub struct Signed { - pub sender: PeerId, // must equal derive_peer_id(sender_key) - pub sender_key: PublicKey, // 32 bytes - pub recipient: PeerId, // who this is for; blocks cross-peer relay - pub sent_at_ms: i64, - pub nonce: [u8; 16], - pub context: SigContext, // Request | Response | Event - pub payload: Bytes, // exact serialized inner message - pub signature: [u8; 64], -} -``` - -- **Sign the transmitted bytes, do not canonicalize.** The wire format is - `serde_json`, which is not canonical, so the envelope carries the inner - message as opaque pre-serialized bytes (base64 in JSON) and the signature - covers exactly those bytes. Receivers verify first, deserialize second. This - removes an entire category of "re-serialization broke the signature" bugs and - is what makes forwarding (5.5) work at all. -- Signed input is domain-separated and length-prefixed: - `"lanspread-sig-v1" || context_tag || len(sender) || sender || … || len(payload) || payload`. -- Freshness: reject `|now - sent_at_ms| > 120 s`; keep a bounded per-sender - nonce LRU inside that window (e.g. 1024 nonces × 256 peers) to kill replay - (T7). Call to Play events are additionally idempotent by event id, and - library deltas are revision-guarded, so the nonce cache is defence in depth - for everything except `Goodbye`, where it is load-bearing. -- **Bulk data frames stay unsigned.** `StreamInstallFrame`s and raw chunk bytes - flow only responder → initiator, inside a channel whose responder key the - initiator pinned, so TLS already authenticates them. Signing 1 MiB chunks - would cost throughput and buy nothing. This asymmetry is deliberate and must - be documented next to the code. -- Verification happens once, at the stream boundary, in - `services/stream.rs::handle_peer_stream`. Handlers receive a - `VerifiedSender { peer_id, public_key }` and lose the `peer_id: String` - parameters they take today. That is the whole point: it becomes impossible to - write a handler that trusts a payload field, because there is no payload - field to trust. - -### 5.5 Signed Call to Play events - -Channel authentication is insufficient here: `Hello`/`HelloAck` carry *other -peers'* events (`ARCHITECTURE.md`, Call to Play replication), so B hands me A's -history. Those events must be verifiable independently of who relayed them. - -```rust -pub struct SignedEvent { - pub author: PeerId, - pub author_key: PublicKey, - pub body: Bytes, // exact serialized CallToPlayEventBody - pub signature: [u8; 64], -} -``` - -- `CallToPlayEventBody` is today's `CallToPlayEvent` **minus `actor_id`** — the - author is the key. `actor_name` stays, signed but unverified (T10). - `publish()`'s `event.actor_id.clone_from(...)` overwrite - (`call_to_play.rs:403`) disappears; the signature replaces it. -- `CallToPlayStore` stores `SignedEvent` and re-transmits `body` **verbatim** - in snapshots, so signatures survive relaying. Nothing in the store may - re-serialize a body. -- **Creator authority becomes cryptographic.** `HistoryIndex` compares signing - keys instead of `actor_id` strings for `Start` / `Cancel` / `AddTime` - (T5). -- **`call_id` becomes creator-bound**: - `call_id = base32(SHA-256(author_key || create_nonce))[..16]`. Receivers - verify the derivation for `Create` events and reject a `Create` for an - existing `call_id` from a different author. A hostile peer can no longer - plant a competing root for someone's call. -- **Per-author quotas** replace the single global cap: global 4096 unresolved - events *and* a per-author bound (e.g. 256), so one peer cannot starve the - shared history (T8). Terminal histories and tombstones keep their current - exemption. -- **Verified-signature cache** keyed by event id so each handshake merge only - verifies genuinely new events; handshakes carry full histories and must not - become O(history) in signature checks. -- Sanity-bound `at` against the local clock (±10 min) so ordering stays sane, - and keep documenting the skew assumption rather than pretending to fix it. -- The rest of the Call to Play design — immutable events, deterministic - reduction, atomic batch merges, tombstones, retention windows, ack-and-heal — - is untouched. This is an identity change, not a replication change. - -### 5.6 Discovery becomes a hint - -- mDNS TXT gains `pk` (base64url public key); `peer_id` stays and must match - the derived id. Optionally an `adv_sig` over - `peer_id|addr|library_rev|library_digest|timestamp`, which is cheap but of - limited value once the handshake verifies everything — decide during - implementation, not before. -- `services/discovery.rs::handle_discovered_peer` **stops mutating the peer - table**. It records a candidate `(peer_id, addr, key)` and triggers a - verified handshake; only `perform_handshake_with_peer` / - `accept_inbound_hello` create or rebind peer records (T4). -- The mismatch path in `handshake.rs:119-127` no longer removes the expected - peer (`remove_peer(expected)`), which is itself a forged-eviction primitive - today. A mismatch fails the attempt and logs. -- `peer_db.rs`'s "fall back to a unique peer with the same IP" heuristic - (`peer_id_for_transport_addr`, `peer_db.rs:140-162`) and address-based - liveness (`update_last_seen_by_addr`) are replaced by verified-identity - lookups. - Liveness refreshes only on a verified frame. -- Sybil bounds: cap known peers, rate-limit new-peer admission per minute, and - keep discovery cheap enough that a flood degrades gracefully (T9). - -### 5.7 Trust store and user-visible trust - -`/trust/peers.json`, versioned, atomic write, debounced: - -```json -{ "version": 1, - "peers": { "": { - "public_key": "…", "first_seen": 0, "last_seen": 0, - "names": ["Alice"], "pinned_name": "Alice", - "state": "known", // known | blocked - "rotated_from": null } } } -``` - -- First contact is TOFU: recorded, surfaced as **new**, never auto-labelled - trusted. -- **Name-conflict detection**: a known `pinned_name` arriving with an unknown - key produces a UI warning and never overwrites the pin (T10). -- **Blocking** is enforced at handshake (reject) and connection (drop, serve - nothing), and persists across restarts. - -### 5.8 Optional hardening (later phases) - -- **Key rotation with continuity**: - `RotationStatement { old_key, new_key, at, sig_by_old, sig_by_new }` - published in `Hello`; receivers move the pin and record `rotated_from`. A - lost key means no continuity — that is a new device, and the UI says so. -- **Party admission code** for public venues: `psk` derived from a short - human-shareable code; `Hello` carries - `HMAC(psk, sender||recipient||nonce)`; peers without a valid proof are - refused while a code is set. Off by default — a normal LAN party stays open. -- **Connection-level abuse control**: s2n-quic address-token / retry providers - plus per-address handshake rate limits, so an unauthenticated flood cannot - soak the accept loop. - -## 6. Wire protocol changes - -- `PROTOCOL_VERSION` bumps once per wire-affecting phase: 7 → **8** (transport - identity), → **9** (signed envelopes), → **10** (signed events), → **11** if - admission control lands. No compatibility paths, per project policy. -- `lanspread-proto` gains `PeerId`, `PublicKey`, `Signature`, `Signed`, - `SignedEvent`, `SigContext` as **dumb data types with no crypto dependency**; - `lanspread-identity` does all key handling. Proto must not depend on the - identity crate, and the identity crate must not depend on proto — shared - newtypes live in proto, algorithms live in identity. -- `Request`/`Response` are wrapped in `Signed`; the per-request `peer_id` - fields on `LibraryDelta`, `CallToPlayEvents`, and `Goodbye` are **removed**. -- `Hello`/`HelloAck` carry `public_key`, the display name, and (later) rotation - statements and admission proof. `call_to_play_events` becomes - `Vec`. -- `CallToPlayEvent` → `CallToPlayEventBody` without `actor_id`. -- mDNS TXT gains `pk`. - -## 7. Code map - -New crate `crates/lanspread-identity`: - -| Module | Contents | -|---|---| -| `key` | Ed25519 keypair, `PeerId` derivation, `Zeroize`ing seed wrapper | -| `store` | `IdentityStore` backends (env, file, keyring, fallback), sidecar, repair errors | -| `sign` | Domain-separated signing/verification, `Signed`/`SignedEvent` construction and checks | -| `freshness` | Clock window + per-sender nonce LRU | -| `tls` | Self-signed cert generation, pinned `ServerCertVerifier`, rustls config builders | -| `trust` | Trust store, TOFU pinning, name conflicts, block list | - -Changes in `lanspread-peer`: - -| File | Change | -|---|---| -| `identity.rs` | UUID generation → load identity, expose `peer_id`, public key, backend | -| `config.rs` | Delete `CERT_PEM` / `KEY_PEM`; delete `cert.pem` / `key.pem` from the repo | -| `network.rs` | rustls client config, `expected: PeerId` on connect, signed request/response helpers | -| `services/server.rs` | rustls server config with the per-peer certificate | -| `services/stream.rs` | Verify envelopes at the boundary; handlers take `VerifiedSender`; fix `handle_goodbye`, `handle_library_delta`, `handle_call_to_play_events`, `note_peer_activity` | -| `services/discovery.rs` | mDNS as hint only; no peer-table mutation; candidate + handshake | -| `services/handshake.rs` | Verify `Hello`/`HelloAck` signatures and key↔id binding; no `remove_peer` on mismatch; trust-store updates | -| `services/advertise.rs` | Advertise `pk`; instance name from short id | -| `services/liveness.rs` | Liveness on verified frames only | -| `peer_db.rs` | Records hold public keys; drop IP-based identity fallbacks | -| `call_to_play.rs` | `SignedEvent` store, key-based creator authority, derived `call_id`, per-author quotas, verification cache | -| `error.rs` | Typed `PeerAuthError` variants that reach the frontend as codes, not substrings | - -Changes elsewhere: `lanspread-peer-cli` gets `--identity-file` / -`--identity-seed`, an `identity` JSONL command, and a hostile mode (§9); -`src-tauri` gets identity/trust commands and events; the frontend gets the -identity panel and peer chips (§8); `justfile` gets deterministic identities for -the alpha/bravo/charlie containers. - -## 8. UI and UX - -- **Settings → Identity**: display name, short id, full grouped fingerprint, - copy button, storage backend with an honest protection label, export/import, - rotate, and reset-with-consequences (resetting invalidates every other peer's - pin of you). -- **Peer chips everywhere** (peer list, game rows, Call to Play roster and - chat): display name + short id, a **new** badge on first sight, a **name - conflict** warning when a pinned name arrives with a different key, and - block/unblock. -- **Call to Play**: creator controls are unchanged but now cryptographically - enforced; the `ARCHITECTURE.md` disclaimer about accidental-only protection - gets rewritten rather than deleted (what is now enforced, what is still - self-asserted: names and timestamps). -- **Log window**: rejected messages surface as typed auth errors with the short - id and reason, so "why is that peer not showing up" is diagnosable. -- **Repair flow** when the identity cannot be read: explain, offer retry, offer - deliberate new identity, never do it silently. - -## 9. Testing - -Unit (`lanspread-identity`): - -- Id derivation stability and key↔id binding; tampered public key rejected. -- Signature round trip; single-bit flips in payload, sender, recipient, nonce, - and context all rejected. -- Freshness window edges; replay rejected; nonce LRU bounded. -- Storage: each backend round-trips; unreadable-but-present identity yields a - repair error and **never** a new key; file permissions asserted on Unix; - sidecar mismatch detected; export/import round trip with a wrong-passphrase - failure case. -- Trust store: atomic write survives a truncated temp file, name conflict - detected, block persists, schema version migration. - -Peer runtime: - -- Pinned verifier: correct key accepted; wrong SPKI, non-Ed25519 SPKI, and - SNI/id mismatch rejected. -- Two in-process peers over loopback (`test_support.rs`) complete a verified - handshake, sync libraries, and exchange signed events. -- Forged `Goodbye`, forged `LibraryDelta`, and mDNS-only address rebinding all - leave the peer table unchanged. -- Call to Play: non-creator `Start`/`Cancel`/`AddTime` rejected; relayed - third-party events verify after a full serialize/deserialize cycle; duplicate - `call_id` from another author rejected; per-author quota does not block other - authors' publishes; verification cache keeps handshake merges linear in new - events. - -Hostile-peer harness (`lanspread-peer-cli`): a mode that emits unsigned frames, -valid signatures with a mismatched id, replays, forged goodbyes, impersonated -creator actions, and history floods — driven from -`crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` with assertions -that each is refused. This is the regression net that keeps the trust model -from eroding; scenarios go into `PEER_CLI_SCENARIOS.md`. - -Performance: LAN download throughput before/after (must be unchanged — bulk -frames are unsigned by design), handshake latency with full Call to Play -history, and certificate generation cost at startup (generate once, never per -connection). - -Every phase ends with `just fmt`, `just clippy`, `just test`, -`just frontend-test`, and a manual three-container check -(`just peer-cli-alpha` / `-bravo` / `-charlie`). - -## 10. Phases - -Each phase is independently shippable and leaves the tree green. - -**Phase 0 — decide and write it down.** `THREAT_MODEL.md` plus an ADR in -`IMPL_DECISIONS.md` recording: Ed25519, derived ids, opaque-bytes signing, -rustls provider, storage order, and the explicit supersession of the -"no cryptographic peer identities" line in `CALL_TO_PLAY_FIXES_PLAN.md`. - -**Phase 1 — `lanspread-identity`, no wire change.** -`feat(identity): derive peer identity from an Ed25519 key`. Key, storage -backends, sidecar, trust-store skeleton, unit tests. `peer_id` becomes the -derived id; legacy `peer_id` file migrated away. Peers still talk plaintext- -trust over the shared cert, so the change is observable and low risk. - -**Phase 2 — authenticated transport (proto 8).** -`feat(peer)!: authenticate QUIC connections with per-peer keys`. Spike the -Ed25519-certificate path against rustls **first**; then rustls provider, cert -generation, pinned verifier, `expected` on connect, delete `cert.pem` / -`key.pem` and their constants, drop `provider-tls-default`. Discovery stops -mutating the peer table. - -**Phase 3 — signed envelopes (proto 9).** -`feat(peer)!: require signed request envelopes`. Envelope type, boundary -verification, `VerifiedSender` in handlers, freshness and replay defence, and -the four trust-site fixes (`Goodbye`, `LibraryDelta`, `CallToPlayEvents`, -liveness). Typed auth errors. - -**Phase 4 — signed Call to Play events (proto 10).** -`feat(call-to-play)!: sign events with peer identity keys`. `SignedEvent` -store, verbatim forwarding, key-based creator authority, creator-bound -`call_id`, per-author quotas, verification cache. - -**Phase 5 — trust UX.** `feat(ui): show verified peer identities`. Identity -panel, peer chips, new/conflict badges, block/unblock, repair flow, -export/import. - -**Phase 6 — optional hardening.** Rotation with continuity, party admission -code (proto 11 if it lands), connection-level abuse control. - -**Phase 7 — harness and docs.** Hostile-peer scenarios; rewrite the trust -paragraphs in `ARCHITECTURE.md`, `README.md`, -`crates/lanspread-peer/README.md`, and the peer-cli docs; final performance -pass. - -Phases 1–4 are the plan's substance; 5 makes it usable; 6–7 make it durable. - -## 11. Dependencies - -Prefer what the tree already builds. rustls/s2n-quic already pull `aws-lc-rs`, -which provides Ed25519, SHA-256, HMAC, and PBKDF2 — no second crypto stack, no -new C toolchain. - -| Crate | Why | Note | +Architecture approved; implementation plan. + +This document incorporates the prior holistic review and subsequent review +dialogue. It replaces the previous version of this file and +supersedes the statement in `CALL_TO_PLAY_FIXES_PLAN.md` that cryptographic +peer identities should not be introduced. + +Nothing in this document is implemented merely because it is specified here. +Each phase below has its own implementation and acceptance gate. Phase 1a +scaffolding can proceed once this plan is accepted; persistent identity writes +wait for the identity transition table in §7.2 to be encoded in tests, and the +network runtime does not switch from the legacy UUID to the derived PeerId +until Phase 2's protocol bump. + +The project has one current wire version and no compatibility paths. Every +wire-changing phase bumps `PROTOCOL_VERSION`; peers on any other version are +rejected. + +## 1. Executive summary + +The design has six connected parts: + +1. Each Lanspread runtime has a long-lived Ed25519 identity. `PeerId` is + derived from the public key; it is never a caller-supplied UUID. +2. Every outbound connection takes a `PeerEndpoint { peer_id, addr }`. TLS + pins the responder to that identity and performs real TLS 1.3 + CertificateVerify validation. An address is never used to discover an + identity after connecting. +3. mDNS and inbound `Hello` messages are candidate hints. They cannot mutate + authenticated peer state. Listener ownership is proven by a bounded pinned + connect-back. +4. Control requests and responses use exact signed envelopes. Independently + relayed Call-to-Play events carry their own signatures, creator commitment, + and content-derived IDs. +5. User-visible continuity, blocking, and permission to download from a device + are separate trust decisions. New devices are not remote byte sources until + explicitly allowed. +6. Transfer-authoritative manifests and bytes come only from allowed, + responder-pinned identities. This is source authorization, not content + integrity: an allowed malicious device can still send hostile same-sized + bytes until catalog-owned hashes or signatures exist. + +There is also one independent prerequisite. The current download preparation +path can truncate another game's `local/` data from an attacker-supplied file +description before any peer connection occurs. §6 specifies a standalone fix +that lands before persistent/runtime identity work. + +## 2. Current security and correctness facts + +- The repository-wide `cert.pem` and `key.pem` authenticate only “some build of + Lanspread.” Their private key is public, so they do not authenticate a peer. +- The current `peer_id` is a self-asserted UUID. mDNS, `Hello`, request bodies, + peer-table rebinding, liveness, and Call-to-Play authority ultimately trust + that string. +- `peer_db` can evict an existing address owner during a claimed collision. + Removing only that eviction branch would corrupt the `peers`/`addr_index` + invariant; the replacement must reject conflicts atomically. +- `handshake.rs` currently records advertised listener addresses rather than + consistently retaining the endpoint actually reached. +- Download, retry, streamed-install, healing, and direct-CLI paths sometimes + retain only an address or fabricate an address-derived peer ID. That is + incompatible with responder pinning. +- A custom rustls `ServerCertVerifier` has two separate duties: inspect and pin + the presented public key, and cryptographically verify TLS 1.3 + CertificateVerify. Returning `HandshakeSignatureValid::assertion()` without + the second operation lets an attacker replay another peer's public + certificate with the attacker's private key. +- Call-to-Play snapshots are relayed. Authenticating the relay does not prove + the authorship of third-party events. +- Current terminal compaction intentionally retains session-long tombstones, + but rootless tombstone handling loses the selected terminal's order key and + fresh stores cannot ingest such tombstones correctly. Signed events must fix + that replication state, not claim replication remains untouched. +- The current active-history capacity tests let terminal actions reduce state, + but there is no hard total tombstone/byte bound and no reserve for ordinary + local publication. +- `LengthDelimitedCodec::new()` already has an 8 MiB default maximum; it is not + unbounded. Protocol-specific frame and batch limits still need to be explicit + and lower where the bounded payload permits. +- The GUI uses Tauri's `app_data_dir()`, not necessarily `~/.lanspread`. + Update and uninstall/reinstall persistence therefore depends on the platform + and packaging behavior. +- `prepare_game_storage` currently receives the whole games directory and may + call `create(true).truncate(true)` for a cross-game or protected `local/` + path before a source connection is attempted. This is a live data-loss bug, + independent of peer authentication. + +## 3. Goals + +1. Give every runtime a stable cryptographic identity whose public ID is + derived, not asserted. +2. Preserve that identity across normal updates and address changes, while + making storage failures and platform-dependent reinstall behavior honest. +3. Authenticate every selected responder and prove possession of its TLS + private key. +4. Remove address-only identity throughout the peer, metadata, download, + retry, streamed-install, healing, liveness, and CLI paths. +5. Attribute every state-changing control message to a verified key. +6. Make stored and forwarded Call-to-Play events independently verifiable. +7. Prevent a blocked author from regaining influence through an allowed relay. +8. Bound peer/authentication and Call-to-Play work without evicting permanent + anti-resurrection state. +9. Default remote download sources to user approval, while leaving public + browsing and outbound serving appropriate for a LAN party. +10. Expose identity continuity, new-key/name conflicts, blocking, source + permission, overload, and repair states clearly in the UI. +11. Land each negative security scenario with the phase that creates its trust + boundary. + +## 4. Non-goals and accepted limits + +- No PKI, accounts, CA, or internet service. Trust is self-certifying identity, + first-seen continuity, and explicit local policy. +- A key proves continuity, not a human name. “Alice” remains a signed + self-assertion; the UI shows the fingerprint and warns when a familiar name + appears under a new key. +- Authentication does not exclude a hostile guest. A fresh key is a valid new + identity. Default-deny source permission adds a human authorization step but + does not make that device's files trustworthy. +- No complete game-content integrity in this plan. Catalog-owned hashes or + signatures remain separate urgent work. +- No durable, global replay ledger. TLS prevents passive capture, recipient + binding prevents cross-peer reuse, and a bounded in-process nonce set + suppresses duplicates. Operations must still be idempotent or sequenced. +- No receiver-relative age expiry for stored signatures. Historical events and + session tombstones remain valid when relayed later. +- No Byzantine convergence guarantee after adversarial global capacity + exhaustion. Call-to-Play memory and merge work remain bounded; remote + availability and exact convergence may stop until the user + blocks/quarantines authors and resets or restarts party state. +- No comprehensive accept-connection/control-stream/disk-read/serve-operation + scheduler is added here. A hostile LAN host can still exhaust application + tasks below physical link saturation through anonymous public serving in the + intermediate phases. This is an explicit availability risk for separate + server-hardening work, not a guarantee hidden behind T9; the concrete new + amplification surfaces introduced by this plan remain bounded. +- No anonymity or metadata privacy. Peer IDs, keys, names, and library summaries + are public to the local segment by design. +- No promise that uninstall/reinstall preserves a file-backed identity on + every platform. The UI and docs describe the actual package behavior and + recommend an encrypted backup. + +## 5. Threat model and resulting guarantees + +The attacker is on the same L2 segment, can send arbitrary packets, run a +modified build, create many identities, and knows the repository's current TLS +private key. The attacker does not have code execution or OS-secret access on a +victim. + +| # | Attack | Result after the owning phase | |---|---|---| -| `rustls` 0.23 | Direct config/verifier construction | Version-locked to `s2n-quic-rustls` | -| `aws-lc-rs` | Ed25519, digests, HMAC, KDF | Already transitive | -| `rcgen` (aws-lc-rs backend) | Self-signed certificate generation | Alternative: hand-rolled DER, not worth it | -| `x509-parser` | SPKI extraction in the verifier | Pure Rust, no crypto | -| `keyring` 3 | OS secret stores | Platform features; Linux pulls zbus/secret-service | -| `zeroize` | Seed hygiene | | -| `data-encoding` | base32 / base64url | | +| T1 | Impersonate a selected responder | Requires the responder's private key. During Phase 2, inbound initiators are not authenticated, so their claims cannot mutate state. | +| T2 | MITM an outbound QUIC connection | Fails responder pinning and/or TLS 1.3 CertificateVerify. | +| T3 | Evict a peer remotely | `Goodbye` is removed. Conditional authenticated liveness is the only remote-removal path. | +| T4 | Rebind or collide a listener address | mDNS and advertised addresses are hints. Only a successful pinned dial binds an endpoint; a cross-ID collision is rejected without eviction. | +| T5 | Start, cancel, or extend another creator's call | Creator actions require the `CallRef.creator_key` signature. | +| T6 | Chat or RSVP as another participant | Participant actions require that participant's signature and a valid call root. | +| T7 | Reuse a captured control object | TLS prevents passive capture, local-recipient/context checks prevent cross-peer reuse, request/response correlation and an atomic bounded nonce set suppress duplicates. No durable universal replay guarantee is claimed. | +| T8 | Exhaust Call-to-Play state | One key cannot exceed its count/byte quota and remote traffic cannot consume the local reserve. Sybil keys can fill the bounded remote pool; overload is surfaced and availability/convergence are then explicitly not guaranteed. | +| T9 | Flood candidate/state admission | Candidate, live-peer, trust, nonce, and connect-back work/state are bounded and deduplicated. Generic accept-loop/link saturation remains an explicit availability limit. | +| T10 | Reuse a familiar display name under a new key | The UI retains the old pin, marks the key as new, and shows a name conflict. It does not claim the name is verified. | +| T11 | Serve hostile game bytes | An unapproved identity cannot become a manifest or byte source. An explicitly allowed malicious identity can still serve hostile content; catalog integrity is unsolved. | +| T12 | Steal a local identity | OS secret storage is preferred; file and explicit-file modes are labelled honestly. Local compromise remains out of scope. | +| T13 | Destroy another game's protected files through a manifest | The standalone manifest-confinement fix rejects the complete manifest before any filesystem or `version.ini` transaction mutation. | -`s2n-quic` moves to `default-features = false` with -`provider-address-token-default`, `provider-event-tracing`, -`provider-tls-rustls`. `unsafe_code = "forbid"` stays on the new crate; all -unsafe lives in dependencies. +## 6. Prerequisite: confine download preparation before persistent identity work -## 12. Risks +This is a standalone safety fix, not a protocol or cryptography change, and it +lands before Phase 1b persistent identity work; Phase 1a may proceed in +parallel. -- **Ed25519 certificates through rustls/aws-lc-rs.** Highest-uncertainty item. - Spike it as the first task of Phase 2. Fallback: P-256 certificate + Ed25519 - identity bound by a signed statement in `Hello`. -- **Keyring on Linux.** Headless, container, and no-Secret-Service setups must - land on the file fallback cleanly and say so. Never a hard failure, never a - silent new key. -- **macOS keychain prompts after updates.** Signing-identity changes can - re-prompt; document it and keep the app-specific service name stable. -- **Identity loss = broken pins.** Export/import and the repair flow are not - optional polish; they are how users recover. -- **Signature cost on handshakes.** Full histories are re-verified on every - handshake without the verification cache. Build the cache with the feature, - not after a report. -- **Scope creep into content trust.** Every doc touched by this work must keep - saying that authenticated peers can still serve bad bytes. -- **The plan touches the hottest files in the peer** (`stream.rs`, - `handshake.rs`, `discovery.rs`, `call_to_play.rs`). Phase boundaries are - chosen so each lands with its own tests rather than as one 3000-line commit. +### 6.1 Validated manifest boundary -## 13. Decisions to confirm before Phase 1 +The peer core constructs a `ValidatedDownloadManifest` before +`begin_version_ini_transaction`, `prepare_game_storage`, directory creation, +file open/truncate/resize, or any other filesystem mutation. Storage accepts +only that validated type; neither Tauri nor a remote peer can pass raw +`GameFileDescription` values to it. -1. **`keyring` dependency acceptable?** It brings zbus/secret-service on Linux. - The alternative is file-only storage with `0600` and an honest UI label, - which is what the plan falls back to anyway. -2. **One Ed25519 key for TLS and objects, or split TLS/object keys?** Plan - recommends one, with the split as the documented fallback. -3. **Should downloads require a verified requester?** Signed envelopes make it - free to enforce; a LAN party may prefer "anyone at the party can pull - games". Plan keeps serving open by default and gates it behind the block - list plus the optional party code. -4. **Party admission code in scope now or later?** Plan says Phase 6. +For the entire list, validation must: + +- require every descriptor's `game_id` to equal the requested game; +- resolve destinations relative to exactly `/`, not the + whole games directory; +- at this standalone no-version-bump boundary, leave protocol-7 producer bytes + unchanged and normalize its current platform all-`/` or all-`\\` separator + form exactly once before validation; reject mixed + separators and ambiguous/empty components. Accept and discard only the + current exact redundant root descriptor + `{ relative_path: game_id, is_dir: true, size: 0 }`; reject every other empty + game-relative entry. All absolute, drive-qualified, UNC, NUL, `.`/`..`, + parent, cross-game, and non-normalized results are rejected; +- reject duplicate normalized paths and conflicting file/directory shapes; +- require directory size to be zero and exactly one regular root + `version.ini` where the existing transaction requires it; +- use one shared `is_reserved_game_path` policy in scanning, serving, + preparation, and discard. It rejects `local/`, `.local.*`, `.sync`, + `.lanspread/`, `.lanspread.json`, `.softlan_game_installed`, + `.version.ini.tmp`, `.version.ini.discarded`, and all current + version-transaction temporary/discard paths while allowing the one intended + root `version.ini`; +- on Windows, compare duplicates and reserved names case-insensitively and + reject alternate-data-stream colons, DOS device components, and trailing-dot + or trailing-space aliases before filesystem lookup; +- require the game root to be a direct non-symlink child of the games folder + and avoid following a symlink or reparse component at the destination; and +- enforce explicit descriptor-count, per-file-size, and aggregate-size bounds + chosen from real catalog measurements in Phase 0. Phase 2b also enforces + those bounds on raw remote manifests before retaining them in `peer_db`, + consensus state, or Tauri output. + +The UI supplies only `{ game_id }`; this plan does not support partial game +selection. The peer core selects the complete backend-authoritative manifest, +and only a complete successfully written payload may commit `version.ini` or +become installable. A UI-echoed description is never authority. If partial +downloads are introduced later, they need an explicit dependency closure and +must not commit the installable sentinel. + +Before committing `version.ini`, transactionally remove or quarantine every +download-owned, nonreserved path under the requested game that is absent from +the complete selected manifest. Preserve `local/` and all reserved transaction +state. This prevents a stale `.eti` from an older, failed, revoked, or +unapproved transfer from being installed or served alongside the newly +approved payload. + +### 6.2 Mandatory proof + +Tests put sentinel bytes in `OtherGame/local/save.dat` and the requested +game's `local/`, then submit malicious descriptors and prove that no existing +file changes and no new path is created. They also cover a valid first +descriptor followed by an invalid later descriptor, traversal/UNC/drive and +case variants, reserved paths, duplicates, root-shape errors, game-ID mismatch, +and symlink/reparse destinations. +They also seed an unlisted download-owned `stale.eti`, complete an authoritative +download, and prove that it is gone before installation/serving while every +protected `local/` sentinel remains. + +The gate is `just fmt`, `just clippy`, `just test`, `just build`, targeted +download/hostile-descriptor peer-CLI scenarios during development, and the +unfiltered `just peer-cli-tests` before completion. + +## 7. Normative design + +### 7.1 Identity primitives, IDs, and crate graph + +- Identity algorithm: Ed25519 using the existing AWS-LC cryptographic stack. +- `PeerId` is lowercase RFC 4648 base32 without padding of the full + `SHA-256(raw_ed25519_public_key)` digest: 52 fixed ASCII characters. It fits a + DNS label and retains 128-bit generic collision strength. +- UI fingerprints group the full ID for copying and show an unambiguous short + prefix only alongside a display name. Protocol policy never compares short + IDs. +- `lanspread-proto` owns dumb wire types and exact transcript builders: + `PeerId`, `PublicKey([u8; 32])`, `Signature([u8; 64])`, + `Nonce([u8; 16])`, `CallNonce([u8; 32])`, signed envelope/event types, and + explicit base64url serde adapters. It has no crypto or storage dependency. +- `lanspread-identity` depends on `lanspread-proto` and owns derivation, + signing/verifying, secret storage, and TLS identity material. +- `lanspread-peer` depends on both. `lanspread-proto` never depends on + `lanspread-identity`; there is no contradictory two-way dependency. +- Key, signature, nonce, and opaque payload JSON fields use + URL-safe base64 without padding and exact decoded lengths. They must not use + `Bytes`' default JSON numeric-array representation. +- Core code constructs all author, call, event, and message IDs. Frontend and + CLI commands express an action against a selected call; they do not supply + trusted identity or event-ID fields. + +### 7.2 Identity storage is a state machine, not a fallback chain + +#### Explicit modes + +CLI flags take precedence over environment variables. Seed and file modes are +mutually exclusive; conflicting settings return a typed configuration error. + +| Mode | Rule | +|---|---| +| `--identity-seed` or `LANSPREAD_IDENTITY_SEED` | Require one exact valid seed, never persist it, and do not read or modify default keyring/file/sidecar state. | +| `--identity-file` or `LANSPREAD_IDENTITY_FILE` | The named versioned secret file is the sole authority for that run. Missing, inaccessible, or corrupt means failure; creation requires a separate explicit operation. No fallback. | +| No explicit mode | Use the persistent-store table below. | + +The versioned secret record contains at least `{ version, seed, created_at }`. +The reconstructible non-secret `/identity.json` contains +`{ version, peer_id, public_key, created_at, backend }`. Its ID and key are +always recomputed from the secret before use. + +Persistent backend observations normalize to: + +```text +Present(secret) | NoEntry | Locked | Denied | Unavailable | Corrupt +``` + +Only `NoEntry` proves absence. An I/O error, locked store, denied access, +unavailable service, or corrupt record never authorizes generation. + +#### Persistent transition table + +| Sidecar | Backend observations | Required action | +|---|---|---| +| Valid | Selected backend is `Present` and derived key/ID match | Load only that backend; do not probe or switch. | +| Valid | Selected backend is missing, locked, denied, unavailable, corrupt, or mismatched | Stop the peer runtime and enter the corresponding typed repair state. Never fall through or generate. | +| Missing | Exactly one readable secret exists and every other supported backend conclusively reports `NoEntry` | Reconstruct the sidecar from that secret and load it. | +| Missing | More than one secret exists, even if identities match | Enter repair and require an explicit authoritative-backend choice. | +| Missing | Readable secrets derive different identities | `AmbiguousIdentity`; show backend names and public fingerprints, never seed material. | +| Missing | No secret exists and every supported backend conclusively reports `NoEntry` | Fresh install: create in the preferred keyring backend. | +| Missing | Any backend is locked, denied, unavailable, or corrupt | Enter repair; do not generate or fall through. | +| Corrupt | Exactly one readable supported-version secret exists and all others are conclusively absent | Reconstruct the derivative sidecar and load. | +| Corrupt | Otherwise | Enter repair. Even all-`NoEntry` does not silently generate because the sidecar proves prior state existed. | +| Unsupported sidecar or secret version | Any | Return `UnsupportedVersion` without probing beyond what identified the version and without writing anything. An older binary never reconstructs or overwrites newer state. | + +On a genuinely fresh Linux system without Secret Service, the GUI may +explicitly offer “Create a file-backed identity.” Headless use chooses a seed +or explicit identity file. A failed keyring write is followed by a read probe; +it never immediately falls through because the write may have partly +succeeded. + +The supported default keyring locator—service `network.paul.lanspread`, account +`peer-identity`—means one default identity per OS account. Multi-profile, +container, and simultaneous test identities use explicit files/seeds. If that +scope changes, Phase 0 must first define a deterministic, recoverable locator; +two stores must not silently implement different identity scopes. + +#### Locking and durability + +- The application identity session acquires an exclusive OS advisory lease + before its first backend probe and holds it through repair, import/reset, + peer-runtime stop/restart, and application exit. The lease exists even while + networking is stopped. File-backed state uses `/identity.lock`; + explicit identity files use an adjacent lease. The fixed OS-account keyring + locator uses one canonical account-wide lease independent of caller state + directory. Default persistent keyring use is supported only through that + canonical application profile; every other/headless profile uses an explicit + seed or identity file. +- Lock contention returns `IdentityBusy`; it never triggers generation. +- Creation/import/reset/backend migration validates input first. A file backend + writes a restrictive unique temporary file, flushes it, atomically renames + it over the selected record, and syncs the containing directory where + supported. A keyring backend uses the platform's atomic record replacement, + then reads it back and verifies the derived key and ID. Platforms that cannot + provide atomic record replacement do not enable in-place keyring + import/reset; they require explicit backend migration or backup/repair. + Initial creation and backend migration write and verify the new secret before + atomically writing the reconstructible sidecar; migration retires the old + backend only after the sidecar commits. + +Backend migration additionally uses a small non-secret +`identity.migration.json { version, from, to, target_fingerprint, stage }` +intent, written before touching the target and cleared only after sidecar +switch plus old-backend cleanup. When present, startup ignores the ordinary +valid-sidecar fast path, probes only the named source/target, and idempotently +resumes or explicitly rolls back. Before target write, `to` must be `NoEntry` +or contain the same identity; a different identity is a repair conflict, never +overwritten. This closes both crash windows without a generation-numbered +two-resource commit protocol. + +Every intent stage is durably updated by atomic rename. A corrupt or +unsupported intent is a typed non-mutating repair state and is never ignored. +Resume/rollback deletes a backend record only after rereading it and matching +the intent fingerprint. + +- A crash after secret write and before sidecar write is recoverable by the + missing-sidecar table. A replacement crash can yield old-sidecar/new-secret + mismatch; startup stops and offers explicit completion or backup restore. + No generation-numbered two-resource transaction is required. +- Same-backend keyring replacement may leave either old or new secret after a + crash; a sidecar mismatch is an explicit recoverable state, never a reason to + generate or fall back. The UI requires a verified encrypted backup before a + destructive in-place replacement. File replacement and backend migration do + not retire the old selected secret before the replacement is safely exposed. +- Phase 1b leaves the legacy UUID `peer_id` as the protocol-7 runtime/advertised + ID even after the cryptographic identity is durable. Phase 2's protocol bump + atomically activates the derived PeerId for networking and only then deletes + the UUID. It is never cryptographic continuity evidence. + +The GUI uses Tauri `app_data_dir()`. `~/.lanspread` is only the core default +when no state directory is supplied. Docs provide an update/uninstall matrix +per package rather than promising universal reinstall survival. + +#### Repair, backup, import, and reset + +Phase 1 ships a typed repair surface, not a log-only startup failure: retry and +unlock guidance, import backup, choose an unambiguous discovered backend, +deliberate file-backed creation on a genuinely fresh system, and deliberate +reset. The shell may start, but networking remains stopped until repair +succeeds. + +Export emits only a versioned authenticated passphrase-encrypted seed envelope. +Phase 0 freezes its magic/version, exact KDF and cost/salt encoding, exact AEAD +and nonce/tag encoding, authenticated header, and maximum decoded size before +Phase 1b implements it. Raw seed material never reaches frontend state, logs, +events, or errors. The UI calls this a **backup** and warns that importing it +creates two cryptographically indistinguishable devices. “Move” means verify +the destination fingerprint and deliberately retire/reset the source; stale +copies cannot be revoked without later key rotation. Reset shows old/new +fingerprints and explains that all remote continuity records are invalidated. + +Import over an existing identity requires old/new fingerprint confirmation and +replaces the selected backend unless the user separately chooses backend +migration. Reset/import fails if that backend cannot be replaced or retired; +it never creates a fallback identity. Identity replacement first stops +networking, commits under the still-held application lease, rebuilds all +runtime identity/TLS state, and only then restarts networking. + +### 7.3 Trust state and source permission + +Trust is keyed by verified full `PeerId` and public key, never by display name +or address. A record contains at least: + +```text +public_key +first_seen, last_seen +observed_names, pinned_name +acknowledged_at // null means persistently “new” +blocked // overrides every other policy +download_from // prompt | allowed | denied +rotated_from // later continuity feature +``` + +- First contact stores `download_from = prompt`, which is not eligible to + source a transfer. Only an explicit user action sets `allowed`. +- The UI wording is “Allow downloads from this device,” never “trust this + device/content.” +- A familiar name under a new key creates a separate new record and a conflict + warning. It never inherits acknowledgement, block, or source permission. +- Setting `blocked` atomically changes `download_from` to `denied`, suppresses + candidate retries, rejects direct authenticated protocol actions, and rejects + independently signed objects from that author even when relayed by an + allowed peer. Existing signed objects are quarantined inside the same bounded + CTP store, remain charged to the author/global quotas, are excluded from + reduction and relay, and preserve tombstone evidence. Unblocking leaves + source permission denied and triggers an authenticated resync before + quarantined state is reconsidered. +- Block, pin/acknowledgement, source permission, import, and reset are written + atomically and durably before success is reported. Only observational data + such as `last_seen` and name telemetry may be debounced. +- Final-state serving remains open to authenticated, nonblocked peers; party + admission can narrow that later. During Phases 2–3, inbound read-only + requesters are deliberately anonymous and serving remains public even to a + caller that might hold a blocked key. Direct requester blocking becomes + enforceable at the Phase 4 envelope boundary. This intermediate limitation + affects what others may pull from us, not local state mutation or which + devices we download from. Local/catalog-matching already-downloaded data + remains usable without any remote-source approval. + +Hard trust/admission bounds prevent sequential Sybil growth: at most 1024 +persistent trust records, eight observed names per record, and 256 +unacknowledged relay/direct-new observations. A directly verified identity or +relay-only author does not create durable trust state when the relevant bound +is full; acknowledged, blocked, or explicitly source-authorized records are +never silently evicted. Relay-only authors remain bounded ephemeral metadata +unless the user acknowledges or blocks them or direct verified contact occurs. +The UI reports capacity and lets the user remove records deliberately. + +### 7.4 First-class endpoints and discovery candidates + +```rust +struct PeerEndpoint { + peer_id: PeerId, + addr: SocketAddr, +} +``` + +The endpoint is captured at discovery/source selection and carried through +handshake, library/manifest fetch, ordinary downloads, chunk plans, retries, +streamed install, healing, liveness, and direct peer-CLI operations. +Every outbound connection requires it or equivalent mandatory `(expected_id, +addr)` arguments. + +Delete `remote_peer::ensure_peer_id_for_addr`, fabricated `addr-*` IDs, +unique-IP identity fallbacks, and address-only direct connect. The peer-CLI +`ConnectPeer` operation requires both full ID and address or is removed; there +is no desktop TOFU bootstrap UI to design for that test-only command. + +mDNS owns a bounded candidate directory separate from authenticated peer +records. Initial values are 256 candidates, 128 simultaneously verified live +peers, 120 new candidate insertions/minute globally, and 8/minute per +advertised target IP (the address exposed by the current mDNS wrapper, not an +authenticated packet source). Phase 0 confirms those values and candidate TTL/backoff against the honest +three-peer harness. At capacity, expired candidates are removed first; a new +candidate/live peer is otherwise rejected without evicting an authenticated +record. It may advertise `(peer_id, addr, public_key, revision hints)`, but a +key/ID consistency check does not prove possession or address control. mDNS +addition, expiry, or change never directly upserts, rebinds, removes, merges a +library, or refreshes authenticated liveness. A blocked ID is not retried. + +Candidate retries are deduplicated with jittered backoff while the +advertisement remains live. Reconciliation cannot depend on mDNS emitting a +second identical `ServiceResolved`; a still-live candidate can re-establish a +peer after transient failure or liveness removal. + +One shared socket predicate applies to mDNS, Hello, and Ack candidates: port is +nonzero; address is neither unspecified, multicast, nor IPv4 broadcast; +loopback is accepted only in explicit test mode; and IPv6 link-local addresses +carry the observed interface scope. Global IPv6 and private/ULA addresses are +not rejected merely because they are not syntactically “private”; interface +provenance and the bounded dialing policy define LAN reachability. + +### 7.5 Authenticated QUIC responder + +Use the s2n-quic rustls provider with TLS 1.3 only. + +Phase 0 spikes RFC 7250 raw public keys end to end through +`s2n-quic-rustls`. If RPK works, use the identity public key directly. Otherwise +use a self-issued Ed25519 X.509 leaf whose SPKI is exactly the identity key. +CA, CN, SAN, hostname, and self-signature are not authentication inputs; the +expected `PeerId` is. + +The custom client verifier captures the expected `PeerId` from +`PeerEndpoint` and MUST: + +1. require the selected RPK/SPKI shape and allowed signature scheme; +2. extract the raw identity key; +3. derive its full `PeerId` and compare it to the expected value; and +4. cryptographically verify TLS 1.3 CertificateVerify against that presented + key using rustls/webpki's verification helper. + +It may return `HandshakeSignatureValid::assertion()` only after step 4 +succeeds. It must advertise only schemes it actually verifies. The load-bearing +negative test presents peer A's certificate/SPKI with peer B's private key and +requires the handshake to fail. + +RPK mode requires `requires_raw_public_keys() == true`, a raw-key server +resolver (`AlwaysResolvesServerRawPublicKeys` or equivalent), and +`verify_tls13_signature_with_raw_key`. X.509 mode uses the certificate-specific +signature helper. The selected mode and mismatched-mode failures are tested. + +Disable TLS resumption/session tickets and QUIC 0-RTT/early application data. +Every connection performs current proof of possession; the in-memory nonce +cache is not durable replay protection. SNI remains +`.lanspread`, but the verifier's captured full expected ID is +authoritative. + +Client and server advertise exactly one version-bound ALPN: +`b"lanspread-peer/" || ASCII_decimal(PROTOCOL_VERSION)`. Any absent or different +ALPN fails the handshake. TLS 1.2 is disabled and +`verify_tls12_signature` always rejects. In RPK mode the transmitted key is the +canonical RFC 7250 Ed25519 DER SubjectPublicKeyInfo, while `PeerId` derivation +still hashes the extracted raw 32-byte key. + +Keep P-256 TLS as a last fallback until the RPK/Ed25519 certificate spike +succeeds. A split P-256/Ed25519 design is allowed only if the verifier receives +and validates a required critical certificate extension before channel +acceptance. The Ed25519 signature input is exactly: + +```text +b"lanspread/tls-key-binding/v1\0" +|| u32_be(PROTOCOL_VERSION) +|| identity_key[32] +|| u32_be(tls_spki_der_length) +|| tls_spki_der[tls_spki_der_length] +``` + +The verifier derives and matches the expected identity, verifies this binding, +then verifies CertificateVerify under the P-256 SPKI. A missing/tampered +extension or statement delivered later inside `Hello` is rejected. If this +pre-channel binding is not practical, the fallback is forbidden. + +If X.509 is used, configure +`rcgen = { default-features = false, features = ["aws_lc_rs"] }` (plus `pem` +only if actually needed) so the plan does not silently add `ring` as a second +crypto stack. + +### 7.6 Outbound binding, inbound Hello, and connect-back + +A successful outgoing handshake records the endpoint actually dialled under +the pinned key. `HelloAck.listen_addr` and every other advertised address are +new candidates only; they never redirect the successful binding. + +Phase 2 authenticates the responder, not an incoming client. Therefore an +inbound `Hello` is non-authoritative: + +- protocol 8 `Hello` is candidate-only: current protocol, claimed ID/key, + listener, bounded revision/feature hints, and + `request_connect_back: bool`; it carries no library or CTP vector; +- perform only bounded cheap syntax/version checks; +- it may receive the responder-authenticated Ack and read-only public data + allowed by policy; +- do not upsert/remove/rebind a peer, merge its library or Call-to-Play state, + create trust continuity, or refresh listener reachability; and +- enqueue at most one bounded, deduplicated connect-back to the claimed + `PeerEndpoint` when `request_connect_back` is true. + +Discovery's first probe sets `request_connect_back = true`. A connect-back or +ordinary pinned resync sets it to false and is terminal: its responder returns +the state-bearing Ack but never schedules another callback. Because inbound +Hello is non-authoritative in both modes, an attacker setting false gains no +state-mutation shortcut. Each side learns the other only from its own outbound +pinned connection, so the initial probe plus one callback establishes both +directions without recursion. + +The connect-back requires the claimed ID as the TLS-pinned responder. Only its +authenticated response may merge state and bind the endpoint that was actually +dialled. A claimed target must be a nonzero unicast LAN socket whose IP equals +the inbound QUIC source IP. mDNS candidates use their separate bounded dialing +path; a forged advertisement is not callback authorization. Permit at most one +in-flight callback per endpoint, two per source IP, and 16 globally, with the +candidate admission rates above, deduplication, expiry, and jittered backoff. +These controls bound rather than eliminate LAN scan/reflection and +work-amplification risk. Hello, mDNS, and legacy library hints all feed one +coalescing scheduler with these per-source/advertised-target, per-PeerId, and +global budgets; blocked IDs are dropped before scheduling. A legacy library +hint can target only an already verified recorded `PeerEndpoint`; its claimed +address and delta body are ignored, and an unknown claimed ID is dropped. + +Before independently signed events land, no client-originated state-changing +object is authoritative. A legacy library notification must trigger the +bounded pinned resync so post-start honest library updates still propagate; +legacy CTP networking is disabled for protocol 8. During +Phase 3, only independently verified signed Call-to-Play objects may mutate +their own store through an otherwise unauthenticated outer request; outer +sender/address claims remain non-authoritative. All other client-originated +state changes wait for Phase 4 envelopes. + +Peer binding is a typed atomic operation. If an address is already bound to a +different ID, reject the candidate and preserve both maps and the established +peer. A same-ID move is accepted only after pinning the new endpoint and +establishing that the old record is no longer current/reachable, then both +indexes change atomically. If old and new endpoints simultaneously prove +possession of the same key, preserve the established binding, quarantine the +candidate, and surface a duplicate-identity/clone conflict instead of flapping +between them. An unproven collision never evicts anyone. + +### 7.7 Liveness and removal + +Remove `Goodbye` from the protocol, handlers, shutdown flow, threat claims, +and tests. Authenticated liveness expiry is the sole remote-removal mechanism. + +Liveness pings take `PeerEndpoint` and pin the responder. Successful outbound +authenticated contact proves endpoint reachability. An inbound signed frame +may prove identity activity after envelopes land, but it does not prove that +the advertised listener is reachable and does not refresh that endpoint's +reachability timeout. + +Every liveness task snapshots the peer-record revision together with the ID, +address, and reachability timestamp. The revision increments on every +successful endpoint-reachability refresh and every rebind, including activity +while an older probe is pending. A late success or failure may update or remove +only if the current record still has that revision, endpoint, and observed +reachability timestamp. +`remove_peer_if_current` or equivalent compare-and-remove logic prevents a +stale probe from deleting a rebound/reconnected peer or cancelling its active +downloads. + +The same conditional comparison applies to asynchronous ping failures and the +periodic stale-prune sweep. Phase 2 removes pre-dispatch +`note_peer_activity`/address-based attribution entirely. + +### 7.8 Exact signed control envelopes + +Every control `Request` and `Response` uses: + +```text +SignedEnvelope { + protocol_version: u32, + sender: PeerId, + sender_key: PublicKey, + recipient: PeerId, + nonce: Nonce, + context: request | response, + payload: opaque bytes, + signature: Signature, +} +``` + +All fields are required; unknown fields are rejected. The inner request or +response is serialized once with `serde_json::to_vec`, stored as explicit +base64url payload bytes, signed, and verified before deserialization. The outer +JSON is not canonical and is not itself signed. + +The exact signature input is: + +```text +b"lanspread/control-envelope/v1\0" +|| u32_be(protocol_version) +|| context_tag // request = 0x01, response = 0x02 +|| sender_ascii[52] +|| sender_key[32] +|| recipient_ascii[52] +|| nonce[16] +|| u32_be(payload_length) +|| payload[payload_length] +``` + +Ed25519 signs these bytes directly. Receivers require the current +`PROTOCOL_VERSION`, exact fixed lengths, expected context/direction, +`recipient == local_peer_id`, `derive_peer_id(sender_key) == sender`, and a +valid signature before parsing the payload or dispatching a handler. A client +also requires `Response.sender` to equal the TLS-pinned responder. A response +sets its recipient to the verified request sender, copies the verified request +nonce, and the initiator requires equality, binding the pair. + +Boundary verification yields `VerifiedSender`; handlers no longer accept or +trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never +forwarded. + +The nonce table is per sender/context, bounded, checked-and-inserted atomically +only after signature verification, and in memory only. Cache loss or eviction +can cause a duplicate to be processed, so state-changing handlers remain +idempotent or revision/event-ID guarded. There is no wall-clock signature +expiry and no claim of durable replay prevention. + +Every request initiator generates a fresh 16-byte CSPRNG nonce and never +intentionally reuses it. The duplicate cache is capped at 64 entries per +sender/context, 256 represented senders, and 8192 entries globally; admission +of a new sender/cache entry is rejected when the applicable bound cannot be +met after normal LRU expiry. A response copies rather than generates the +request nonce. + +Bulk responder-to-initiator chunk and stream-install frames remain unsigned +inside the responder-pinned TLS channel. That deliberate asymmetry preserves +throughput. Every retry still uses the selected `PeerEndpoint`; TLS must never +fall back to “whoever is at this address.” + +The length-delimited control codec gets a named maximum below its current 8 +MiB default after Phase 0 measures the largest bounded library/manifest +message. Any bounded payload that cannot fit must be paginated rather than +raising the frame indefinitely. The encoded outer-frame cap is enforced before +frame allocation; a bounded base64 visitor checks computed decoded length +before allocating the decoded payload. Both happen before signature work and +inner deserialization. + +### 7.9 Independently signed Call-to-Play events + +Call-to-Play objects land before general signed envelopes so relayed authorship +is independently safe as early as possible. + +```text +SignedCallToPlayEvent { + protocol_version: u32, + event_id: EventId, + author: PeerId, + author_key: PublicKey, + body: opaque CallToPlayEventBody bytes, + signature: Signature, +} + +CallToPlayEventBody { + call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce }, + actor_name, + at, + action, +} +``` + +The body excludes `actor_id`, `call_id`, `event_id`, and independent chat +`message_id`. The author is derived from `author_key`; the redundant outer +`author` must match. Core publication creates the 32-byte CSPRNG nonce for +`Create`, resolves the retained `CallRef` for later actions, chooses identity +and IDs, and signs. The frontend never supplies those authority fields. + +The exact event-signature transcript is: + +```text +b"lanspread/call-to-play-event/v1\0" +|| u32_be(protocol_version) +|| author_ascii[52] +|| author_key[32] +|| u32_be(body_length) +|| body[body_length] +``` + +The body encoding is UTF-8 RFC 8259 JSON with the exact field/action names in +the versioned proto schema, integer timestamps only, explicit URL-safe +unpadded-base64 adapters for `PublicKey` and the 32-byte `CallNonce`, and no +unknown or duplicate fields or trailing non-whitespace data. The local emitter +uses compact `serde_json` with declared struct-field order. Verification and +forwarding always use the exact received bytes and never reconstruct them; the +store retains body and signature verbatim. Fixed vectors freeze both the local +encoding and exact-byte verification behavior. + +Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32: + +```text +call_id = H("lanspread/ctp-call/v1\0" || creator_key || create_nonce) +event_id = H("lanspread/ctp-event-id/v1\0" || u32_be(protocol_version) + || author_key || u32_be(body_length) || body) +``` + +The receiver first requires `event.protocol_version == PROTOCOL_VERSION`, then +recomputes both IDs and verifies the signature before parsing/admission. The +event signature covers the transcript above. The content-derived event ID makes adversarial +same-ID/different-body conflicts unreachable; the enclosing `event_id` is also +the chat/UI message identity. + +Authority rules: + +- `Create`, `Start`, `Cancel`, and `AddTime` require + `author_key == call_ref.creator_key`. +- `Respond`/RSVP, `Leave`, and `SendMessage` may be signed by a participant but + require a retained or same-batch valid `Create` for the same `CallRef`. +- A rooted nonterminal action, including `AddTime`, has reducer effect only + when it sorts strictly after canonical `Create` by `(at, event_id)`. A + correctly signed candidate with some valid root but currently before the + canonical Create remains retained/inactive within quotas so a later earlier + canonical Create can trigger deterministic recomputation. Only a + creator-signed `Start`/`Cancel` has root-independent burn semantics. +- A rootless `AddTime` or participant event is `NeedHistory`, not + independently applicable. +- A valid creator-signed rootless `Start` or `Cancel` is an independently + verifiable irrevocable burn certificate for that `CallRef`. +- Names remain signed self-assertions and never grant authority. + +For a creator that equivocates, canonical `Create` is the minimum +`(at, event_id)`. Among all rooted or rootless `Start`/`Cancel` certificates, +the canonical terminal is also the minimum `(at, event_id)`. Rootless state +retains that exact winning terminal and order key, not only a set of call IDs. +An earlier valid terminal arriving later atomically replaces a later tombstone; +losers and all later nonterminal/root data are obsolete. This selection is +arrival- and grouping-independent. A stale `Create` can never resurrect a +burned `CallRef`. + +Compaction retains the current rooted display history for 15 minutes, then the +single canonical terminal. Fresh peers accept and relay that self-proving +tombstone. This deliberately changes and repairs the current replication +contract. A rootless tombstone is internal anti-resurrection state and is not +rendered as a nomination because it lacks the root's game/deadline data. Later +valid rooted history may hydrate terminal display but never reopen the call. + +The verification cache is a bounded LRU keyed by a digest of the exact signed +object, including context, author key, body, and signature. A hit may reuse only +the canonical object already verified; it must never bless an incoming +alternate signature merely because an event ID matches. Eviction changes +performance only. + +Do not reject relayed events because `now - at` is large. Every timestamp, +deadline, and `ready_at` is an integer in +`1..=8_640_000_000_000_000` milliseconds (the positive ECMAScript Date range, +also exactly representable as an integer in JavaScript), and all arithmetic is +checked for overflow. Replicated validation uses only these deterministic +signed-field rules: + +- every event `at` is positive; +- `Create.deadline > Create.at` and + `Create.deadline - Create.at <= 3 days`; +- `Create.scheduled_for` is absent or exactly equals `Create.deadline`; +- `AddTime.deadline` is strictly greater than the event's `at`; it has reducer + effect only when it also exceeds the effective deadline immediately before + it in the canonical fold; and + `AddTime.deadline - canonical_Create.at <= 3 days`; and +- `Respond.ready_at` is absent or lies in the inclusive interval + `[Respond.at, canonical_Create.at + 3 days]`. + +Local direct publication may apply a one-sided future-skew check before +signing; receivers do not make signature validity depend on their current +clock. + +To compute extensions, sort all otherwise valid `AddTime` events after the +canonical Create by bytewise-ASCII `(at, event_id)`, fold from +`Create.deadline`, and apply only strict deadline increases satisfying the +rules above. Retain structurally valid rooted candidates within quotas even +when currently ineffective. Rebuild that fold and every dependent-action +classification over the retained union whenever an earlier Create/extension +arrives. Missing-root objects remain `NeedHistory`; normal expiry or canonical +terminal compaction eventually removes inactive candidates. Backend and +frontend use the same numeric +timestamp and bytewise lowercase-base32 ID comparator; the frontend must not +use locale-dependent collation. + +#### Call-to-Play limits and merge semantics + +Initial hard limits, adjusted only through a measured Phase 0 decision: + +| Limit | Value | +|---|---:| +| Exact encoded signed event | 4 KiB | +| Total stored events | 4096 | +| Total exact encoded stored bytes | 4 MiB | +| Per author, including local | 256 events and 256 KiB | +| Remote-author identities represented | 256 | +| Reserved inside global limits for local author | 256 events and 256 KiB | +| Verification cache | 4096 exact-object entries | +| Incoming CTP vector before signature work | 4096 events and 4 MiB | + +All count and byte limits are conjunctive and include active events, rooted +terminal history, rootless tombstones, and quarantined blocked objects. Quotas +are charged to the signed author, not the transport relay; a locally authored +object relayed back still counts as local. Tombstones are never evicted for any +admission. + +The remote pool stops at 3840 events and 4 MiB minus 256 KiB, preserving the +local reserve; the local author still has the same 256-event/256-KiB author cap. +The 256 represented-remote-author limit counts identities with retained remote +objects, not an ever-seen set. A creator terminal +may settle an already-retained rooted call at capacity if post-reduction +compaction brings usage within hard limits; it may also replace an existing +tombstone with the canonical earlier winner only when the replacement also +fits the byte cap. A new rootless terminal consumes normal global/per-author +capacity—Create+Cancel spam cannot bypass quotas. Under overload, rooted +display retention may compact immediately to the canonical terminal to +preserve the state-reducing action and hard bound. + +Merge processing is: + +1. enforce frame, vector, object-size, and structural bounds before signatures; +2. independently authenticate and schema/block-classify each object; +3. compute canonical/state-reducing replacements and a dependency-closed + candidate set, allowing a same-batch `Create` + regardless of vector order and the explicit rootless-terminal exception; +4. reduce and pressure-compact on a clone, then apply per-author and represented + author limits to capacity-increasing groups without letting one author + poison another; +5. after every author/represented-author/capacity filter, recompute dependency + closure to a fixed point (or admit a whole dependency component), so a + rejected Create cannot leave an admitted dependent participant action; +6. commit state reducers first. Admit the remaining capacity-increasing groups + only if they collectively fit global count/byte/remote-pool limits; + otherwise admit none of those groups (never an arrival-order prefix); and +7. canonicalize and swap the store atomically once. + +Atomic commit remains a feature; before the explicit collective-global- +exhaustion condition, one hostile event does not reject every unrelated +author. After Sybil exhaustion, emit a persistent typed warning that +history is full and updates may be incomplete. Do not accuse the transport +relay. Recovery is block/quarantine followed by deliberate party-state reset or +restart and authenticated resync. Deterministic over-quota quarantine is later +hardening. + +Responder-authenticated transport never confers third-party authorship. +Protocol 8 takes the simplest safe option: it disables all network +Call-to-Play ingestion/synchronization while leaving local UI state available. +Phase 3 restores network propagation with signed objects. A paginated Phase 3 +snapshot uses a random snapshot ID, page index/count, declared total +event/decoded-byte counts, and full-snapshot digest; pages are assembled only +within one pinned handshake/session under the 4096-event/4-MiB cap and merged +once. Missing, duplicate, mixed-ID, digest-mismatched, expired, or over-limit +assemblies are discarded. Independently signed event notifications may be +merged directly. Neither an inner event author nor an unauthenticated outer +relay refreshes listener liveness; after Phase 4 only the verified envelope +sender may record identity activity, never endpoint reachability. + +### 7.10 Download-source authorization + +Browse/library summaries from nonblocked `prompt` or `denied` peers may remain +visible. They are not transfer authority. + +At transfer start, the peer core—not Tauri—builds the authoritative manifest +from direct responses received over responder-pinned connections from +`download_from = allowed` identities. Every accepted tuple +`(game_id, canonical path, is_dir, size)` has exact attestation from at least +one allowed identity. Transfer consensus excludes unapproved peers; an +unapproved vote cannot introduce or select a descriptor that causes filesystem +mutation. + +For ordinary downloads, the validated manifest maps each exact descriptor to +the allowed `PeerEndpoint`s that attested it. The plan, progress state, and +retry paths preserve that provenance. A retry chooses only an allowed identity +that advertised that exact descriptor. It never falls back from a file-specific +source set to a global address list. An address may refresh only by resolving +and pinning the same selected `PeerId`. + +Streamed install has a distinct boundary: ordinary metadata describes root +archives, while `FileBegin` dynamically introduces extracted paths. A streamed +source is eligible only if it is allowed, TLS-pinned, and attested the complete +selected root-archive manifest—not merely one consensus file. Its extracted +frames stay inside isolated staging and are checked with §6's canonical, +reserved-path, symlink/reparse, count, and byte rules before each staged create. +There is no per-extracted-file fallback. Retrying the stream restarts it from an +allowed identity that attested the same complete archive manifest. A future +pre-attested extracted-manifest preamble would be a separate wire change, not +an assumption in this phase. No staged path is promoted until the terminal +`Complete` frame validates the observed entry count, aggregate bytes, archive +set, and stream result; failure discards the entire staging transaction. + +Validate the complete authoritative/selected manifest using §6 before +`begin_version_ini_transaction` or any preparation. Then and only then may +storage mutate the requested game root. + +Changing `allowed` to `denied`, or blocking a peer, durably changes policy +before success is reported and cancels the entire normal or streamed transfer +if it uses that source. Remove it from retries, restore/leave the installation +sentinel in the existing incomplete state, invoke the ordinary-download +discard/cleanup path for partial peer-owned payload, and cleanly discard +streamed staging; a new user-initiated attempt may replan from remaining +eligible sources. User-owned `local/` remains untouched. + +This boundary stops an unapproved peer from supplying transfer metadata or +bytes. It does not verify bytes from an approved peer. + +## 8. Wire and protocol evolution + +- Transport identity and endpoint semantics: protocol 8. +- Independently signed Call-to-Play objects: protocol 9. +- Signed control envelopes: protocol 10. +- Optional party admission/rotation: a later bump only if implemented. + +Exact numeric versions are rebased if the repository's current version changes +before implementation; the ordering and no-compatibility policy are fixed. + +`Request`/`Response` become inner payloads of `SignedEnvelope`; identity fields +that duplicate the verified sender are removed. `Goodbye` is deleted. Protocol +8/9 transitional `Hello` carries a claimed identity only to request the pinned +connect-back. Transitional `HelloAck.peer_id/public_key` MUST exactly equal the +TLS-pinned identity or the response is rejected; binding and library merge are +always keyed from the pin, never those redundant fields. +Protocol 10 removes those duplicate inner sender ID/key fields and uses the +verified envelope sender (plus the TLS pin for responses) as the sole identity +authority. Advertised listener addresses remain candidates requiring proof. +Call-to-Play snapshots carry `SignedCallToPlayEvent` and use the bounded atomic +snapshot assembly in §7.9. + +Protocol 9's `CallToPlayEvents` request contains only signed event objects or +snapshot-page data; it removes the legacy relay/sender `peer_id` field. The +outer transport origin grants no event authority. Remaining per-request sender +fields disappear with all control envelopes in protocol 10. + +Protocol 8 standardizes newly produced manifest paths to `/`-separated +canonical form. Because that is inside the version bump, protocol-8 receivers +need no legacy producer fallback; the standalone protocol-7 validator's +one-time normalization exists only until the cutover. + +| Version | Inbound control dispatch | +|---|---| +| 8 | `Hello` is candidate-only; Ping/browse/metadata/transfer reads are anonymous/public; LibraryDelta is a coalesced pinned-resync hint; network CTP is disabled; no inbound activity attribution; no `Goodbye`. | +| 9 | Version 8 behavior, except independently signed CTP objects/snapshot assemblies may mutate only the CTP store after per-object verification. | +| 10 | Every control request/response requires a verified envelope; block policy runs before inner dispatch. Bulk chunk/stream frames remain unsigned only inside the responder-pinned TLS connection created by that verified request. | + +## 9. Code ownership map + +New `crates/lanspread-identity`: + +| Area | Responsibility | +|---|---| +| `key` | Ed25519 secret/public key, full PeerId derivation, zeroizing secret record | +| `store` | Explicit modes, normalized backend results, transition table, lifetime lease, keyring/file storage, sidecar, repair API | +| `sign` | Exact transcript signing and verification, verified sender/event construction | +| `tls` | RPK/X.509 material and pinned rustls client/server configuration | + +`lanspread-proto` owns wire-only fixed types, explicit serializers, transcript +builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data. + +Principal `lanspread-peer` changes: + +| Area | Required change | +|---|---| +| `identity.rs`, startup | Durable derived identity and typed repair; no silent UUID regeneration | +| `config.rs`, TLS assets | Remove shared `CERT_PEM`/`KEY_PEM` and repository key material after Phase 2 | +| `network.rs`, remote peer helpers | Mandatory expected endpoint; remove address-derived identity | +| `services/server.rs` | Per-peer rustls TLS server and bounded candidate/connect-back work; generic accept-loop DoS remains an accepted limit | +| `services/discovery.rs` | Candidate directory/retry only; no authenticated-state mutation | +| `services/handshake.rs` | Non-authoritative inbound Hello; pinned callback; record dialled endpoint | +| `services/stream.rs` | Envelope verification boundary and `VerifiedSender`; no payload-trusted IDs or `Goodbye` | +| `services/liveness.rs` | Pinned endpoint probes and revision-conditional update/removal | +| `peer_db.rs` | Atomic verified bind/collision rejection; first-class endpoint/index invariants | +| metadata/download/stream install | Allowed-source manifest provenance through selection, storage, retries, and revocation | +| `call_to_play.rs` | Signed event store, CallRef authority, canonical rootless terminals, exact cache, count/byte/reserve policy | +| errors/events | Typed auth, identity-repair, source-denied, and overload states reaching the UI | + +The peer CLI gains deterministic explicit identities, full endpoint input, and +hostile modes. Tauri gains minimal Phase 1 repair and Phase 2 source-approval +surfaces before the complete trust UI. + +## 10. UI and operational semantics + +- **Identity repair (Phase 1):** typed failure, retry/unlock, import backup, + explicit backend selection where safe, deliberate file-backed fresh create, + and deliberate reset. Networking stays stopped during repair. +- **Source approval (with enforcement):** show full/short fingerprint and + “Allow downloads from this device.” New devices default to prompt. The UI + must not call this content trust. +- **Peer identity:** display name + short fingerprint, persistent New marker + until acknowledged, and a prominent same-name/new-key conflict. +- **Blocking:** immediate durable block/unblock, direct and relayed enforcement, + visible recomputation, and resync on unblock. Unblock does not restore source + permission automatically. +- **Identity settings:** full copyable fingerprint, honest backend label, + backup/import/reset consequences, and platform-specific persistence wording. +- **Call-to-Play overload:** persistent typed “history limit reached; updates + may be incomplete” warning, signed offending authors when known, and reset / + restart recovery guidance. +- **Diagnostics:** rejected auth/source objects surface typed reason codes and + fingerprints, never secret material or misleading generic network errors. + +## 11. Implementation phases and acceptance gates + +Every code phase runs `just fmt`, `just clippy`, and `just test`. UI/Tauri +phases also run `just frontend-test` and `just build`. Every protocol/peer phase +runs targeted `just peer-cli-tests` during development and the unfiltered suite +before phase completion. Wire phases run the honest alpha/bravo/charlie matrix +using a freshly built image. + +Fix the `justfile` stale-image hazard first: manual `peer-cli-run`, alpha, +bravo, and charlie recipes must depend on a readiness target that builds the +current `peer-cli-image` and network. A manual run against an old image is not +acceptance evidence. + +### Phase 0 — freeze contracts and complete spikes + +Record in the repository: + +- the identity transition table and backend scope; +- exact backup-envelope KDF/AEAD/header parameters; +- `PeerEndpoint`, socket-admissibility, candidate/verified-record, clone, and + liveness-generation invariants; +- candidate TTL/backoff and all candidate/live-peer/connect-back/trust/nonce + capacity and rate values; +- trust/source-permission schema and block transitions; +- exact envelope and event transcripts; +- the protocol 8/9/10 dispatch matrix (protocol 8 no network CTP or + client-authoritative controls; protocol 9 self-signed CTP only; protocol 10 + envelope-authenticated controls); +- Call-to-Play count/byte/reserve values and accepted exhaustion behavior; +- manifest count/size bounds, Windows/path policy, and control/snapshot + frame/pagination decision; +- maximum-size history/manifest and honest download/handshake workloads plus + objective peak-memory, latency, and throughput-regression thresholds for the + final performance gate; +- the dependency rule: `lanspread-identity` depends on `lanspread-proto`, never + the reverse; and +- the completed RFC 7250 RPK / Ed25519 X.509 spike result, disabled + resumption/0-RTT configuration, and exact legal fallback if one is needed. + +Phase 1a may run in parallel. Its identity primitives need only the frozen +identity/transcript slice; persistent writes and runtime identity replacement +wait for the storage table, while unrelated TLS/manifest measurements may +finish concurrently. + +### Prerequisite — download manifest confinement + +After Phase 0 freezes manifest bounds, implement §6 with no wire change and +before Phase 1b writes persistent identity. Phase 1a may remain in parallel. +Mandatory hostile path, limit, absent-from-authoritative-selection, and +zero-mutation tests land here, including the cross-game `local/` sentinel. Run +the full peer-CLI suite and `just build` because the Tauri-to-download +integration is in the path. Windows alias/reparse behavior requires supported +Windows CI or recorded manual evidence; Linux-only results are labelled as +such rather than generalized. + +### Phase 1a — primitives and fake-backed groundwork + +Add the identity crate, Ed25519/PeerId/signature primitives, versioned +zeroizing secret type, normalized backend outcomes, fake backend, fixed-seed +golden transcript/signature vectors, secret-redaction tests, and +keyring/platform feasibility. Golden vectors include exact control request and +correlated response bytes/signatures plus one CTP event and assert canonical +base64url string fields. Certificate DER/cross-language vectors are not +required. No persistent writes, runtime-ID replacement, legacy deletion, or +trust population. + +### Phase 1b — durable identity and usable repair, no wire change + +Implement every §7.2 transition-table cell, lifetime lease, keyring/file +backends, secret-first sidecar recovery, override isolation, correct Tauri +`app_data_dir`, encrypted backup/import/reset, and the minimal repair UI. Keep +the protocol-7 runtime/advertised UUID until the Phase 2 wire cutover; do not +populate security trust from it. + +Mandatory tests include every backend state, no-fallthrough on +locked/denied/unavailable/corrupt, sidecar mismatch, absent/corrupt sidecar +recovery, unsupported-version zero writes, valid-sidecar no unselected probe, +multiple secrets, write-failure reprobe, crash after secret before sidecar, +replacement mismatch repair, every backend-migration intent stage and target +conflict, account-wide lease held throughout repair, two +simultaneous starts, file permissions/atomic replacement, CLI-over-environment +precedence and zero default-store access in explicit modes, +tampered/wrong-passphrase backup, selected-backend reset/import failure without +fallback, successful export/import fingerprint round trip, header/decoded-size +and KDF-cost bounds rejected before expensive allocation/work with zero writes, +runtime stop/rebuild/restart on replacement, legacy UUID preservation +on every failed path, and successful import/reset still advertising the same +protocol-7 UUID/shared TLS identity rather than prematurely activating the new +key, secret redaction, and “shell visible/network stopped” +repair behavior. Give every Docker/peer-CLI recipe a distinct deterministic +seed/file before this gate; test restart stability and no accidental shared +identity. Run `just frontend-test`, `just build`, and the unfiltered peer-CLI +suite. Real supported keyring/app-data backends require platform CI or recorded +manual evidence. + +### Phase 2 — responder-authenticated transport and endpoints (protocol 8) + +Land the selected RPK/X.509 mode, real CertificateVerify, mandatory expected +endpoint, all endpoint plumbing, mDNS candidate separation/retry, +non-authoritative inbound Hello, bounded/deduplicated connect-back, atomic +collision rejection, removal of address fallbacks and `Goodbye`, and +revision-conditional liveness. Delete the shared repository certificate/key and +compiled constants regardless of which selected per-peer TLS mode lands. +After the durable identity loads successfully, protocol 8 activates its derived +PeerId for runtime/advertising and deletes the legacy UUID as part of this +versioned cutover—not earlier. + +Client-originated controls remain unauthenticated. Library/legacy notifications +cause only bounded pinned resync. Protocol 8 disables all network Call-to-Play +ingestion/sync; local CTP remains available and Phase 3 restores networking +with self-authenticating events. The phase-current scenario matrix explicitly +expects this temporary gate instead of retaining contradictory S48/S49 +third-party-relay expectations. + +Mandatory negatives: cert/SPKI A with private key B, wrong expected ID, +disallowed/mismatched RPK or X.509 mode, resumption/0-RTT disabled, copied +public-key inbound claim, invalid callback socket class/source-IP, every +per-source/per-target/global callback and candidate-cap axis, advertised Ack +ID/key mismatch and address redirect, callback/resync terminal behavior with no +recursive callback, ALPN/version mismatch, address collision +with intact indexes, simultaneous same-key clone, +mDNS-only mutation, candidate expiry/retry without a new mDNS event, stale ping +or timeout-prune after same-address activity/rebind/reconnect, inbound traffic +not refreshing an unreachable listener, wrong key at reused address, and no +address-derived fallback. If P-256 is selected, also mutate/remove every +pre-channel binding field/signature/SPKI and reject a Hello-only binding. +The resumption negative performs a second connection/early-data attempt and +proves the full verifier and CertificateVerify path runs again. A flood mixing +Hello, mDNS, and legacy library hints proves the unified scheduler remains +within every budget. + +Send forged candidate Hello bodies containing library/CTP data and forged +legacy LibraryDelta/CTP payloads; they must not merge, create/rebind/remove a +peer, or refresh liveness. Only the separately pinned resync response may +merge. Protocol-8 Hello's candidate-only decoder rejects the obsolete full +state shape before retaining it. + +Positive tests prove pinned resync propagates post-start library changes, a +genuinely dead current endpoint eventually emits `PeerLost` and applies the +defined active-download cancellation, and every endpoint consumer (metadata, +chunks, retry, streamed install, healing, liveness, direct CLI) pins the +captured ID. Import/reset under per-peer TLS stops networking, rebuilds the +derived ID/certificate/advertisement, rejects the old ID/key, and reconnects +under the new identity. The former Goodbye shutdown scenario is rewritten around liveness. +Run `just build`, the unfiltered phase-current peer-CLI suite, and fresh +three-peer matrix. Update the relevant architecture, threat, and CLI docs in +this phase. + +### Phase 2b — default-deny transfer sources and minimal approval UI + +Using Phase 2 identities, implement the durable trust/source state and outbound +source-enforcement portions of §§7.3 and 7.10: approved-only raw-manifest +bounds/consensus, ordinary exact provenance, streamed root-archive eligibility +and staging validation, and mid-transfer revocation. Public browse remains +open; inbound serving remains anonymous/public until Phase 4, so requester +blocking is not claimed here. Relayed-author blocking belongs to Phase 3. + +Mandatory tests: a prompt/denied sole source creates nothing; browse still +works; approval enables download; unapproved metadata cannot enter transfer +consensus; UI selection absent from approved manifests fails; wrong identity at +a reused address fails; retry stays within exact attesters; manifest mismatch +fails before mutation; and deny/block cancels and cleans both ordinary and +streamed transfers without touching `local/`. Streamed hostile tests require +complete root-archive attestation and reject extracted traversal/reserved/ +symlink-reparse paths, count/byte overflow including a late invalid entry, and +invalid `Complete` without promoting any staging. Also test raw count/byte limits, +failed security-state writes/restart, and that same-name/new-key or address +reuse by a different key never inherits `allowed`, while the same verified +PeerId moving to a newly pinned address retains it; existing honest download +scenarios explicitly approve their sources. Saturate total trust records, +observed-name history, and unacknowledged observations and prove that +acknowledged, blocked, and allowed records are never silently evicted. Run +frontend/build and full peer-CLI gates, and update +source-permission/user-facing docs here. + +Catalog-matching already-downloaded data is explicitly tested for offline +install/reinstall and local serving with no remote approval, so source policy +cannot accidentally gate local provenance. + +### Phase 3 — independently signed Call-to-Play objects (protocol 9) + +Implement §7.9, including core-created CallRef/IDs, verbatim relay, creator and +participant authority, canonical rooted/rootless terminal state, deterministic +horizon checks, exact-object cache, block-at-merge, hard count/byte/author +limits, local reserve, dependency-closed classification, and overload UI. +Third-party relay is restored only for valid signed objects. + +Mandatory tests cover wrong creator/participant, CallRef mismatch, competing +same-CallRef Creates under arrival/group/partition permutations, fresh-store +rootless terminal, noncreator tombstone, rooted/rootless terminal permutations +and partitions, later-arriving earlier winner, stale resurrection, 15-minute +compaction, rootless AddTime/participant `NeedHistory`, rootless no-display and +later hydration, old and two-day history, timestamp equality/overflow/ready-at +and exact ECMAScript-range boundaries, AddTime/Create recomputation under +arrival/group/partition permutations, bytewise Rust/TypeScript ordering, and +receiver-clock-independent three-day rules, every +body/key/signature/ID/protocol-version mutation, observed-ID preseed, invalid signature after +cache hit/compaction, bounded cache, count/byte/per-author/tombstone/Sybil +limits, local reserve, terminal at capacity, blocked author relayed by a +nonblocked peer regardless of that relay's source permission, blocking +already-retained creator and participant state, +quarantine accounting, unblock authenticated resync, mixed +invalid/blocked/over-quota author isolation, all-or-none collective global +overflow, over-quota Create removing its under-quota dependent while preserving +an unrelated call, sequential-versus-single-batch arrival where an initially +inactive action becomes effective after an earlier canonical Create, +same-batch fixed-point dependency closure, bounded snapshot assembly, and +explicit bounded-divergence recovery. Frontend tests prove chat uses enclosing +event ID and rootless state stays hidden. Saturate relay-only author metadata +and prove it remains ephemeral and bounded without growing the durable trust +store. Run `just frontend-test`, `just +build`, the full hostile peer-CLI suite (restoring late-join/relay scenarios), +and honest matrix. Update CTP architecture and UI claims here. + +### Phase 4 — signed control envelopes (protocol 10) + +Implement the exact §7.8 envelope, explicit byte serializers, request/response +nonce correlation, boundary verification, `VerifiedSender`, removal of +payload identity fields, bounded duplicate suppression, and control frame cap +or pagination. Reject a blocked `VerifiedSender` before every inner handler, +including Hello, browse, and download serving; associate long-lived streamed +and ordinary chunk serving with that identity and cancel every active outbound +serve operation if the identity becomes blocked. + +Mandatory tests mutate every signed field and cover unsigned frames, wrong +signer/derived ID, protocol, recipient, context, pinned response sender, +request nonce, missing/unknown fields, padded/wrong-alphabet base64, +numeric-array bytes, wrong decoded lengths, explicit base64 round trip, +concurrent duplicates, sender/context isolation, invalid-signature nonce-cache +poisoning, eviction with idempotent replay, oversized frame/payload, forged +library delta, every valid blocked state-changing sender before handler +dispatch, blocked Hello/read/stream serving and unsigned-Hello bypass, block +cancellation of active ordinary-chunk and streamed serving, and liveness +attribution. Honest +library, download, and event flows +must remain green. Run `just build`, the full peer-CLI suite, and fresh +three-peer matrix. Update protocol/trust documentation here. + +### Phase 5 — complete trust and identity UX + +Add polished identity/pin/new/reviewed/name-conflict displays, source permission +management, block/unblock, honest storage labels, and refined repair/backup +flows. Source enforcement, relayed-object blocking, and direct requester +blocking already exist in Phases 2b, 3, and 4 respectively; this phase improves +visibility and management. + +Test reducers and Tauri failure paths, immediate sensitive-state durability, +relayed blocking, existing-state recomputation, unblock resync, permission +persistence, same-name/new-key behavior, duplicate identity warnings, and proof +that unblock leaves download permission denied. Saturation must be visible; +the user can deliberately remove an eligible unacknowledged record without +evicting protected state, after which an honest new identity can be admitted +and approved. Run frontend/build and full peer-CLI gates. Update UX +documentation in the same phase. + +### Phase 6 — optional hardening + +Separately designed features may include dual-signed key rotation, party +admission, connection retry/address tokens, and deterministic over-quota author +quarantine. Each feature owns a protocol bump and hostile tests if it lands. +Before implementation it freezes its own contract; its gate includes positive +end-to-end behavior, applicable persistence/failure/recovery cases, negative +security cases, and same-phase documentation. A reject-all implementation does +not satisfy the gate. + +Catalog content hashes/signatures remain separate, non-optional future work, +not a claim completed by this phase. + +### Phase 7 — final audit, documentation, and performance + +This is not the first hostile-test or documentation phase. Audit the already +updated `ARCHITECTURE.md`, README files, threat claims, peer-CLI docs, and UI +wording; run every standard gate, +unfiltered `just peer-cli-tests`, a fresh three-peer matrix, and download/ +handshake/full-history performance checks against Phase 0's fixed workloads and +failure thresholds. Build supported-platform production bundles with +`just bundle` and smoke-test packaged startup, identity/keyring/app-data +selection, repair, and restart; the no-bundle output from `just build` alone is +not shipping-artifact evidence. + +Audit for absence of the shared TLS key, `Goodbye`, address-only connects, +`ensure_peer_id_for_addr`, fabricated IDs, payload-trusted sender fields, +unbounded auth/CTP inputs, deferred negative tests, and any “verified/trusted +content” wording. + +## 12. Dependencies + +Prefer the existing AWS-LC stack. + +| Crate | Purpose | Constraint | +|---|---|---| +| `rustls` 0.23 | Full verifier/config construction | Version aligned with s2n-quic-rustls | +| `aws-lc-rs` | Ed25519, SHA-256, KDF/AEAD as needed | Existing crypto backend | +| `rcgen` if X.509 is selected | Self-issued leaf generation | `default-features = false`, AWS-LC feature only | +| `x509-parser` if needed | Strict SPKI/binding extraction | Parsing only; reject unsupported shapes | +| `keyring` 3 | OS secret stores | Normalize locked/denied/unavailable distinctly | +| `zeroize` | Secret hygiene | Seed-bearing values only | +| `data-encoding` or existing equivalent | base32/base64url | One exact encoding implementation | + +`s2n-quic` uses the rustls provider and retains only required provider features. +The new crate keeps `unsafe_code = "forbid"`. + +## 13. Explicit risks and decisions + +- **TLS mode uncertainty:** Phase 2 does not begin implementation around an + assumed raw-key path; Phase 0 chooses RPK or Ed25519 X.509 and proves the + wrong-private-key feasibility first. +- **Linux keyring availability:** unavailable is not absent. Fresh GUI users + can deliberately choose a labelled file backend; headless runs choose an + explicit identity source. +- **Identity loss and clones:** backup/import are necessary recovery tools but + can clone a key. The UI cannot claim automatic clone revocation. +- **Endpoint plumbing size:** this is real cross-cutting work, not hidden behind + `connect_to_peer(expected)`. Phase 2 inventories every caller and tests + retries/streamed installs explicitly. +- **Authenticated does not mean harmless:** self-signed identities and source + approval still leave approved malicious content and link/application-task + DoS, including the explicitly accepted generic server-fan-out risk in §4. +- **Bounded state versus Byzantine convergence:** this plan chooses finite + memory and explicit overload over distributed consensus machinery for a + noncritical LAN-party feature. +- **Permanent tombstones:** their anti-resurrection purpose is preserved. They + are bounded through admission, never arrival-ordered eviction. +- **Scope pressure:** each phase owns its negative tests and safe intermediate + restrictions. No phase gets security credit for a later phase's mechanism. + +## 14. Closure of prior review findings + +| Finding | Resolution in this plan | +|---|---| +| F1 signed listener/address collision | §§7.4–7.6: dialled endpoint authority, non-authoritative claims, pinned connect-back, atomic conflict rejection without eviction. | +| F2 responder-only Phase 2 / relayed authority gap | §§7.6, 7.9 and phases 2–4: protocol 8 disables network CTP and treats client claims as hints; protocol 9 admits only independently signed CTP objects; all other client authority waits for envelopes. | +| F3 compacted tombstone creator proof | §7.9: CallRef in every body, creator-signed rootless burn certificates, canonical retained rootless terminal, fresh-peer propagation. | +| F4 arbitrary event IDs/cache bypass | §7.9: full content-derived IDs and bounded exact-signed-object cache. | +| F5 invalid ±10-minute history rule | §7.9: no receiver-age rejection; deterministic three-day field horizon and local-only publication skew policy. | +| F6 resource exhaustion | §§4, 7.6, 7.8, 7.9: concrete new-amplification/batch/store/cache bounds, author quotas, local reserve, rejection/no tombstone eviction, and honest exhaustion behavior. The review's comprehensive connection/stream/disk-read scheduler is explicitly not adopted and remains an accepted separate availability risk. | +| F7 replay/Goodbye | §§7.7–7.8: delete Goodbye, narrow duplicate suppression claim, local recipient/context/pinned response, conditional liveness removal. | +| F8 storage crash/fallback state | §7.2 and Phase 1: explicit transition table, conclusive absence, lifetime lease, secret-first derivative sidecar, early repair, correct GUI path. | +| F9 expected identity not threaded | §7.4 and Phase 2: first-class endpoint through all consumers; delete address-derived/fabricated identity and require CLI fingerprint. | +| F10 trust/block/lifecycle | §§7.2–7.3, 7.9–7.10: separate review/block/source states, durable writes, relay blocking, Phase 1 repair, honest backup/clone semantics. | +| F11 incomplete crypto/wire contract | §§7.1, 7.5, 7.8–7.9: exact transcripts/encodings/crate direction, real CertificateVerify, wrong-key test, AWS-LC rcgen, pre-channel fallback binding. | +| F12 late tests/docs | §11: phase-owned negatives, full peer-CLI gates, GUI build gates, fresh manual images, final-audit-only Phase 7. | + +Additional dialogue findings are also closed: §6 handles the live cross-game +`local/` truncation bug; §7.7 handles the stale liveness-probe race; §7.9 +handles rootless terminal ordering and chat `message_id`; §§7.3 and 7.10 add +default-deny source admission without misrepresenting it as content integrity. diff --git a/PEER_AUTH_PLAN_REVIEW.md b/PEER_AUTH_PLAN_REVIEW.md deleted file mode 100644 index efcc876..0000000 --- a/PEER_AUTH_PLAN_REVIEW.md +++ /dev/null @@ -1,98 +0,0 @@ -• ## Review outcome - - The core direction is sound: per-device Ed25519 identity, pinned QUIC responders, signed forwarded events, explicit TOFU semantics, and keeping content trust out of scope. - However, I would not implement from this plan yet. Several security claims do not follow from the proposed mechanics, and the phases are not independently shippable as written. - -### Findings - -1. Blocker — a signed listener address still is not authenticated. - A signature proves who asserted listen_addr, not that they control it. The plan permits a verified handshake to rebind records (PEER_AUTH_PLAN.md:316), while the current - collision rule evicts the peer already occupying that address (crates/lanspread-peer/src/peer_db.rs:78). An authenticated attacker can therefore claim another peer’s listener - and still trigger T4. Outbound handshakes should retain the endpoint actually reached under the pinned key; inbound claims need a pinned connect-back or equivalent proof. An - unproven collision must never evict an established identity. - -2. Blocker — Phase 2 authenticates only the responder. - Client certificates are omitted and application signatures arrive in Phase 3 (PEER_AUTH_PLAN.md:202), yet Phase 2 lets inbound handshakes mutate peer state. Today - accept_inbound_hello immediately trusts the supplied identity, history, and address (crates/lanspread-peer/src/services/handshake.rs:152). A copied public key in an unsigned - Hello proves no possession. Signed Hello must move into Phase 2, inbound mutation must wait, or Phases 2–3 must be combined. - - Similarly, Phase 3 still lets B forge A’s relayed Call-to-Play history until signed events arrive in Phase 4. Those phases must ship together or temporarily reject third- - party snapshot authority. - -3. Blocker — compacted Call-to-Play tombstones lack creator proof. - The proposed call_id needs create_nonce, but that nonce appears in neither the proposed body nor today’s schema (PEER_AUTH_PLAN.md:281, crates/lanspread-proto/src/lib.rs:43). - More fundamentally, current compaction discards Create and retains only Start/Cancel (crates/lanspread-peer/src/call_to_play.rs:340). A fresh peer can verify the terminal - signer but cannot prove that signer created the call. Retain the signed root or carry a verifiable root/creator commitment in the tombstone. The replication contract cannot - remain “exactly unchanged.” - -4. Blocker — event IDs and the verification cache remain attacker-controlled. - Event IDs remain globally arbitrary, while the store rejects same-ID/different-body objects atomically (crates/lanspread-peer/src/call_to_play.rs:100). An attacker can reuse - an observed legitimate ID, pre-seed a fresh peer, and make later legitimate snapshots conflict. An ID-only signature cache (PEER_AUTH_PLAN.md:300) can also skip verification - for a different body after compaction. Event identity should be author/content-or-nonce bound; cache entries must bind the exact signed-object digest and have bounded - lifetime. - -5. High — the ±10-minute event rule rejects valid history. - Full terminal history lasts 15 minutes and tombstones last for the session, while active or scheduled calls may be older still. The proposed local-clock check - (PEER_AUTH_PLAN.md:303) would make legitimate historical acceptance peer-dependent. Apply future-skew checks at direct publication if desired, but do not expire signatures - merely because an event is relayed later. - -6. Blocker — T8/T9 and general resource exhaustion remain open. - Terminal histories and tombstones are exempt from the proposed bounds, so one key can issue unlimited Create+Cancel pairs. Sybil keys and relayed authors defeat per-author - quotas; sixteen suggested 256-event quotas still fill the global 4096 unresolved-event capacity. Add absolute object and byte limits covering unresolved events, terminal - history, tombstones, caches, and distinct authors, plus local-capacity reservation and defined eviction/rejection behavior. - - The same threat model also requires connection, stream, signature-verification, transfer, disk-read, and expensive-operation limits. The server currently spawns work per - connection and stream (crates/lanspread-peer/src/services/server.rs:58); self-issued signatures do not make a requester trustworthy. These controls cannot remain optional - Phase 6 hardening. - -7. High — replay protection does not establish T7. - A 1024-entry LRU can evict a still-fresh nonce, restart loses the cache, concurrent check-and-insert needs atomicity, and the ±120-second window introduces hard clock trust - despite the non-goal (PEER_AUTH_PLAN.md:249). A delayed legitimate Goodbye can also arrive after a newer handshake and remove the new incarnation. Use session/challenge or - incarnation binding—particularly for Goodbye—or persist all still-valid replay state and narrow the stated guarantee. - -8. Blocker — identity storage needs a crash-safe backend state machine. - “Keyring unavailable” cannot mean the same thing as “no key exists”: falling through to a file can fork an established identity. The sidecar-selected backend must become - authoritative, with explicit handling for missing, locked, denied, corrupt, and mismatched states. The secret and identity.json also cannot be atomically committed together; - concurrent starts, crashes, import, reset, and rotation need a process lock, generation-based commit protocol, and reconciliation rules. - - The persistence rationale is also inaccurate for the GUI: production passes Tauri’s app_data_dir, not the core ~/.lanspread fallback (crates/lanspread-tauri-deno-ts/src- - tauri/src/lib.rs:2437). Reinstall survival needs a platform/package matrix. The fixed keyring account additionally describes one identity per OS account, not necessarily one - per installation. - -9. Blocker — mandatory expected identity is not threaded through the runtime. - The plan bans address-only connections (PEER_AUTH_PLAN.md:210), but direct connect is still ConnectPeer(SocketAddr) (crates/lanspread-peer/src/lib.rs:263), and downloads, - retries, streamed installs, healing, shutdown, and liveness frequently retain only addresses. Phase 2 needs a first-class PeerEndpoint { peer_id, addr } throughout. Direct - connect must require an expected fingerprint/ID or define an explicit user-confirmed TOFU bootstrap. - -10. High — trust and identity lifecycle semantics are incomplete. - Blocking at direct handshake/connection does not block A’s events relayed by allowed B. The common event-merge boundary needs a block/admission policy, including existing - history and unblock recovery. The trust schema also has only known | blocked, so it cannot represent the promised persistent “new,” reviewed, or acknowledged state; security- - sensitive writes such as block/pin must not be merely debounced. - - Phase 1 can already produce fatal identity errors, but usable repair is deferred to Phase 5; today startup failure is only logged (crates/lanspread-tauri-deno-ts/src-tauri/ - src/lib.rs:2118). Minimum retry/import/reset/export must accompany storage. Seed export also creates two indistinguishable live devices unless backup versus transfer, - retirement, and stale-copy behavior are defined. - -11. High — the cryptographic wire contract is not normative enough. - The signature transcript still contains an ellipsis (PEER_AUTH_PLAN.md:247); it must define every field, order, tag value, integer width/endian, encoding, and protocol- - version binding, plus require recipient-local, expected-context, and response-sender-equals-TLS-pinned-responder checks. Add golden vectors. - - Additional concrete corrections: - - A custom rustls verifier must verify TLS 1.3 CertificateVerify, not only inspect SPKI (/home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rustls-0.23.42/src/ - verify.rs:69). - - - Bytes becomes a JSON numeric array, not base64, without an explicit serializer. - - The proposed crate graph is contradictory: lanspread-identity cannot construct lanspread-proto types while both dependency directions are forbidden - (PEER_AUTH_PLAN.md:370). - - - rcgen must disable its default ring feature to preserve the one-crypto-stack claim. - - The P-256 fallback needs a binding visible before accepting the TLS channel; an identity statement delivered only inside that channel is too late. - -12. High — adversarial tests and documentation arrive too late. - The hostile harness is described as the trust model’s regression net but deferred to Phase 7 (PEER_AUTH_PLAN.md:523). Each trust boundary must land with its negative - scenarios and documentation. Per-phase gates should include targeted just peer-cli-tests, the honest-path matrix after major protocol phases, and just build for GUI changes— - not only manual containers, whose recipes can use an old image (justfile:46). Phase 7 should be a final audit, not the first security acceptance pass. - - My recommended prerequisite is a revised Phase 0 that settles the endpoint model, exact wire transcripts, storage state machine, resource budgets, crate graph, and TLS spike. - Then Phase 1 can ship storage with recovery; Phase 2 can ship mutually authenticated handshakes and address proof; signed envelopes and independently verifiable relayed events - should ship atomically or behind a safe intermediate restriction.