31 KiB
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-51compilescert.pemandkey.pemfrom the repository root into the binary. The QUIC server presents it (services/server.rs:31) and the client trusts exactly it, under the server namelocalhost(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_idis a self-asserted UUID.identity.rs:11-26generates a UUIDv7 and stores it in<state_dir>/peer_id. It is announced in mDNS TXT records (services/advertise.rs:96), inHello/HelloAck, and re-sent as a field inside individual request bodies.- The peer table is mutated from unauthenticated input.
services/discovery.rs:165-182upserts a peer record and its library revision straight from mDNS TXT data, before any handshake.peer_db.rs:78-117rebinds a knownpeer_idto 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-116readspeer_idout ofRequest::LibraryDelta,Request::CallToPlayEventsandRequest::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'sactor_idequals thepeer_idfrom 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 earliestCreateevent'sactor_idas the creator and acceptsStart,Cancel, andAddTimefrom that string. A hostile peer can cancel or start anyone's call, chat as anyone, and — sinceMAX_EVENTS(call_to_play.rs:20) is a single global 4096-event bound — fill the shared history until legitimate local publishes fail withHistoryFull. ARCHITECTURE.mdalready 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
- Every peer has a long-lived cryptographic identity it controls, and
peer_idis derived from that key rather than asserted. - The identity survives app updates, reinstalls, and IP changes, and is stored with platform-appropriate protection at rest.
- The transport authenticates the peer, so QUIC connections cannot be impersonated or man-in-the-middled by another LAN host.
- 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.
- 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.
- 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.
- 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.mdstreamed-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 bumpsPROTOCOL_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 ofSHA-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:
LANSPREAD_IDENTITY_SEED(env, base64url seed) — deterministic identities for tests, containers, and scripted scenarios. Never persisted.--identity-file/LANSPREAD_IDENTITY_FILE— explicit path, for the peer-cli and CI.- OS secret store via
keyring: Windows Credential Manager (DPAPI), macOS Keychain, Linux Secret Service. Servicenetwork.paul.lanspread, accountpeer-identity. - File fallback
<state_dir>/identity.key, mode0600on 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>(~/.lanspreadby default,state_paths.rs:10-24) already survives binary updates, and so does the current plaintextpeer_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_idUUID 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
ServerCertVerifierthat 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-80today) or get pooled later. connect_to_peergrows a requiredexpected: PeerIdargument. 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 exceptGoodbye, 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 aVerifiedSender { peer_id, public_key }and lose thepeer_id: Stringparameters 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],
}
CallToPlayEventBodyis today'sCallToPlayEventminusactor_id— the author is the key.actor_namestays, signed but unverified (T10).publish()'sevent.actor_id.clone_from(...)overwrite (call_to_play.rs:403) disappears; the signature replaces it.CallToPlayStorestoresSignedEventand re-transmitsbodyverbatim in snapshots, so signatures survive relaying. Nothing in the store may re-serialize a body.- Creator authority becomes cryptographic.
HistoryIndexcompares signing keys instead ofactor_idstrings forStart/Cancel/AddTime(T5). call_idbecomes creator-bound:call_id = base32(SHA-256(author_key || create_nonce))[..16]. Receivers verify the derivation forCreateevents and reject aCreatefor an existingcall_idfrom 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
atagainst 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_idstays and must match the derived id. Optionally anadv_sigoverpeer_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_peerstops mutating the peer table. It records a candidate(peer_id, addr, key)and triggers a verified handshake; onlyperform_handshake_with_peer/accept_inbound_hellocreate or rebind peer records (T4).- The mismatch path in
handshake.rs:119-127no 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_namearriving 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 inHello; receivers move the pin and recordrotated_from. A lost key means no continuity — that is a new device, and the UI says so. - Party admission code for public venues:
pskderived from a short human-shareable code;HellocarriesHMAC(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_VERSIONbumps 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-protogainsPeerId,PublicKey,Signature,Signed,SignedEvent,SigContextas dumb data types with no crypto dependency;lanspread-identitydoes 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/Responseare wrapped inSigned; the per-requestpeer_idfields onLibraryDelta,CallToPlayEvents, andGoodbyeare removed.Hello/HelloAckcarrypublic_key, the display name, and (later) rotation statements and admission proof.call_to_play_eventsbecomesVec<SignedEvent>.CallToPlayEvent→CallToPlayEventBodywithoutactor_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.mddisclaimer 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, forgedLibraryDelta, and mDNS-only address rebinding all leave the peer table unchanged. - Call to Play: non-creator
Start/Cancel/AddTimerejected; relayed third-party events verify after a full serialize/deserialize cycle; duplicatecall_idfrom 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
keyringdependency acceptable? It brings zbus/secret-service on Linux. The alternative is file-only storage with0600and an honest UI label, which is what the plan falls back to anyway.- One Ed25519 key for TLS and objects, or split TLS/object keys? Plan recommends one, with the split as the documented fallback.
- 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.
- Party admission code in scope now or later? Plan says Phase 6.