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