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

80 KiB
Raw Blame History

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:

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:

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

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:

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:

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:

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.

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:

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:

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 PeerEndpoints 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.