diff --git a/PEER_AUTH_PLAN.md b/PEER_AUTH_PLAN.md new file mode 100644 index 0000000..75fddb7 --- /dev/null +++ b/PEER_AUTH_PLAN.md @@ -0,0 +1,583 @@ +# Rework peer authentication: keys, signed events, real identity + +## 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 | +|---|---|---| +| `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 | | + +`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. + +## 12. Risks + +- **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. + +## 13. Decisions to confirm before Phase 1 + +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. diff --git a/PEER_AUTH_PLAN_REVIEW.md b/PEER_AUTH_PLAN_REVIEW.md new file mode 100644 index 0000000..efcc876 --- /dev/null +++ b/PEER_AUTH_PLAN_REVIEW.md @@ -0,0 +1,98 @@ +• ## 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.