Peer auth plan

This commit is contained in:
2026-07-28 07:41:30 +02:00
parent f608eaa6b1
commit 8d1e1a13c5
2 changed files with 681 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
# Rework peer authentication: keys, signed events, real identity
## Status
Proposal. Supersedes the line in `CALL_TO_PLAY_FIXES_PLAN.md` that said not to
introduce cryptographic peer identities: Call to Play's event/merge design
stays exactly as it is, but the identities it is keyed on become real.
Nothing in this document is implemented yet.
## 1. Where we are today
Everything a peer claims about itself is a string it made up, and every trust
decision in the peer runtime is made on those strings.
- **One shared TLS identity for the entire product.** `config.rs:48-51`
compiles `cert.pem` and `key.pem` from the repository root into the binary.
The QUIC server presents it (`services/server.rs:31`) and the client trusts
exactly it, under the server name `localhost` (`network.rs:70-78`). The
private key is in git. Any machine on the LAN can therefore terminate,
impersonate, and man-in-the-middle any peer connection: the transport
authenticates "some build of Lanspread", not "this peer".
- **`peer_id` is a self-asserted UUID.** `identity.rs:11-26` generates a
UUIDv7 and stores it in `<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 14 are the plan's substance; 5 makes it usable; 67 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.
+98
View File
@@ -0,0 +1,98 @@
• ## Review outcome
The core direction is sound: per-device Ed25519 identity, pinned QUIC responders, signed forwarded events, explicit TOFU semantics, and keeping content trust out of scope.
However, I would not implement from this plan yet. Several security claims do not follow from the proposed mechanics, and the phases are not independently shippable as written.
### Findings
1. Blocker — a signed listener address still is not authenticated.
A signature proves who asserted listen_addr, not that they control it. The plan permits a verified handshake to rebind records (PEER_AUTH_PLAN.md:316), while the current
collision rule evicts the peer already occupying that address (crates/lanspread-peer/src/peer_db.rs:78). An authenticated attacker can therefore claim another peers listener
and still trigger T4. Outbound handshakes should retain the endpoint actually reached under the pinned key; inbound claims need a pinned connect-back or equivalent proof. An
unproven collision must never evict an established identity.
2. Blocker — Phase 2 authenticates only the responder.
Client certificates are omitted and application signatures arrive in Phase 3 (PEER_AUTH_PLAN.md:202), yet Phase 2 lets inbound handshakes mutate peer state. Today
accept_inbound_hello immediately trusts the supplied identity, history, and address (crates/lanspread-peer/src/services/handshake.rs:152). A copied public key in an unsigned
Hello proves no possession. Signed Hello must move into Phase 2, inbound mutation must wait, or Phases 23 must be combined.
Similarly, Phase 3 still lets B forge As relayed Call-to-Play history until signed events arrive in Phase 4. Those phases must ship together or temporarily reject third-
party snapshot authority.
3. Blocker — compacted Call-to-Play tombstones lack creator proof.
The proposed call_id needs create_nonce, but that nonce appears in neither the proposed body nor todays schema (PEER_AUTH_PLAN.md:281, crates/lanspread-proto/src/lib.rs:43).
More fundamentally, current compaction discards Create and retains only Start/Cancel (crates/lanspread-peer/src/call_to_play.rs:340). A fresh peer can verify the terminal
signer but cannot prove that signer created the call. Retain the signed root or carry a verifiable root/creator commitment in the tombstone. The replication contract cannot
remain “exactly unchanged.”
4. Blocker — event IDs and the verification cache remain attacker-controlled.
Event IDs remain globally arbitrary, while the store rejects same-ID/different-body objects atomically (crates/lanspread-peer/src/call_to_play.rs:100). An attacker can reuse
an observed legitimate ID, pre-seed a fresh peer, and make later legitimate snapshots conflict. An ID-only signature cache (PEER_AUTH_PLAN.md:300) can also skip verification
for a different body after compaction. Event identity should be author/content-or-nonce bound; cache entries must bind the exact signed-object digest and have bounded
lifetime.
5. High — the ±10-minute event rule rejects valid history.
Full terminal history lasts 15 minutes and tombstones last for the session, while active or scheduled calls may be older still. The proposed local-clock check
(PEER_AUTH_PLAN.md:303) would make legitimate historical acceptance peer-dependent. Apply future-skew checks at direct publication if desired, but do not expire signatures
merely because an event is relayed later.
6. Blocker — T8/T9 and general resource exhaustion remain open.
Terminal histories and tombstones are exempt from the proposed bounds, so one key can issue unlimited Create+Cancel pairs. Sybil keys and relayed authors defeat per-author
quotas; sixteen suggested 256-event quotas still fill the global 4096 unresolved-event capacity. Add absolute object and byte limits covering unresolved events, terminal
history, tombstones, caches, and distinct authors, plus local-capacity reservation and defined eviction/rejection behavior.
The same threat model also requires connection, stream, signature-verification, transfer, disk-read, and expensive-operation limits. The server currently spawns work per
connection and stream (crates/lanspread-peer/src/services/server.rs:58); self-issued signatures do not make a requester trustworthy. These controls cannot remain optional
Phase 6 hardening.
7. High — replay protection does not establish T7.
A 1024-entry LRU can evict a still-fresh nonce, restart loses the cache, concurrent check-and-insert needs atomicity, and the ±120-second window introduces hard clock trust
despite the non-goal (PEER_AUTH_PLAN.md:249). A delayed legitimate Goodbye can also arrive after a newer handshake and remove the new incarnation. Use session/challenge or
incarnation binding—particularly for Goodbye—or persist all still-valid replay state and narrow the stated guarantee.
8. Blocker — identity storage needs a crash-safe backend state machine.
“Keyring unavailable” cannot mean the same thing as “no key exists”: falling through to a file can fork an established identity. The sidecar-selected backend must become
authoritative, with explicit handling for missing, locked, denied, corrupt, and mismatched states. The secret and identity.json also cannot be atomically committed together;
concurrent starts, crashes, import, reset, and rotation need a process lock, generation-based commit protocol, and reconciliation rules.
The persistence rationale is also inaccurate for the GUI: production passes Tauris app_data_dir, not the core ~/.lanspread fallback (crates/lanspread-tauri-deno-ts/src-
tauri/src/lib.rs:2437). Reinstall survival needs a platform/package matrix. The fixed keyring account additionally describes one identity per OS account, not necessarily one
per installation.
9. Blocker — mandatory expected identity is not threaded through the runtime.
The plan bans address-only connections (PEER_AUTH_PLAN.md:210), but direct connect is still ConnectPeer(SocketAddr) (crates/lanspread-peer/src/lib.rs:263), and downloads,
retries, streamed installs, healing, shutdown, and liveness frequently retain only addresses. Phase 2 needs a first-class PeerEndpoint { peer_id, addr } throughout. Direct
connect must require an expected fingerprint/ID or define an explicit user-confirmed TOFU bootstrap.
10. High — trust and identity lifecycle semantics are incomplete.
Blocking at direct handshake/connection does not block As events relayed by allowed B. The common event-merge boundary needs a block/admission policy, including existing
history and unblock recovery. The trust schema also has only known | blocked, so it cannot represent the promised persistent “new,” reviewed, or acknowledged state; security-
sensitive writes such as block/pin must not be merely debounced.
Phase 1 can already produce fatal identity errors, but usable repair is deferred to Phase 5; today startup failure is only logged (crates/lanspread-tauri-deno-ts/src-tauri/
src/lib.rs:2118). Minimum retry/import/reset/export must accompany storage. Seed export also creates two indistinguishable live devices unless backup versus transfer,
retirement, and stale-copy behavior are defined.
11. High — the cryptographic wire contract is not normative enough.
The signature transcript still contains an ellipsis (PEER_AUTH_PLAN.md:247); it must define every field, order, tag value, integer width/endian, encoding, and protocol-
version binding, plus require recipient-local, expected-context, and response-sender-equals-TLS-pinned-responder checks. Add golden vectors.
Additional concrete corrections:
- A custom rustls verifier must verify TLS 1.3 CertificateVerify, not only inspect SPKI (/home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rustls-0.23.42/src/
verify.rs:69).
- Bytes becomes a JSON numeric array, not base64, without an explicit serializer.
- The proposed crate graph is contradictory: lanspread-identity cannot construct lanspread-proto types while both dependency directions are forbidden
(PEER_AUTH_PLAN.md:370).
- rcgen must disable its default ring feature to preserve the one-crypto-stack claim.
- The P-256 fallback needs a binding visible before accepting the TLS channel; an identity statement delivered only inside that channel is too late.
12. High — adversarial tests and documentation arrive too late.
The hostile harness is described as the trust models regression net but deferred to Phase 7 (PEER_AUTH_PLAN.md:523). Each trust boundary must land with its negative
scenarios and documentation. Per-phase gates should include targeted just peer-cli-tests, the honest-path matrix after major protocol phases, and just build for GUI changes—
not only manual containers, whose recipes can use an old image (justfile:46). Phase 7 should be a final audit, not the first security acceptance pass.
My recommended prerequisite is a revised Phase 0 that settles the endpoint model, exact wire transcripts, storage state machine, resource budgets, crate graph, and TLS spike.
Then Phase 1 can ship storage with recovery; Phase 2 can ship mutually authenticated handshakes and address proof; signed envelopes and independently verifiable relayed events
should ship atomically or behind a safe intermediate restriction.