docs(peer): consolidate authentication plan

Replace the earlier peer-authentication proposal with the reviewed,
implementation-oriented design. The plan now records the identity-storage
state machine, pinned TLS and endpoint rules, signed Call-to-Play objects,
download-source authorization, resource limits, safe protocol phases, and
phase-owned acceptance gates.

Remove the standalone review after incorporating its findings and follow-up
adjudication into the authoritative plan, including an explicit closure matrix.
This avoids maintaining two documents with conflicting severity and guidance.

Test Plan:
- `git diff --cached --check` -- passed
- Code tests not run; documentation-only change
This commit is contained in:
2026-08-09 11:15:50 +02:00
parent 8d1e1a13c5
commit a886e64fc7
2 changed files with 1413 additions and 672 deletions
+1413 -574
View File
@@ -1,583 +1,1422 @@
# Rework peer authentication: keys, signed events, real identity
# Peer authentication, identity continuity, and download-source authorization
## 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 |
Architecture approved; implementation plan.
This document incorporates the prior holistic review and subsequent review
dialogue. It replaces the previous version of this file and
supersedes the statement in `CALL_TO_PLAY_FIXES_PLAN.md` that cryptographic
peer identities should not be introduced.
Nothing in this document is implemented merely because it is specified here.
Each phase below has its own implementation and acceptance gate. Phase 1a
scaffolding can proceed once this plan is accepted; persistent identity writes
wait for the identity transition table in §7.2 to be encoded in tests, and the
network runtime does not switch from the legacy UUID to the derived PeerId
until Phase 2's protocol bump.
The project has one current wire version and no compatibility paths. Every
wire-changing phase bumps `PROTOCOL_VERSION`; peers on any other version are
rejected.
## 1. Executive summary
The design has six connected parts:
1. Each Lanspread runtime has a long-lived Ed25519 identity. `PeerId` is
derived from the public key; it is never a caller-supplied UUID.
2. Every outbound connection takes a `PeerEndpoint { peer_id, addr }`. TLS
pins the responder to that identity and performs real TLS 1.3
CertificateVerify validation. An address is never used to discover an
identity after connecting.
3. mDNS and inbound `Hello` messages are candidate hints. They cannot mutate
authenticated peer state. Listener ownership is proven by a bounded pinned
connect-back.
4. Control requests and responses use exact signed envelopes. Independently
relayed Call-to-Play events carry their own signatures, creator commitment,
and content-derived IDs.
5. User-visible continuity, blocking, and permission to download from a device
are separate trust decisions. New devices are not remote byte sources until
explicitly allowed.
6. Transfer-authoritative manifests and bytes come only from allowed,
responder-pinned identities. This is source authorization, not content
integrity: an allowed malicious device can still send hostile same-sized
bytes until catalog-owned hashes or signatures exist.
There is also one independent prerequisite. The current download preparation
path can truncate another game's `local/` data from an attacker-supplied file
description before any peer connection occurs. §6 specifies a standalone fix
that lands before persistent/runtime identity work.
## 2. Current security and correctness facts
- The repository-wide `cert.pem` and `key.pem` authenticate only “some build of
Lanspread.” Their private key is public, so they do not authenticate a peer.
- The current `peer_id` is a self-asserted UUID. mDNS, `Hello`, request bodies,
peer-table rebinding, liveness, and Call-to-Play authority ultimately trust
that string.
- `peer_db` can evict an existing address owner during a claimed collision.
Removing only that eviction branch would corrupt the `peers`/`addr_index`
invariant; the replacement must reject conflicts atomically.
- `handshake.rs` currently records advertised listener addresses rather than
consistently retaining the endpoint actually reached.
- Download, retry, streamed-install, healing, and direct-CLI paths sometimes
retain only an address or fabricate an address-derived peer ID. That is
incompatible with responder pinning.
- A custom rustls `ServerCertVerifier` has two separate duties: inspect and pin
the presented public key, and cryptographically verify TLS 1.3
CertificateVerify. Returning `HandshakeSignatureValid::assertion()` without
the second operation lets an attacker replay another peer's public
certificate with the attacker's private key.
- Call-to-Play snapshots are relayed. Authenticating the relay does not prove
the authorship of third-party events.
- Current terminal compaction intentionally retains session-long tombstones,
but rootless tombstone handling loses the selected terminal's order key and
fresh stores cannot ingest such tombstones correctly. Signed events must fix
that replication state, not claim replication remains untouched.
- The current active-history capacity tests let terminal actions reduce state,
but there is no hard total tombstone/byte bound and no reserve for ordinary
local publication.
- `LengthDelimitedCodec::new()` already has an 8 MiB default maximum; it is not
unbounded. Protocol-specific frame and batch limits still need to be explicit
and lower where the bounded payload permits.
- The GUI uses Tauri's `app_data_dir()`, not necessarily `~/.lanspread`.
Update and uninstall/reinstall persistence therefore depends on the platform
and packaging behavior.
- `prepare_game_storage` currently receives the whole games directory and may
call `create(true).truncate(true)` for a cross-game or protected `local/`
path before a source connection is attempted. This is a live data-loss bug,
independent of peer authentication.
## 3. Goals
1. Give every runtime a stable cryptographic identity whose public ID is
derived, not asserted.
2. Preserve that identity across normal updates and address changes, while
making storage failures and platform-dependent reinstall behavior honest.
3. Authenticate every selected responder and prove possession of its TLS
private key.
4. Remove address-only identity throughout the peer, metadata, download,
retry, streamed-install, healing, liveness, and CLI paths.
5. Attribute every state-changing control message to a verified key.
6. Make stored and forwarded Call-to-Play events independently verifiable.
7. Prevent a blocked author from regaining influence through an allowed relay.
8. Bound peer/authentication and Call-to-Play work without evicting permanent
anti-resurrection state.
9. Default remote download sources to user approval, while leaving public
browsing and outbound serving appropriate for a LAN party.
10. Expose identity continuity, new-key/name conflicts, blocking, source
permission, overload, and repair states clearly in the UI.
11. Land each negative security scenario with the phase that creates its trust
boundary.
## 4. Non-goals and accepted limits
- No PKI, accounts, CA, or internet service. Trust is self-certifying identity,
first-seen continuity, and explicit local policy.
- A key proves continuity, not a human name. “Alice” remains a signed
self-assertion; the UI shows the fingerprint and warns when a familiar name
appears under a new key.
- Authentication does not exclude a hostile guest. A fresh key is a valid new
identity. Default-deny source permission adds a human authorization step but
does not make that device's files trustworthy.
- No complete game-content integrity in this plan. Catalog-owned hashes or
signatures remain separate urgent work.
- No durable, global replay ledger. TLS prevents passive capture, recipient
binding prevents cross-peer reuse, and a bounded in-process nonce set
suppresses duplicates. Operations must still be idempotent or sequenced.
- No receiver-relative age expiry for stored signatures. Historical events and
session tombstones remain valid when relayed later.
- No Byzantine convergence guarantee after adversarial global capacity
exhaustion. Call-to-Play memory and merge work remain bounded; remote
availability and exact convergence may stop until the user
blocks/quarantines authors and resets or restarts party state.
- No comprehensive accept-connection/control-stream/disk-read/serve-operation
scheduler is added here. A hostile LAN host can still exhaust application
tasks below physical link saturation through anonymous public serving in the
intermediate phases. This is an explicit availability risk for separate
server-hardening work, not a guarantee hidden behind T9; the concrete new
amplification surfaces introduced by this plan remain bounded.
- No anonymity or metadata privacy. Peer IDs, keys, names, and library summaries
are public to the local segment by design.
- No promise that uninstall/reinstall preserves a file-backed identity on
every platform. The UI and docs describe the actual package behavior and
recommend an encrypted backup.
## 5. Threat model and resulting guarantees
The attacker is on the same L2 segment, can send arbitrary packets, run a
modified build, create many identities, and knows the repository's current TLS
private key. The attacker does not have code execution or OS-secret access on a
victim.
| # | Attack | Result after the owning phase |
|---|---|---|
| `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 | |
| T1 | Impersonate a selected responder | Requires the responder's private key. During Phase 2, inbound initiators are not authenticated, so their claims cannot mutate state. |
| T2 | MITM an outbound QUIC connection | Fails responder pinning and/or TLS 1.3 CertificateVerify. |
| T3 | Evict a peer remotely | `Goodbye` is removed. Conditional authenticated liveness is the only remote-removal path. |
| T4 | Rebind or collide a listener address | mDNS and advertised addresses are hints. Only a successful pinned dial binds an endpoint; a cross-ID collision is rejected without eviction. |
| T5 | Start, cancel, or extend another creator's call | Creator actions require the `CallRef.creator_key` signature. |
| T6 | Chat or RSVP as another participant | Participant actions require that participant's signature and a valid call root. |
| T7 | Reuse a captured control object | TLS prevents passive capture, local-recipient/context checks prevent cross-peer reuse, request/response correlation and an atomic bounded nonce set suppress duplicates. No durable universal replay guarantee is claimed. |
| T8 | Exhaust Call-to-Play state | One key cannot exceed its count/byte quota and remote traffic cannot consume the local reserve. Sybil keys can fill the bounded remote pool; overload is surfaced and availability/convergence are then explicitly not guaranteed. |
| T9 | Flood candidate/state admission | Candidate, live-peer, trust, nonce, and connect-back work/state are bounded and deduplicated. Generic accept-loop/link saturation remains an explicit availability limit. |
| T10 | Reuse a familiar display name under a new key | The UI retains the old pin, marks the key as new, and shows a name conflict. It does not claim the name is verified. |
| T11 | Serve hostile game bytes | An unapproved identity cannot become a manifest or byte source. An explicitly allowed malicious identity can still serve hostile content; catalog integrity is unsolved. |
| T12 | Steal a local identity | OS secret storage is preferred; file and explicit-file modes are labelled honestly. Local compromise remains out of scope. |
| T13 | Destroy another game's protected files through a manifest | The standalone manifest-confinement fix rejects the complete manifest before any filesystem or `version.ini` transaction mutation. |
`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.
## 6. Prerequisite: confine download preparation before persistent identity work
## 12. Risks
This is a standalone safety fix, not a protocol or cryptography change, and it
lands before Phase 1b persistent identity work; Phase 1a may proceed in
parallel.
- **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.
### 6.1 Validated manifest boundary
## 13. Decisions to confirm before Phase 1
The peer core constructs a `ValidatedDownloadManifest` before
`begin_version_ini_transaction`, `prepare_game_storage`, directory creation,
file open/truncate/resize, or any other filesystem mutation. Storage accepts
only that validated type; neither Tauri nor a remote peer can pass raw
`GameFileDescription` values to it.
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.
For the entire list, validation must:
- require every descriptor's `game_id` to equal the requested game;
- resolve destinations relative to exactly `<games_folder>/<game_id>`, not the
whole games directory;
- at this standalone no-version-bump boundary, leave protocol-7 producer bytes
unchanged and normalize its current platform all-`/` or all-`\\` separator
form exactly once before validation; reject mixed
separators and ambiguous/empty components. Accept and discard only the
current exact redundant root descriptor
`{ relative_path: game_id, is_dir: true, size: 0 }`; reject every other empty
game-relative entry. All absolute, drive-qualified, UNC, NUL, `.`/`..`,
parent, cross-game, and non-normalized results are rejected;
- reject duplicate normalized paths and conflicting file/directory shapes;
- require directory size to be zero and exactly one regular root
`version.ini` where the existing transaction requires it;
- use one shared `is_reserved_game_path` policy in scanning, serving,
preparation, and discard. It rejects `local/`, `.local.*`, `.sync`,
`.lanspread/`, `.lanspread.json`, `.softlan_game_installed`,
`.version.ini.tmp`, `.version.ini.discarded`, and all current
version-transaction temporary/discard paths while allowing the one intended
root `version.ini`;
- on Windows, compare duplicates and reserved names case-insensitively and
reject alternate-data-stream colons, DOS device components, and trailing-dot
or trailing-space aliases before filesystem lookup;
- require the game root to be a direct non-symlink child of the games folder
and avoid following a symlink or reparse component at the destination; and
- enforce explicit descriptor-count, per-file-size, and aggregate-size bounds
chosen from real catalog measurements in Phase 0. Phase 2b also enforces
those bounds on raw remote manifests before retaining them in `peer_db`,
consensus state, or Tauri output.
The UI supplies only `{ game_id }`; this plan does not support partial game
selection. The peer core selects the complete backend-authoritative manifest,
and only a complete successfully written payload may commit `version.ini` or
become installable. A UI-echoed description is never authority. If partial
downloads are introduced later, they need an explicit dependency closure and
must not commit the installable sentinel.
Before committing `version.ini`, transactionally remove or quarantine every
download-owned, nonreserved path under the requested game that is absent from
the complete selected manifest. Preserve `local/` and all reserved transaction
state. This prevents a stale `.eti` from an older, failed, revoked, or
unapproved transfer from being installed or served alongside the newly
approved payload.
### 6.2 Mandatory proof
Tests put sentinel bytes in `OtherGame/local/save.dat` and the requested
game's `local/`, then submit malicious descriptors and prove that no existing
file changes and no new path is created. They also cover a valid first
descriptor followed by an invalid later descriptor, traversal/UNC/drive and
case variants, reserved paths, duplicates, root-shape errors, game-ID mismatch,
and symlink/reparse destinations.
They also seed an unlisted download-owned `stale.eti`, complete an authoritative
download, and prove that it is gone before installation/serving while every
protected `local/` sentinel remains.
The gate is `just fmt`, `just clippy`, `just test`, `just build`, targeted
download/hostile-descriptor peer-CLI scenarios during development, and the
unfiltered `just peer-cli-tests` before completion.
## 7. Normative design
### 7.1 Identity primitives, IDs, and crate graph
- Identity algorithm: Ed25519 using the existing AWS-LC cryptographic stack.
- `PeerId` is lowercase RFC 4648 base32 without padding of the full
`SHA-256(raw_ed25519_public_key)` digest: 52 fixed ASCII characters. It fits a
DNS label and retains 128-bit generic collision strength.
- UI fingerprints group the full ID for copying and show an unambiguous short
prefix only alongside a display name. Protocol policy never compares short
IDs.
- `lanspread-proto` owns dumb wire types and exact transcript builders:
`PeerId`, `PublicKey([u8; 32])`, `Signature([u8; 64])`,
`Nonce([u8; 16])`, `CallNonce([u8; 32])`, signed envelope/event types, and
explicit base64url serde adapters. It has no crypto or storage dependency.
- `lanspread-identity` depends on `lanspread-proto` and owns derivation,
signing/verifying, secret storage, and TLS identity material.
- `lanspread-peer` depends on both. `lanspread-proto` never depends on
`lanspread-identity`; there is no contradictory two-way dependency.
- Key, signature, nonce, and opaque payload JSON fields use
URL-safe base64 without padding and exact decoded lengths. They must not use
`Bytes`' default JSON numeric-array representation.
- Core code constructs all author, call, event, and message IDs. Frontend and
CLI commands express an action against a selected call; they do not supply
trusted identity or event-ID fields.
### 7.2 Identity storage is a state machine, not a fallback chain
#### Explicit modes
CLI flags take precedence over environment variables. Seed and file modes are
mutually exclusive; conflicting settings return a typed configuration error.
| Mode | Rule |
|---|---|
| `--identity-seed` or `LANSPREAD_IDENTITY_SEED` | Require one exact valid seed, never persist it, and do not read or modify default keyring/file/sidecar state. |
| `--identity-file` or `LANSPREAD_IDENTITY_FILE` | The named versioned secret file is the sole authority for that run. Missing, inaccessible, or corrupt means failure; creation requires a separate explicit operation. No fallback. |
| No explicit mode | Use the persistent-store table below. |
The versioned secret record contains at least `{ version, seed, created_at }`.
The reconstructible non-secret `<state_dir>/identity.json` contains
`{ version, peer_id, public_key, created_at, backend }`. Its ID and key are
always recomputed from the secret before use.
Persistent backend observations normalize to:
```text
Present(secret) | NoEntry | Locked | Denied | Unavailable | Corrupt
```
Only `NoEntry` proves absence. An I/O error, locked store, denied access,
unavailable service, or corrupt record never authorizes generation.
#### Persistent transition table
| Sidecar | Backend observations | Required action |
|---|---|---|
| Valid | Selected backend is `Present` and derived key/ID match | Load only that backend; do not probe or switch. |
| Valid | Selected backend is missing, locked, denied, unavailable, corrupt, or mismatched | Stop the peer runtime and enter the corresponding typed repair state. Never fall through or generate. |
| Missing | Exactly one readable secret exists and every other supported backend conclusively reports `NoEntry` | Reconstruct the sidecar from that secret and load it. |
| Missing | More than one secret exists, even if identities match | Enter repair and require an explicit authoritative-backend choice. |
| Missing | Readable secrets derive different identities | `AmbiguousIdentity`; show backend names and public fingerprints, never seed material. |
| Missing | No secret exists and every supported backend conclusively reports `NoEntry` | Fresh install: create in the preferred keyring backend. |
| Missing | Any backend is locked, denied, unavailable, or corrupt | Enter repair; do not generate or fall through. |
| Corrupt | Exactly one readable supported-version secret exists and all others are conclusively absent | Reconstruct the derivative sidecar and load. |
| Corrupt | Otherwise | Enter repair. Even all-`NoEntry` does not silently generate because the sidecar proves prior state existed. |
| Unsupported sidecar or secret version | Any | Return `UnsupportedVersion` without probing beyond what identified the version and without writing anything. An older binary never reconstructs or overwrites newer state. |
On a genuinely fresh Linux system without Secret Service, the GUI may
explicitly offer “Create a file-backed identity.” Headless use chooses a seed
or explicit identity file. A failed keyring write is followed by a read probe;
it never immediately falls through because the write may have partly
succeeded.
The supported default keyring locator—service `network.paul.lanspread`, account
`peer-identity`—means one default identity per OS account. Multi-profile,
container, and simultaneous test identities use explicit files/seeds. If that
scope changes, Phase 0 must first define a deterministic, recoverable locator;
two stores must not silently implement different identity scopes.
#### Locking and durability
- The application identity session acquires an exclusive OS advisory lease
before its first backend probe and holds it through repair, import/reset,
peer-runtime stop/restart, and application exit. The lease exists even while
networking is stopped. File-backed state uses `<state_dir>/identity.lock`;
explicit identity files use an adjacent lease. The fixed OS-account keyring
locator uses one canonical account-wide lease independent of caller state
directory. Default persistent keyring use is supported only through that
canonical application profile; every other/headless profile uses an explicit
seed or identity file.
- Lock contention returns `IdentityBusy`; it never triggers generation.
- Creation/import/reset/backend migration validates input first. A file backend
writes a restrictive unique temporary file, flushes it, atomically renames
it over the selected record, and syncs the containing directory where
supported. A keyring backend uses the platform's atomic record replacement,
then reads it back and verifies the derived key and ID. Platforms that cannot
provide atomic record replacement do not enable in-place keyring
import/reset; they require explicit backend migration or backup/repair.
Initial creation and backend migration write and verify the new secret before
atomically writing the reconstructible sidecar; migration retires the old
backend only after the sidecar commits.
Backend migration additionally uses a small non-secret
`identity.migration.json { version, from, to, target_fingerprint, stage }`
intent, written before touching the target and cleared only after sidecar
switch plus old-backend cleanup. When present, startup ignores the ordinary
valid-sidecar fast path, probes only the named source/target, and idempotently
resumes or explicitly rolls back. Before target write, `to` must be `NoEntry`
or contain the same identity; a different identity is a repair conflict, never
overwritten. This closes both crash windows without a generation-numbered
two-resource commit protocol.
Every intent stage is durably updated by atomic rename. A corrupt or
unsupported intent is a typed non-mutating repair state and is never ignored.
Resume/rollback deletes a backend record only after rereading it and matching
the intent fingerprint.
- A crash after secret write and before sidecar write is recoverable by the
missing-sidecar table. A replacement crash can yield old-sidecar/new-secret
mismatch; startup stops and offers explicit completion or backup restore.
No generation-numbered two-resource transaction is required.
- Same-backend keyring replacement may leave either old or new secret after a
crash; a sidecar mismatch is an explicit recoverable state, never a reason to
generate or fall back. The UI requires a verified encrypted backup before a
destructive in-place replacement. File replacement and backend migration do
not retire the old selected secret before the replacement is safely exposed.
- Phase 1b leaves the legacy UUID `peer_id` as the protocol-7 runtime/advertised
ID even after the cryptographic identity is durable. Phase 2's protocol bump
atomically activates the derived PeerId for networking and only then deletes
the UUID. It is never cryptographic continuity evidence.
The GUI uses Tauri `app_data_dir()`. `~/.lanspread` is only the core default
when no state directory is supplied. Docs provide an update/uninstall matrix
per package rather than promising universal reinstall survival.
#### Repair, backup, import, and reset
Phase 1 ships a typed repair surface, not a log-only startup failure: retry and
unlock guidance, import backup, choose an unambiguous discovered backend,
deliberate file-backed creation on a genuinely fresh system, and deliberate
reset. The shell may start, but networking remains stopped until repair
succeeds.
Export emits only a versioned authenticated passphrase-encrypted seed envelope.
Phase 0 freezes its magic/version, exact KDF and cost/salt encoding, exact AEAD
and nonce/tag encoding, authenticated header, and maximum decoded size before
Phase 1b implements it. Raw seed material never reaches frontend state, logs,
events, or errors. The UI calls this a **backup** and warns that importing it
creates two cryptographically indistinguishable devices. “Move” means verify
the destination fingerprint and deliberately retire/reset the source; stale
copies cannot be revoked without later key rotation. Reset shows old/new
fingerprints and explains that all remote continuity records are invalidated.
Import over an existing identity requires old/new fingerprint confirmation and
replaces the selected backend unless the user separately chooses backend
migration. Reset/import fails if that backend cannot be replaced or retired;
it never creates a fallback identity. Identity replacement first stops
networking, commits under the still-held application lease, rebuilds all
runtime identity/TLS state, and only then restarts networking.
### 7.3 Trust state and source permission
Trust is keyed by verified full `PeerId` and public key, never by display name
or address. A record contains at least:
```text
public_key
first_seen, last_seen
observed_names, pinned_name
acknowledged_at // null means persistently “new”
blocked // overrides every other policy
download_from // prompt | allowed | denied
rotated_from // later continuity feature
```
- First contact stores `download_from = prompt`, which is not eligible to
source a transfer. Only an explicit user action sets `allowed`.
- The UI wording is “Allow downloads from this device,” never “trust this
device/content.”
- A familiar name under a new key creates a separate new record and a conflict
warning. It never inherits acknowledgement, block, or source permission.
- Setting `blocked` atomically changes `download_from` to `denied`, suppresses
candidate retries, rejects direct authenticated protocol actions, and rejects
independently signed objects from that author even when relayed by an
allowed peer. Existing signed objects are quarantined inside the same bounded
CTP store, remain charged to the author/global quotas, are excluded from
reduction and relay, and preserve tombstone evidence. Unblocking leaves
source permission denied and triggers an authenticated resync before
quarantined state is reconsidered.
- Block, pin/acknowledgement, source permission, import, and reset are written
atomically and durably before success is reported. Only observational data
such as `last_seen` and name telemetry may be debounced.
- Final-state serving remains open to authenticated, nonblocked peers; party
admission can narrow that later. During Phases 23, inbound read-only
requesters are deliberately anonymous and serving remains public even to a
caller that might hold a blocked key. Direct requester blocking becomes
enforceable at the Phase 4 envelope boundary. This intermediate limitation
affects what others may pull from us, not local state mutation or which
devices we download from. Local/catalog-matching already-downloaded data
remains usable without any remote-source approval.
Hard trust/admission bounds prevent sequential Sybil growth: at most 1024
persistent trust records, eight observed names per record, and 256
unacknowledged relay/direct-new observations. A directly verified identity or
relay-only author does not create durable trust state when the relevant bound
is full; acknowledged, blocked, or explicitly source-authorized records are
never silently evicted. Relay-only authors remain bounded ephemeral metadata
unless the user acknowledges or blocks them or direct verified contact occurs.
The UI reports capacity and lets the user remove records deliberately.
### 7.4 First-class endpoints and discovery candidates
```rust
struct PeerEndpoint {
peer_id: PeerId,
addr: SocketAddr,
}
```
The endpoint is captured at discovery/source selection and carried through
handshake, library/manifest fetch, ordinary downloads, chunk plans, retries,
streamed install, healing, liveness, and direct peer-CLI operations.
Every outbound connection requires it or equivalent mandatory `(expected_id,
addr)` arguments.
Delete `remote_peer::ensure_peer_id_for_addr`, fabricated `addr-*` IDs,
unique-IP identity fallbacks, and address-only direct connect. The peer-CLI
`ConnectPeer` operation requires both full ID and address or is removed; there
is no desktop TOFU bootstrap UI to design for that test-only command.
mDNS owns a bounded candidate directory separate from authenticated peer
records. Initial values are 256 candidates, 128 simultaneously verified live
peers, 120 new candidate insertions/minute globally, and 8/minute per
advertised target IP (the address exposed by the current mDNS wrapper, not an
authenticated packet source). Phase 0 confirms those values and candidate TTL/backoff against the honest
three-peer harness. At capacity, expired candidates are removed first; a new
candidate/live peer is otherwise rejected without evicting an authenticated
record. It may advertise `(peer_id, addr, public_key, revision hints)`, but a
key/ID consistency check does not prove possession or address control. mDNS
addition, expiry, or change never directly upserts, rebinds, removes, merges a
library, or refreshes authenticated liveness. A blocked ID is not retried.
Candidate retries are deduplicated with jittered backoff while the
advertisement remains live. Reconciliation cannot depend on mDNS emitting a
second identical `ServiceResolved`; a still-live candidate can re-establish a
peer after transient failure or liveness removal.
One shared socket predicate applies to mDNS, Hello, and Ack candidates: port is
nonzero; address is neither unspecified, multicast, nor IPv4 broadcast;
loopback is accepted only in explicit test mode; and IPv6 link-local addresses
carry the observed interface scope. Global IPv6 and private/ULA addresses are
not rejected merely because they are not syntactically “private”; interface
provenance and the bounded dialing policy define LAN reachability.
### 7.5 Authenticated QUIC responder
Use the s2n-quic rustls provider with TLS 1.3 only.
Phase 0 spikes RFC 7250 raw public keys end to end through
`s2n-quic-rustls`. If RPK works, use the identity public key directly. Otherwise
use a self-issued Ed25519 X.509 leaf whose SPKI is exactly the identity key.
CA, CN, SAN, hostname, and self-signature are not authentication inputs; the
expected `PeerId` is.
The custom client verifier captures the expected `PeerId` from
`PeerEndpoint` and MUST:
1. require the selected RPK/SPKI shape and allowed signature scheme;
2. extract the raw identity key;
3. derive its full `PeerId` and compare it to the expected value; and
4. cryptographically verify TLS 1.3 CertificateVerify against that presented
key using rustls/webpki's verification helper.
It may return `HandshakeSignatureValid::assertion()` only after step 4
succeeds. It must advertise only schemes it actually verifies. The load-bearing
negative test presents peer A's certificate/SPKI with peer B's private key and
requires the handshake to fail.
RPK mode requires `requires_raw_public_keys() == true`, a raw-key server
resolver (`AlwaysResolvesServerRawPublicKeys` or equivalent), and
`verify_tls13_signature_with_raw_key`. X.509 mode uses the certificate-specific
signature helper. The selected mode and mismatched-mode failures are tested.
Disable TLS resumption/session tickets and QUIC 0-RTT/early application data.
Every connection performs current proof of possession; the in-memory nonce
cache is not durable replay protection. SNI remains
`<expected_peer_id>.lanspread`, but the verifier's captured full expected ID is
authoritative.
Client and server advertise exactly one version-bound ALPN:
`b"lanspread-peer/" || ASCII_decimal(PROTOCOL_VERSION)`. Any absent or different
ALPN fails the handshake. TLS 1.2 is disabled and
`verify_tls12_signature` always rejects. In RPK mode the transmitted key is the
canonical RFC 7250 Ed25519 DER SubjectPublicKeyInfo, while `PeerId` derivation
still hashes the extracted raw 32-byte key.
Keep P-256 TLS as a last fallback until the RPK/Ed25519 certificate spike
succeeds. A split P-256/Ed25519 design is allowed only if the verifier receives
and validates a required critical certificate extension before channel
acceptance. The Ed25519 signature input is exactly:
```text
b"lanspread/tls-key-binding/v1\0"
|| u32_be(PROTOCOL_VERSION)
|| identity_key[32]
|| u32_be(tls_spki_der_length)
|| tls_spki_der[tls_spki_der_length]
```
The verifier derives and matches the expected identity, verifies this binding,
then verifies CertificateVerify under the P-256 SPKI. A missing/tampered
extension or statement delivered later inside `Hello` is rejected. If this
pre-channel binding is not practical, the fallback is forbidden.
If X.509 is used, configure
`rcgen = { default-features = false, features = ["aws_lc_rs"] }` (plus `pem`
only if actually needed) so the plan does not silently add `ring` as a second
crypto stack.
### 7.6 Outbound binding, inbound Hello, and connect-back
A successful outgoing handshake records the endpoint actually dialled under
the pinned key. `HelloAck.listen_addr` and every other advertised address are
new candidates only; they never redirect the successful binding.
Phase 2 authenticates the responder, not an incoming client. Therefore an
inbound `Hello` is non-authoritative:
- protocol 8 `Hello` is candidate-only: current protocol, claimed ID/key,
listener, bounded revision/feature hints, and
`request_connect_back: bool`; it carries no library or CTP vector;
- perform only bounded cheap syntax/version checks;
- it may receive the responder-authenticated Ack and read-only public data
allowed by policy;
- do not upsert/remove/rebind a peer, merge its library or Call-to-Play state,
create trust continuity, or refresh listener reachability; and
- enqueue at most one bounded, deduplicated connect-back to the claimed
`PeerEndpoint` when `request_connect_back` is true.
Discovery's first probe sets `request_connect_back = true`. A connect-back or
ordinary pinned resync sets it to false and is terminal: its responder returns
the state-bearing Ack but never schedules another callback. Because inbound
Hello is non-authoritative in both modes, an attacker setting false gains no
state-mutation shortcut. Each side learns the other only from its own outbound
pinned connection, so the initial probe plus one callback establishes both
directions without recursion.
The connect-back requires the claimed ID as the TLS-pinned responder. Only its
authenticated response may merge state and bind the endpoint that was actually
dialled. A claimed target must be a nonzero unicast LAN socket whose IP equals
the inbound QUIC source IP. mDNS candidates use their separate bounded dialing
path; a forged advertisement is not callback authorization. Permit at most one
in-flight callback per endpoint, two per source IP, and 16 globally, with the
candidate admission rates above, deduplication, expiry, and jittered backoff.
These controls bound rather than eliminate LAN scan/reflection and
work-amplification risk. Hello, mDNS, and legacy library hints all feed one
coalescing scheduler with these per-source/advertised-target, per-PeerId, and
global budgets; blocked IDs are dropped before scheduling. A legacy library
hint can target only an already verified recorded `PeerEndpoint`; its claimed
address and delta body are ignored, and an unknown claimed ID is dropped.
Before independently signed events land, no client-originated state-changing
object is authoritative. A legacy library notification must trigger the
bounded pinned resync so post-start honest library updates still propagate;
legacy CTP networking is disabled for protocol 8. During
Phase 3, only independently verified signed Call-to-Play objects may mutate
their own store through an otherwise unauthenticated outer request; outer
sender/address claims remain non-authoritative. All other client-originated
state changes wait for Phase 4 envelopes.
Peer binding is a typed atomic operation. If an address is already bound to a
different ID, reject the candidate and preserve both maps and the established
peer. A same-ID move is accepted only after pinning the new endpoint and
establishing that the old record is no longer current/reachable, then both
indexes change atomically. If old and new endpoints simultaneously prove
possession of the same key, preserve the established binding, quarantine the
candidate, and surface a duplicate-identity/clone conflict instead of flapping
between them. An unproven collision never evicts anyone.
### 7.7 Liveness and removal
Remove `Goodbye` from the protocol, handlers, shutdown flow, threat claims,
and tests. Authenticated liveness expiry is the sole remote-removal mechanism.
Liveness pings take `PeerEndpoint` and pin the responder. Successful outbound
authenticated contact proves endpoint reachability. An inbound signed frame
may prove identity activity after envelopes land, but it does not prove that
the advertised listener is reachable and does not refresh that endpoint's
reachability timeout.
Every liveness task snapshots the peer-record revision together with the ID,
address, and reachability timestamp. The revision increments on every
successful endpoint-reachability refresh and every rebind, including activity
while an older probe is pending. A late success or failure may update or remove
only if the current record still has that revision, endpoint, and observed
reachability timestamp.
`remove_peer_if_current` or equivalent compare-and-remove logic prevents a
stale probe from deleting a rebound/reconnected peer or cancelling its active
downloads.
The same conditional comparison applies to asynchronous ping failures and the
periodic stale-prune sweep. Phase 2 removes pre-dispatch
`note_peer_activity`/address-based attribution entirely.
### 7.8 Exact signed control envelopes
Every control `Request` and `Response` uses:
```text
SignedEnvelope {
protocol_version: u32,
sender: PeerId,
sender_key: PublicKey,
recipient: PeerId,
nonce: Nonce,
context: request | response,
payload: opaque bytes,
signature: Signature,
}
```
All fields are required; unknown fields are rejected. The inner request or
response is serialized once with `serde_json::to_vec`, stored as explicit
base64url payload bytes, signed, and verified before deserialization. The outer
JSON is not canonical and is not itself signed.
The exact signature input is:
```text
b"lanspread/control-envelope/v1\0"
|| u32_be(protocol_version)
|| context_tag // request = 0x01, response = 0x02
|| sender_ascii[52]
|| sender_key[32]
|| recipient_ascii[52]
|| nonce[16]
|| u32_be(payload_length)
|| payload[payload_length]
```
Ed25519 signs these bytes directly. Receivers require the current
`PROTOCOL_VERSION`, exact fixed lengths, expected context/direction,
`recipient == local_peer_id`, `derive_peer_id(sender_key) == sender`, and a
valid signature before parsing the payload or dispatching a handler. A client
also requires `Response.sender` to equal the TLS-pinned responder. A response
sets its recipient to the verified request sender, copies the verified request
nonce, and the initiator requires equality, binding the pair.
Boundary verification yields `VerifiedSender`; handlers no longer accept or
trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never
forwarded.
The nonce table is per sender/context, bounded, checked-and-inserted atomically
only after signature verification, and in memory only. Cache loss or eviction
can cause a duplicate to be processed, so state-changing handlers remain
idempotent or revision/event-ID guarded. There is no wall-clock signature
expiry and no claim of durable replay prevention.
Every request initiator generates a fresh 16-byte CSPRNG nonce and never
intentionally reuses it. The duplicate cache is capped at 64 entries per
sender/context, 256 represented senders, and 8192 entries globally; admission
of a new sender/cache entry is rejected when the applicable bound cannot be
met after normal LRU expiry. A response copies rather than generates the
request nonce.
Bulk responder-to-initiator chunk and stream-install frames remain unsigned
inside the responder-pinned TLS channel. That deliberate asymmetry preserves
throughput. Every retry still uses the selected `PeerEndpoint`; TLS must never
fall back to “whoever is at this address.”
The length-delimited control codec gets a named maximum below its current 8
MiB default after Phase 0 measures the largest bounded library/manifest
message. Any bounded payload that cannot fit must be paginated rather than
raising the frame indefinitely. The encoded outer-frame cap is enforced before
frame allocation; a bounded base64 visitor checks computed decoded length
before allocating the decoded payload. Both happen before signature work and
inner deserialization.
### 7.9 Independently signed Call-to-Play events
Call-to-Play objects land before general signed envelopes so relayed authorship
is independently safe as early as possible.
```text
SignedCallToPlayEvent {
protocol_version: u32,
event_id: EventId,
author: PeerId,
author_key: PublicKey,
body: opaque CallToPlayEventBody bytes,
signature: Signature,
}
CallToPlayEventBody {
call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce },
actor_name,
at,
action,
}
```
The body excludes `actor_id`, `call_id`, `event_id`, and independent chat
`message_id`. The author is derived from `author_key`; the redundant outer
`author` must match. Core publication creates the 32-byte CSPRNG nonce for
`Create`, resolves the retained `CallRef` for later actions, chooses identity
and IDs, and signs. The frontend never supplies those authority fields.
The exact event-signature transcript is:
```text
b"lanspread/call-to-play-event/v1\0"
|| u32_be(protocol_version)
|| author_ascii[52]
|| author_key[32]
|| u32_be(body_length)
|| body[body_length]
```
The body encoding is UTF-8 RFC 8259 JSON with the exact field/action names in
the versioned proto schema, integer timestamps only, explicit URL-safe
unpadded-base64 adapters for `PublicKey` and the 32-byte `CallNonce`, and no
unknown or duplicate fields or trailing non-whitespace data. The local emitter
uses compact `serde_json` with declared struct-field order. Verification and
forwarding always use the exact received bytes and never reconstruct them; the
store retains body and signature verbatim. Fixed vectors freeze both the local
encoding and exact-byte verification behavior.
Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32:
```text
call_id = H("lanspread/ctp-call/v1\0" || creator_key || create_nonce)
event_id = H("lanspread/ctp-event-id/v1\0" || u32_be(protocol_version)
|| author_key || u32_be(body_length) || body)
```
The receiver first requires `event.protocol_version == PROTOCOL_VERSION`, then
recomputes both IDs and verifies the signature before parsing/admission. The
event signature covers the transcript above. The content-derived event ID makes adversarial
same-ID/different-body conflicts unreachable; the enclosing `event_id` is also
the chat/UI message identity.
Authority rules:
- `Create`, `Start`, `Cancel`, and `AddTime` require
`author_key == call_ref.creator_key`.
- `Respond`/RSVP, `Leave`, and `SendMessage` may be signed by a participant but
require a retained or same-batch valid `Create` for the same `CallRef`.
- A rooted nonterminal action, including `AddTime`, has reducer effect only
when it sorts strictly after canonical `Create` by `(at, event_id)`. A
correctly signed candidate with some valid root but currently before the
canonical Create remains retained/inactive within quotas so a later earlier
canonical Create can trigger deterministic recomputation. Only a
creator-signed `Start`/`Cancel` has root-independent burn semantics.
- A rootless `AddTime` or participant event is `NeedHistory`, not
independently applicable.
- A valid creator-signed rootless `Start` or `Cancel` is an independently
verifiable irrevocable burn certificate for that `CallRef`.
- Names remain signed self-assertions and never grant authority.
For a creator that equivocates, canonical `Create` is the minimum
`(at, event_id)`. Among all rooted or rootless `Start`/`Cancel` certificates,
the canonical terminal is also the minimum `(at, event_id)`. Rootless state
retains that exact winning terminal and order key, not only a set of call IDs.
An earlier valid terminal arriving later atomically replaces a later tombstone;
losers and all later nonterminal/root data are obsolete. This selection is
arrival- and grouping-independent. A stale `Create` can never resurrect a
burned `CallRef`.
Compaction retains the current rooted display history for 15 minutes, then the
single canonical terminal. Fresh peers accept and relay that self-proving
tombstone. This deliberately changes and repairs the current replication
contract. A rootless tombstone is internal anti-resurrection state and is not
rendered as a nomination because it lacks the root's game/deadline data. Later
valid rooted history may hydrate terminal display but never reopen the call.
The verification cache is a bounded LRU keyed by a digest of the exact signed
object, including context, author key, body, and signature. A hit may reuse only
the canonical object already verified; it must never bless an incoming
alternate signature merely because an event ID matches. Eviction changes
performance only.
Do not reject relayed events because `now - at` is large. Every timestamp,
deadline, and `ready_at` is an integer in
`1..=8_640_000_000_000_000` milliseconds (the positive ECMAScript Date range,
also exactly representable as an integer in JavaScript), and all arithmetic is
checked for overflow. Replicated validation uses only these deterministic
signed-field rules:
- every event `at` is positive;
- `Create.deadline > Create.at` and
`Create.deadline - Create.at <= 3 days`;
- `Create.scheduled_for` is absent or exactly equals `Create.deadline`;
- `AddTime.deadline` is strictly greater than the event's `at`; it has reducer
effect only when it also exceeds the effective deadline immediately before
it in the canonical fold; and
`AddTime.deadline - canonical_Create.at <= 3 days`; and
- `Respond.ready_at` is absent or lies in the inclusive interval
`[Respond.at, canonical_Create.at + 3 days]`.
Local direct publication may apply a one-sided future-skew check before
signing; receivers do not make signature validity depend on their current
clock.
To compute extensions, sort all otherwise valid `AddTime` events after the
canonical Create by bytewise-ASCII `(at, event_id)`, fold from
`Create.deadline`, and apply only strict deadline increases satisfying the
rules above. Retain structurally valid rooted candidates within quotas even
when currently ineffective. Rebuild that fold and every dependent-action
classification over the retained union whenever an earlier Create/extension
arrives. Missing-root objects remain `NeedHistory`; normal expiry or canonical
terminal compaction eventually removes inactive candidates. Backend and
frontend use the same numeric
timestamp and bytewise lowercase-base32 ID comparator; the frontend must not
use locale-dependent collation.
#### Call-to-Play limits and merge semantics
Initial hard limits, adjusted only through a measured Phase 0 decision:
| Limit | Value |
|---|---:|
| Exact encoded signed event | 4 KiB |
| Total stored events | 4096 |
| Total exact encoded stored bytes | 4 MiB |
| Per author, including local | 256 events and 256 KiB |
| Remote-author identities represented | 256 |
| Reserved inside global limits for local author | 256 events and 256 KiB |
| Verification cache | 4096 exact-object entries |
| Incoming CTP vector before signature work | 4096 events and 4 MiB |
All count and byte limits are conjunctive and include active events, rooted
terminal history, rootless tombstones, and quarantined blocked objects. Quotas
are charged to the signed author, not the transport relay; a locally authored
object relayed back still counts as local. Tombstones are never evicted for any
admission.
The remote pool stops at 3840 events and 4 MiB minus 256 KiB, preserving the
local reserve; the local author still has the same 256-event/256-KiB author cap.
The 256 represented-remote-author limit counts identities with retained remote
objects, not an ever-seen set. A creator terminal
may settle an already-retained rooted call at capacity if post-reduction
compaction brings usage within hard limits; it may also replace an existing
tombstone with the canonical earlier winner only when the replacement also
fits the byte cap. A new rootless terminal consumes normal global/per-author
capacity—Create+Cancel spam cannot bypass quotas. Under overload, rooted
display retention may compact immediately to the canonical terminal to
preserve the state-reducing action and hard bound.
Merge processing is:
1. enforce frame, vector, object-size, and structural bounds before signatures;
2. independently authenticate and schema/block-classify each object;
3. compute canonical/state-reducing replacements and a dependency-closed
candidate set, allowing a same-batch `Create`
regardless of vector order and the explicit rootless-terminal exception;
4. reduce and pressure-compact on a clone, then apply per-author and represented
author limits to capacity-increasing groups without letting one author
poison another;
5. after every author/represented-author/capacity filter, recompute dependency
closure to a fixed point (or admit a whole dependency component), so a
rejected Create cannot leave an admitted dependent participant action;
6. commit state reducers first. Admit the remaining capacity-increasing groups
only if they collectively fit global count/byte/remote-pool limits;
otherwise admit none of those groups (never an arrival-order prefix); and
7. canonicalize and swap the store atomically once.
Atomic commit remains a feature; before the explicit collective-global-
exhaustion condition, one hostile event does not reject every unrelated
author. After Sybil exhaustion, emit a persistent typed warning that
history is full and updates may be incomplete. Do not accuse the transport
relay. Recovery is block/quarantine followed by deliberate party-state reset or
restart and authenticated resync. Deterministic over-quota quarantine is later
hardening.
Responder-authenticated transport never confers third-party authorship.
Protocol 8 takes the simplest safe option: it disables all network
Call-to-Play ingestion/synchronization while leaving local UI state available.
Phase 3 restores network propagation with signed objects. A paginated Phase 3
snapshot uses a random snapshot ID, page index/count, declared total
event/decoded-byte counts, and full-snapshot digest; pages are assembled only
within one pinned handshake/session under the 4096-event/4-MiB cap and merged
once. Missing, duplicate, mixed-ID, digest-mismatched, expired, or over-limit
assemblies are discarded. Independently signed event notifications may be
merged directly. Neither an inner event author nor an unauthenticated outer
relay refreshes listener liveness; after Phase 4 only the verified envelope
sender may record identity activity, never endpoint reachability.
### 7.10 Download-source authorization
Browse/library summaries from nonblocked `prompt` or `denied` peers may remain
visible. They are not transfer authority.
At transfer start, the peer core—not Tauri—builds the authoritative manifest
from direct responses received over responder-pinned connections from
`download_from = allowed` identities. Every accepted tuple
`(game_id, canonical path, is_dir, size)` has exact attestation from at least
one allowed identity. Transfer consensus excludes unapproved peers; an
unapproved vote cannot introduce or select a descriptor that causes filesystem
mutation.
For ordinary downloads, the validated manifest maps each exact descriptor to
the allowed `PeerEndpoint`s that attested it. The plan, progress state, and
retry paths preserve that provenance. A retry chooses only an allowed identity
that advertised that exact descriptor. It never falls back from a file-specific
source set to a global address list. An address may refresh only by resolving
and pinning the same selected `PeerId`.
Streamed install has a distinct boundary: ordinary metadata describes root
archives, while `FileBegin` dynamically introduces extracted paths. A streamed
source is eligible only if it is allowed, TLS-pinned, and attested the complete
selected root-archive manifest—not merely one consensus file. Its extracted
frames stay inside isolated staging and are checked with §6's canonical,
reserved-path, symlink/reparse, count, and byte rules before each staged create.
There is no per-extracted-file fallback. Retrying the stream restarts it from an
allowed identity that attested the same complete archive manifest. A future
pre-attested extracted-manifest preamble would be a separate wire change, not
an assumption in this phase. No staged path is promoted until the terminal
`Complete` frame validates the observed entry count, aggregate bytes, archive
set, and stream result; failure discards the entire staging transaction.
Validate the complete authoritative/selected manifest using §6 before
`begin_version_ini_transaction` or any preparation. Then and only then may
storage mutate the requested game root.
Changing `allowed` to `denied`, or blocking a peer, durably changes policy
before success is reported and cancels the entire normal or streamed transfer
if it uses that source. Remove it from retries, restore/leave the installation
sentinel in the existing incomplete state, invoke the ordinary-download
discard/cleanup path for partial peer-owned payload, and cleanly discard
streamed staging; a new user-initiated attempt may replan from remaining
eligible sources. User-owned `local/` remains untouched.
This boundary stops an unapproved peer from supplying transfer metadata or
bytes. It does not verify bytes from an approved peer.
## 8. Wire and protocol evolution
- Transport identity and endpoint semantics: protocol 8.
- Independently signed Call-to-Play objects: protocol 9.
- Signed control envelopes: protocol 10.
- Optional party admission/rotation: a later bump only if implemented.
Exact numeric versions are rebased if the repository's current version changes
before implementation; the ordering and no-compatibility policy are fixed.
`Request`/`Response` become inner payloads of `SignedEnvelope`; identity fields
that duplicate the verified sender are removed. `Goodbye` is deleted. Protocol
8/9 transitional `Hello` carries a claimed identity only to request the pinned
connect-back. Transitional `HelloAck.peer_id/public_key` MUST exactly equal the
TLS-pinned identity or the response is rejected; binding and library merge are
always keyed from the pin, never those redundant fields.
Protocol 10 removes those duplicate inner sender ID/key fields and uses the
verified envelope sender (plus the TLS pin for responses) as the sole identity
authority. Advertised listener addresses remain candidates requiring proof.
Call-to-Play snapshots carry `SignedCallToPlayEvent` and use the bounded atomic
snapshot assembly in §7.9.
Protocol 9's `CallToPlayEvents` request contains only signed event objects or
snapshot-page data; it removes the legacy relay/sender `peer_id` field. The
outer transport origin grants no event authority. Remaining per-request sender
fields disappear with all control envelopes in protocol 10.
Protocol 8 standardizes newly produced manifest paths to `/`-separated
canonical form. Because that is inside the version bump, protocol-8 receivers
need no legacy producer fallback; the standalone protocol-7 validator's
one-time normalization exists only until the cutover.
| Version | Inbound control dispatch |
|---|---|
| 8 | `Hello` is candidate-only; Ping/browse/metadata/transfer reads are anonymous/public; LibraryDelta is a coalesced pinned-resync hint; network CTP is disabled; no inbound activity attribution; no `Goodbye`. |
| 9 | Version 8 behavior, except independently signed CTP objects/snapshot assemblies may mutate only the CTP store after per-object verification. |
| 10 | Every control request/response requires a verified envelope; block policy runs before inner dispatch. Bulk chunk/stream frames remain unsigned only inside the responder-pinned TLS connection created by that verified request. |
## 9. Code ownership map
New `crates/lanspread-identity`:
| Area | Responsibility |
|---|---|
| `key` | Ed25519 secret/public key, full PeerId derivation, zeroizing secret record |
| `store` | Explicit modes, normalized backend results, transition table, lifetime lease, keyring/file storage, sidecar, repair API |
| `sign` | Exact transcript signing and verification, verified sender/event construction |
| `tls` | RPK/X.509 material and pinned rustls client/server configuration |
`lanspread-proto` owns wire-only fixed types, explicit serializers, transcript
builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data.
Principal `lanspread-peer` changes:
| Area | Required change |
|---|---|
| `identity.rs`, startup | Durable derived identity and typed repair; no silent UUID regeneration |
| `config.rs`, TLS assets | Remove shared `CERT_PEM`/`KEY_PEM` and repository key material after Phase 2 |
| `network.rs`, remote peer helpers | Mandatory expected endpoint; remove address-derived identity |
| `services/server.rs` | Per-peer rustls TLS server and bounded candidate/connect-back work; generic accept-loop DoS remains an accepted limit |
| `services/discovery.rs` | Candidate directory/retry only; no authenticated-state mutation |
| `services/handshake.rs` | Non-authoritative inbound Hello; pinned callback; record dialled endpoint |
| `services/stream.rs` | Envelope verification boundary and `VerifiedSender`; no payload-trusted IDs or `Goodbye` |
| `services/liveness.rs` | Pinned endpoint probes and revision-conditional update/removal |
| `peer_db.rs` | Atomic verified bind/collision rejection; first-class endpoint/index invariants |
| metadata/download/stream install | Allowed-source manifest provenance through selection, storage, retries, and revocation |
| `call_to_play.rs` | Signed event store, CallRef authority, canonical rootless terminals, exact cache, count/byte/reserve policy |
| errors/events | Typed auth, identity-repair, source-denied, and overload states reaching the UI |
The peer CLI gains deterministic explicit identities, full endpoint input, and
hostile modes. Tauri gains minimal Phase 1 repair and Phase 2 source-approval
surfaces before the complete trust UI.
## 10. UI and operational semantics
- **Identity repair (Phase 1):** typed failure, retry/unlock, import backup,
explicit backend selection where safe, deliberate file-backed fresh create,
and deliberate reset. Networking stays stopped during repair.
- **Source approval (with enforcement):** show full/short fingerprint and
“Allow downloads from this device.” New devices default to prompt. The UI
must not call this content trust.
- **Peer identity:** display name + short fingerprint, persistent New marker
until acknowledged, and a prominent same-name/new-key conflict.
- **Blocking:** immediate durable block/unblock, direct and relayed enforcement,
visible recomputation, and resync on unblock. Unblock does not restore source
permission automatically.
- **Identity settings:** full copyable fingerprint, honest backend label,
backup/import/reset consequences, and platform-specific persistence wording.
- **Call-to-Play overload:** persistent typed “history limit reached; updates
may be incomplete” warning, signed offending authors when known, and reset /
restart recovery guidance.
- **Diagnostics:** rejected auth/source objects surface typed reason codes and
fingerprints, never secret material or misleading generic network errors.
## 11. Implementation phases and acceptance gates
Every code phase runs `just fmt`, `just clippy`, and `just test`. UI/Tauri
phases also run `just frontend-test` and `just build`. Every protocol/peer phase
runs targeted `just peer-cli-tests` during development and the unfiltered suite
before phase completion. Wire phases run the honest alpha/bravo/charlie matrix
using a freshly built image.
Fix the `justfile` stale-image hazard first: manual `peer-cli-run`, alpha,
bravo, and charlie recipes must depend on a readiness target that builds the
current `peer-cli-image` and network. A manual run against an old image is not
acceptance evidence.
### Phase 0 — freeze contracts and complete spikes
Record in the repository:
- the identity transition table and backend scope;
- exact backup-envelope KDF/AEAD/header parameters;
- `PeerEndpoint`, socket-admissibility, candidate/verified-record, clone, and
liveness-generation invariants;
- candidate TTL/backoff and all candidate/live-peer/connect-back/trust/nonce
capacity and rate values;
- trust/source-permission schema and block transitions;
- exact envelope and event transcripts;
- the protocol 8/9/10 dispatch matrix (protocol 8 no network CTP or
client-authoritative controls; protocol 9 self-signed CTP only; protocol 10
envelope-authenticated controls);
- Call-to-Play count/byte/reserve values and accepted exhaustion behavior;
- manifest count/size bounds, Windows/path policy, and control/snapshot
frame/pagination decision;
- maximum-size history/manifest and honest download/handshake workloads plus
objective peak-memory, latency, and throughput-regression thresholds for the
final performance gate;
- the dependency rule: `lanspread-identity` depends on `lanspread-proto`, never
the reverse; and
- the completed RFC 7250 RPK / Ed25519 X.509 spike result, disabled
resumption/0-RTT configuration, and exact legal fallback if one is needed.
Phase 1a may run in parallel. Its identity primitives need only the frozen
identity/transcript slice; persistent writes and runtime identity replacement
wait for the storage table, while unrelated TLS/manifest measurements may
finish concurrently.
### Prerequisite — download manifest confinement
After Phase 0 freezes manifest bounds, implement §6 with no wire change and
before Phase 1b writes persistent identity. Phase 1a may remain in parallel.
Mandatory hostile path, limit, absent-from-authoritative-selection, and
zero-mutation tests land here, including the cross-game `local/` sentinel. Run
the full peer-CLI suite and `just build` because the Tauri-to-download
integration is in the path. Windows alias/reparse behavior requires supported
Windows CI or recorded manual evidence; Linux-only results are labelled as
such rather than generalized.
### Phase 1a — primitives and fake-backed groundwork
Add the identity crate, Ed25519/PeerId/signature primitives, versioned
zeroizing secret type, normalized backend outcomes, fake backend, fixed-seed
golden transcript/signature vectors, secret-redaction tests, and
keyring/platform feasibility. Golden vectors include exact control request and
correlated response bytes/signatures plus one CTP event and assert canonical
base64url string fields. Certificate DER/cross-language vectors are not
required. No persistent writes, runtime-ID replacement, legacy deletion, or
trust population.
### Phase 1b — durable identity and usable repair, no wire change
Implement every §7.2 transition-table cell, lifetime lease, keyring/file
backends, secret-first sidecar recovery, override isolation, correct Tauri
`app_data_dir`, encrypted backup/import/reset, and the minimal repair UI. Keep
the protocol-7 runtime/advertised UUID until the Phase 2 wire cutover; do not
populate security trust from it.
Mandatory tests include every backend state, no-fallthrough on
locked/denied/unavailable/corrupt, sidecar mismatch, absent/corrupt sidecar
recovery, unsupported-version zero writes, valid-sidecar no unselected probe,
multiple secrets, write-failure reprobe, crash after secret before sidecar,
replacement mismatch repair, every backend-migration intent stage and target
conflict, account-wide lease held throughout repair, two
simultaneous starts, file permissions/atomic replacement, CLI-over-environment
precedence and zero default-store access in explicit modes,
tampered/wrong-passphrase backup, selected-backend reset/import failure without
fallback, successful export/import fingerprint round trip, header/decoded-size
and KDF-cost bounds rejected before expensive allocation/work with zero writes,
runtime stop/rebuild/restart on replacement, legacy UUID preservation
on every failed path, and successful import/reset still advertising the same
protocol-7 UUID/shared TLS identity rather than prematurely activating the new
key, secret redaction, and “shell visible/network stopped”
repair behavior. Give every Docker/peer-CLI recipe a distinct deterministic
seed/file before this gate; test restart stability and no accidental shared
identity. Run `just frontend-test`, `just build`, and the unfiltered peer-CLI
suite. Real supported keyring/app-data backends require platform CI or recorded
manual evidence.
### Phase 2 — responder-authenticated transport and endpoints (protocol 8)
Land the selected RPK/X.509 mode, real CertificateVerify, mandatory expected
endpoint, all endpoint plumbing, mDNS candidate separation/retry,
non-authoritative inbound Hello, bounded/deduplicated connect-back, atomic
collision rejection, removal of address fallbacks and `Goodbye`, and
revision-conditional liveness. Delete the shared repository certificate/key and
compiled constants regardless of which selected per-peer TLS mode lands.
After the durable identity loads successfully, protocol 8 activates its derived
PeerId for runtime/advertising and deletes the legacy UUID as part of this
versioned cutover—not earlier.
Client-originated controls remain unauthenticated. Library/legacy notifications
cause only bounded pinned resync. Protocol 8 disables all network Call-to-Play
ingestion/sync; local CTP remains available and Phase 3 restores networking
with self-authenticating events. The phase-current scenario matrix explicitly
expects this temporary gate instead of retaining contradictory S48/S49
third-party-relay expectations.
Mandatory negatives: cert/SPKI A with private key B, wrong expected ID,
disallowed/mismatched RPK or X.509 mode, resumption/0-RTT disabled, copied
public-key inbound claim, invalid callback socket class/source-IP, every
per-source/per-target/global callback and candidate-cap axis, advertised Ack
ID/key mismatch and address redirect, callback/resync terminal behavior with no
recursive callback, ALPN/version mismatch, address collision
with intact indexes, simultaneous same-key clone,
mDNS-only mutation, candidate expiry/retry without a new mDNS event, stale ping
or timeout-prune after same-address activity/rebind/reconnect, inbound traffic
not refreshing an unreachable listener, wrong key at reused address, and no
address-derived fallback. If P-256 is selected, also mutate/remove every
pre-channel binding field/signature/SPKI and reject a Hello-only binding.
The resumption negative performs a second connection/early-data attempt and
proves the full verifier and CertificateVerify path runs again. A flood mixing
Hello, mDNS, and legacy library hints proves the unified scheduler remains
within every budget.
Send forged candidate Hello bodies containing library/CTP data and forged
legacy LibraryDelta/CTP payloads; they must not merge, create/rebind/remove a
peer, or refresh liveness. Only the separately pinned resync response may
merge. Protocol-8 Hello's candidate-only decoder rejects the obsolete full
state shape before retaining it.
Positive tests prove pinned resync propagates post-start library changes, a
genuinely dead current endpoint eventually emits `PeerLost` and applies the
defined active-download cancellation, and every endpoint consumer (metadata,
chunks, retry, streamed install, healing, liveness, direct CLI) pins the
captured ID. Import/reset under per-peer TLS stops networking, rebuilds the
derived ID/certificate/advertisement, rejects the old ID/key, and reconnects
under the new identity. The former Goodbye shutdown scenario is rewritten around liveness.
Run `just build`, the unfiltered phase-current peer-CLI suite, and fresh
three-peer matrix. Update the relevant architecture, threat, and CLI docs in
this phase.
### Phase 2b — default-deny transfer sources and minimal approval UI
Using Phase 2 identities, implement the durable trust/source state and outbound
source-enforcement portions of §§7.3 and 7.10: approved-only raw-manifest
bounds/consensus, ordinary exact provenance, streamed root-archive eligibility
and staging validation, and mid-transfer revocation. Public browse remains
open; inbound serving remains anonymous/public until Phase 4, so requester
blocking is not claimed here. Relayed-author blocking belongs to Phase 3.
Mandatory tests: a prompt/denied sole source creates nothing; browse still
works; approval enables download; unapproved metadata cannot enter transfer
consensus; UI selection absent from approved manifests fails; wrong identity at
a reused address fails; retry stays within exact attesters; manifest mismatch
fails before mutation; and deny/block cancels and cleans both ordinary and
streamed transfers without touching `local/`. Streamed hostile tests require
complete root-archive attestation and reject extracted traversal/reserved/
symlink-reparse paths, count/byte overflow including a late invalid entry, and
invalid `Complete` without promoting any staging. Also test raw count/byte limits,
failed security-state writes/restart, and that same-name/new-key or address
reuse by a different key never inherits `allowed`, while the same verified
PeerId moving to a newly pinned address retains it; existing honest download
scenarios explicitly approve their sources. Saturate total trust records,
observed-name history, and unacknowledged observations and prove that
acknowledged, blocked, and allowed records are never silently evicted. Run
frontend/build and full peer-CLI gates, and update
source-permission/user-facing docs here.
Catalog-matching already-downloaded data is explicitly tested for offline
install/reinstall and local serving with no remote approval, so source policy
cannot accidentally gate local provenance.
### Phase 3 — independently signed Call-to-Play objects (protocol 9)
Implement §7.9, including core-created CallRef/IDs, verbatim relay, creator and
participant authority, canonical rooted/rootless terminal state, deterministic
horizon checks, exact-object cache, block-at-merge, hard count/byte/author
limits, local reserve, dependency-closed classification, and overload UI.
Third-party relay is restored only for valid signed objects.
Mandatory tests cover wrong creator/participant, CallRef mismatch, competing
same-CallRef Creates under arrival/group/partition permutations, fresh-store
rootless terminal, noncreator tombstone, rooted/rootless terminal permutations
and partitions, later-arriving earlier winner, stale resurrection, 15-minute
compaction, rootless AddTime/participant `NeedHistory`, rootless no-display and
later hydration, old and two-day history, timestamp equality/overflow/ready-at
and exact ECMAScript-range boundaries, AddTime/Create recomputation under
arrival/group/partition permutations, bytewise Rust/TypeScript ordering, and
receiver-clock-independent three-day rules, every
body/key/signature/ID/protocol-version mutation, observed-ID preseed, invalid signature after
cache hit/compaction, bounded cache, count/byte/per-author/tombstone/Sybil
limits, local reserve, terminal at capacity, blocked author relayed by a
nonblocked peer regardless of that relay's source permission, blocking
already-retained creator and participant state,
quarantine accounting, unblock authenticated resync, mixed
invalid/blocked/over-quota author isolation, all-or-none collective global
overflow, over-quota Create removing its under-quota dependent while preserving
an unrelated call, sequential-versus-single-batch arrival where an initially
inactive action becomes effective after an earlier canonical Create,
same-batch fixed-point dependency closure, bounded snapshot assembly, and
explicit bounded-divergence recovery. Frontend tests prove chat uses enclosing
event ID and rootless state stays hidden. Saturate relay-only author metadata
and prove it remains ephemeral and bounded without growing the durable trust
store. Run `just frontend-test`, `just
build`, the full hostile peer-CLI suite (restoring late-join/relay scenarios),
and honest matrix. Update CTP architecture and UI claims here.
### Phase 4 — signed control envelopes (protocol 10)
Implement the exact §7.8 envelope, explicit byte serializers, request/response
nonce correlation, boundary verification, `VerifiedSender`, removal of
payload identity fields, bounded duplicate suppression, and control frame cap
or pagination. Reject a blocked `VerifiedSender` before every inner handler,
including Hello, browse, and download serving; associate long-lived streamed
and ordinary chunk serving with that identity and cancel every active outbound
serve operation if the identity becomes blocked.
Mandatory tests mutate every signed field and cover unsigned frames, wrong
signer/derived ID, protocol, recipient, context, pinned response sender,
request nonce, missing/unknown fields, padded/wrong-alphabet base64,
numeric-array bytes, wrong decoded lengths, explicit base64 round trip,
concurrent duplicates, sender/context isolation, invalid-signature nonce-cache
poisoning, eviction with idempotent replay, oversized frame/payload, forged
library delta, every valid blocked state-changing sender before handler
dispatch, blocked Hello/read/stream serving and unsigned-Hello bypass, block
cancellation of active ordinary-chunk and streamed serving, and liveness
attribution. Honest
library, download, and event flows
must remain green. Run `just build`, the full peer-CLI suite, and fresh
three-peer matrix. Update protocol/trust documentation here.
### Phase 5 — complete trust and identity UX
Add polished identity/pin/new/reviewed/name-conflict displays, source permission
management, block/unblock, honest storage labels, and refined repair/backup
flows. Source enforcement, relayed-object blocking, and direct requester
blocking already exist in Phases 2b, 3, and 4 respectively; this phase improves
visibility and management.
Test reducers and Tauri failure paths, immediate sensitive-state durability,
relayed blocking, existing-state recomputation, unblock resync, permission
persistence, same-name/new-key behavior, duplicate identity warnings, and proof
that unblock leaves download permission denied. Saturation must be visible;
the user can deliberately remove an eligible unacknowledged record without
evicting protected state, after which an honest new identity can be admitted
and approved. Run frontend/build and full peer-CLI gates. Update UX
documentation in the same phase.
### Phase 6 — optional hardening
Separately designed features may include dual-signed key rotation, party
admission, connection retry/address tokens, and deterministic over-quota author
quarantine. Each feature owns a protocol bump and hostile tests if it lands.
Before implementation it freezes its own contract; its gate includes positive
end-to-end behavior, applicable persistence/failure/recovery cases, negative
security cases, and same-phase documentation. A reject-all implementation does
not satisfy the gate.
Catalog content hashes/signatures remain separate, non-optional future work,
not a claim completed by this phase.
### Phase 7 — final audit, documentation, and performance
This is not the first hostile-test or documentation phase. Audit the already
updated `ARCHITECTURE.md`, README files, threat claims, peer-CLI docs, and UI
wording; run every standard gate,
unfiltered `just peer-cli-tests`, a fresh three-peer matrix, and download/
handshake/full-history performance checks against Phase 0's fixed workloads and
failure thresholds. Build supported-platform production bundles with
`just bundle` and smoke-test packaged startup, identity/keyring/app-data
selection, repair, and restart; the no-bundle output from `just build` alone is
not shipping-artifact evidence.
Audit for absence of the shared TLS key, `Goodbye`, address-only connects,
`ensure_peer_id_for_addr`, fabricated IDs, payload-trusted sender fields,
unbounded auth/CTP inputs, deferred negative tests, and any “verified/trusted
content” wording.
## 12. Dependencies
Prefer the existing AWS-LC stack.
| Crate | Purpose | Constraint |
|---|---|---|
| `rustls` 0.23 | Full verifier/config construction | Version aligned with s2n-quic-rustls |
| `aws-lc-rs` | Ed25519, SHA-256, KDF/AEAD as needed | Existing crypto backend |
| `rcgen` if X.509 is selected | Self-issued leaf generation | `default-features = false`, AWS-LC feature only |
| `x509-parser` if needed | Strict SPKI/binding extraction | Parsing only; reject unsupported shapes |
| `keyring` 3 | OS secret stores | Normalize locked/denied/unavailable distinctly |
| `zeroize` | Secret hygiene | Seed-bearing values only |
| `data-encoding` or existing equivalent | base32/base64url | One exact encoding implementation |
`s2n-quic` uses the rustls provider and retains only required provider features.
The new crate keeps `unsafe_code = "forbid"`.
## 13. Explicit risks and decisions
- **TLS mode uncertainty:** Phase 2 does not begin implementation around an
assumed raw-key path; Phase 0 chooses RPK or Ed25519 X.509 and proves the
wrong-private-key feasibility first.
- **Linux keyring availability:** unavailable is not absent. Fresh GUI users
can deliberately choose a labelled file backend; headless runs choose an
explicit identity source.
- **Identity loss and clones:** backup/import are necessary recovery tools but
can clone a key. The UI cannot claim automatic clone revocation.
- **Endpoint plumbing size:** this is real cross-cutting work, not hidden behind
`connect_to_peer(expected)`. Phase 2 inventories every caller and tests
retries/streamed installs explicitly.
- **Authenticated does not mean harmless:** self-signed identities and source
approval still leave approved malicious content and link/application-task
DoS, including the explicitly accepted generic server-fan-out risk in §4.
- **Bounded state versus Byzantine convergence:** this plan chooses finite
memory and explicit overload over distributed consensus machinery for a
noncritical LAN-party feature.
- **Permanent tombstones:** their anti-resurrection purpose is preserved. They
are bounded through admission, never arrival-ordered eviction.
- **Scope pressure:** each phase owns its negative tests and safe intermediate
restrictions. No phase gets security credit for a later phase's mechanism.
## 14. Closure of prior review findings
| Finding | Resolution in this plan |
|---|---|
| F1 signed listener/address collision | §§7.47.6: dialled endpoint authority, non-authoritative claims, pinned connect-back, atomic conflict rejection without eviction. |
| F2 responder-only Phase 2 / relayed authority gap | §§7.6, 7.9 and phases 24: protocol 8 disables network CTP and treats client claims as hints; protocol 9 admits only independently signed CTP objects; all other client authority waits for envelopes. |
| F3 compacted tombstone creator proof | §7.9: CallRef in every body, creator-signed rootless burn certificates, canonical retained rootless terminal, fresh-peer propagation. |
| F4 arbitrary event IDs/cache bypass | §7.9: full content-derived IDs and bounded exact-signed-object cache. |
| F5 invalid ±10-minute history rule | §7.9: no receiver-age rejection; deterministic three-day field horizon and local-only publication skew policy. |
| F6 resource exhaustion | §§4, 7.6, 7.8, 7.9: concrete new-amplification/batch/store/cache bounds, author quotas, local reserve, rejection/no tombstone eviction, and honest exhaustion behavior. The review's comprehensive connection/stream/disk-read scheduler is explicitly not adopted and remains an accepted separate availability risk. |
| F7 replay/Goodbye | §§7.77.8: delete Goodbye, narrow duplicate suppression claim, local recipient/context/pinned response, conditional liveness removal. |
| F8 storage crash/fallback state | §7.2 and Phase 1: explicit transition table, conclusive absence, lifetime lease, secret-first derivative sidecar, early repair, correct GUI path. |
| F9 expected identity not threaded | §7.4 and Phase 2: first-class endpoint through all consumers; delete address-derived/fabricated identity and require CLI fingerprint. |
| F10 trust/block/lifecycle | §§7.27.3, 7.97.10: separate review/block/source states, durable writes, relay blocking, Phase 1 repair, honest backup/clone semantics. |
| F11 incomplete crypto/wire contract | §§7.1, 7.5, 7.87.9: exact transcripts/encodings/crate direction, real CertificateVerify, wrong-key test, AWS-LC rcgen, pre-channel fallback binding. |
| F12 late tests/docs | §11: phase-owned negatives, full peer-CLI gates, GUI build gates, fresh manual images, final-audit-only Phase 7. |
Additional dialogue findings are also closed: §6 handles the live cross-game
`local/` truncation bug; §7.7 handles the stale liveness-probe race; §7.9
handles rootless terminal ordering and chat `message_id`; §§7.3 and 7.10 add
default-deny source admission without misrepresenting it as content integrity.
-98
View File
@@ -1,98 +0,0 @@
• ## 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.