Files
lanspread/PEER_AUTH_PLAN.md
T
2026-07-28 07:41:30 +02:00

31 KiB
Raw Blame History

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 <state_dir>/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 <state_dir>/identity.key, mode 0600 on Unix, user-only ACL on Windows, written tmp + atomic rename.

Alongside it, a non-secret <state_dir>/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 Zeroized, 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: <state_dir> (~/.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 <state_dir>/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 = <peer_id>.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 (<expected_id>.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:

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. StreamInstallFrames 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.

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

<state_dir>/trust/peers.json, versioned, atomic write, debounced:

{ "version": 1,
  "peers": { "<peer_id>": {
      "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<SignedEvent>.
  • CallToPlayEvent → CallToPlayEventBody without actor_id.
  • mDNS TXT gains pk.

7. Code map

New crate crates/lanspread-identity:

Module Contents
key Ed25519 keypair, PeerId derivation, Zeroizeing 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.