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