Files
lanspread/PEER_AUTH_PLAN.md
T
ddidderr a886e64fc7 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
2026-08-09 11:15:50 +02:00

1423 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Peer authentication, identity continuity, and download-source authorization
## Status
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 |
|---|---|---|
| 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. |
## 6. Prerequisite: confine download preparation before persistent identity work
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.
### 6.1 Validated manifest boundary
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.
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.