584 lines
31 KiB
Markdown
584 lines
31 KiB
Markdown
# 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 `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: `<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:
|
||
|
||
```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
|
||
|
||
`<state_dir>/trust/peers.json`, versioned, atomic write, debounced:
|
||
|
||
```json
|
||
{ "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, `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.
|