From 8dae9dfe755601e96d0b4af1d194c572fd239f4f Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 9 Aug 2026 12:47:14 +0200 Subject: [PATCH] PEER_AUTH_PLAN simplified drastically. GPT 5.6 Sol (ultra) was doing a great job, but with my poor prompting it completely overengineered. --- PEER_AUTH_PLAN.md | 1776 ++++++++++++--------------------------------- 1 file changed, 471 insertions(+), 1305 deletions(-) diff --git a/PEER_AUTH_PLAN.md b/PEER_AUTH_PLAN.md index 0985716..a9c8716 100644 --- a/PEER_AUTH_PLAN.md +++ b/PEER_AUTH_PLAN.md @@ -1,460 +1,271 @@ -# Peer authentication, identity continuity, and download-source authorization +# Pragmatic LAN safety, peer identity, and content integrity ## Status -Architecture approved; implementation plan. +Revised implementation plan; not yet implemented. -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. +This plan deliberately treats Lanspread as what it is: a desktop utility for +friends and other attendees at a LAN party to discover each other, share a +known game catalog at LAN speed, and coordinate a match. It is not an account +system, a global untrusted file-sharing network, or a device-administration +product. -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 normal user journey must remain: -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. Open Lanspread. +2. See nearby people and their available games automatically. +3. Click Download or Stream Install without approving every source. +4. Let Lanspread swarm from matching peers and verify the result itself. +5. Use Call to Play while those people are present. -## 1. Executive summary +Security mechanisms in this plan are automatic. There are no key backup +dialogs, trust ceremonies, fingerprint prompts, or per-device download +permissions in the normal UI. -The design has six connected parts: +The project still has one current wire version and no compatibility shims. The +wire changes below are developed together and activated with one protocol bump, +not three partially compatible protocol generations. -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. +## 1. Product and architecture decisions -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 | +| Area | Decision | User-visible result | |---|---|---| -| 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. | +| Filesystem safety | Validate the complete destination manifest before any mutation and confine it to one catalog game root. | A hostile peer cannot overwrite another game, `local/`, saves, or transaction state. | +| Content authority | Ship SHA-256 file and chunk hashes from the same bundled catalog authority as `game.db`. | Every eligible nearby peer is usable automatically; wrong bytes are rejected and retried elsewhere. | +| Peer identity | Use one installation-local TLS key and derive `PeerId` from that TLS public key. | Identity works silently and survives ordinary restarts when possible; users do not manage it. | +| Transport | Pin every outbound QUIC connection to the expected `PeerId`. | An address spoof or MITM cannot impersonate the peer selected as a source. | +| Control messages | Use ordinary bounded protocol messages inside TLS. Treat unauthenticated inbound change notifications only as hints that trigger a pinned pull. | No signed-envelope layer, nonce ledger, or message-signing overhead. | +| Call to Play | Exchange only each peer's own session state by direct pinned pulls; do not relay third-party histories. | Calls are live LAN-party state and disappear naturally as their authors leave. | +| Privacy | Provide one global Local network sharing switch. | Participation is easy to understand; no per-peer policy matrix. | +| Protocol rollout | Make one cutover to the new current protocol. | Mixed versions are explained clearly, without maintaining legacy paths. | -## 6. Prerequisite: confine download preparation before persistent identity work +The resulting data flow is intentionally small: -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. +```text +mDNS candidate -> responder-pinned TLS -> peer-owned snapshot or file bytes -### 6.1 Validated manifest boundary +bundled content manifest -> validated local download plan + -> SHA-256 check for every received chunk + -> version.ini commit only after complete success + +local Call to Play change -> cheap invalidation hint to known peers + -> each peer pulls the author's current state over pinned TLS +``` + +## 2. Threat model and guarantees + +Assume a hostile device can join the same LAN, advertise arbitrary mDNS data, +send arbitrary protocol messages, occupy reused IP addresses, and run a +modified Lanspread build. The attacker does not control the victim's OS, the +installed Lanspread application, or its bundled catalog files. + +After this plan: + +- a remote description cannot make Lanspread create, truncate, or delete a + path outside the requested catalog game's download-owned area; +- a selected responder must prove possession of the TLS private key whose + public key derives the expected `PeerId`; +- mDNS, IP addresses, display names, and inbound notification bodies never + become identity authority by themselves; +- a source cannot make a download commit bytes that differ from the hashes in + the victim's bundled catalog, even if that source is the only peer present; +- corrupt sources are removed from the current transfer automatically rather + than presented to the user as a trust decision; and +- one peer cannot publish Call-to-Play actions as another peer or mutate + another peer's author-owned state. + +The following are explicit non-goals: + +- A `PeerId` does not prove a human name. Display names remain friendly labels. +- The installation key is not a user account and has no promised continuity + across OS reinstall, application-data deletion, or copying the application + to another computer. +- Content hashes prove that bytes match the bundled catalog. They do not prove + that the catalog publisher's game is benign, licensed, or malware-free. +- A hash advertised by the same peer that sends the bytes is not trusted. The + expected hash must come from the local bundled catalog. +- When Local network sharing is enabled, nearby devices may browse and request + shared catalog content. Per-device admission and requester blocking are not + part of this product model. +- Basic frame, connection, and work limits are required, but internet-scale + Sybil resistance and Byzantine convergence are not goals for a LAN-party + utility. +- Peers on another protocol version do not interoperate. The UI explains the + mismatch instead of adding a legacy protocol path. + +## 3. Normative design + +### 3.1 Confine download preparation first + +This remains the first implementation task because it fixes a live local +data-loss path without depending on authentication or a wire change. 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. +file creation/truncation, preallocation, or cleanup. Storage functions accept +that validated type, never raw `GameFileDescription` values from Tauri or a +peer. -For the entire list, validation must: +Validation is for the complete list and fails without any mutation. It must: -- require every descriptor's `game_id` to equal the requested game; -- resolve destinations relative to exactly `/`, 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. +- require a known catalog `game_id` and resolve every destination relative to + exactly `/`; +- use one canonical forward-slash relative-path form and reject empty, + absolute, drive-qualified, UNC, NUL, `.`, `..`, mixed-separator, and + non-normalized paths; +- reject duplicate paths, file/directory conflicts, and platform aliases such + as Windows case, trailing-dot/space, device-name, and alternate-data-stream + collisions; +- reject `local/`, `.local.*`, download/install intent state, legacy state, + scratch sentinels, and every other path owned by installation or recovery, + while allowing the intended root `version.ini`; +- require the game root to be one direct non-symlink child of the configured + games directory and avoid following symlink or reparse components while + opening destinations; +- enforce descriptor-count, individual-size, and aggregate-size limits; and +- require the exact root/file shape needed for a complete downloadable game, + including one regular root `version.ini`. -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. +The Tauri command supplies only the selected `game_id`. The peer core chooses +the complete authoritative plan. A UI-echoed file list is never authority. -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. +After a successful complete transfer, remove download-owned files absent from +the authoritative manifest before committing `version.ini`. Preserve +`local/`, install staging/backup state, and user-owned files in all success, +failure, cancellation, and recovery paths. -### 6.2 Mandatory proof +For the current protocol, this validator safely contains the existing remote +descriptions. A narrow protocol-7 adapter requires and removes exactly one +matching leading `game_id/` component (and discards only the current exact +redundant game-root directory entry) before constructing root-relative paths; +it rejects a missing/different/doubled prefix. After the protocol cutover, the +same validated type is constructed directly from the bundled content manifest +and remote descriptions cease to define local paths at all. -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. +Required proof includes hostile descriptors placed after valid descriptors, +cross-game paths, both requested and other-game `local/` sentinels, reserved +paths, duplicates and aliases, symlink/reparse destinations, oversized lists, +and stale download-owned files. Every rejection must prove zero filesystem +mutation. -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. +### 3.2 Make the bundled catalog the content authority -## 7. Normative design +`game.db` is already the application's authority for game identity and +version. Add a reproducibly generated companion catalog artifact, for example +`content-manifests-v1.json`, and package it with both the desktop application +and peer-CLI fixtures. -### 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 `/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: +For each supported `(game_id, game_version)`, the artifact contains: ```text -Present(secret) | NoEntry | Locked | Denied | Unavailable | Corrupt +CatalogContentManifest { + schema_version + game_id + game_version + chunk_size + files: [ + { canonical_path, kind, size, file_sha256, chunk_sha256[] } + ] + streamed_install_files: [ + { canonical_path, kind, size, file_sha256 } + ] + content_id +} ``` -Only `NoEntry` proves absence. An I/O error, locked store, denied access, -unavailable service, or corrupt record never authorizes generation. +Entries are sorted by canonical path. `content_id` is SHA-256 over a +versioned, length-delimited encoding of all preceding manifest fields and +hashes, excluding the `content_id` field itself; it is not the current +noncryptographic `u64 manifest_hash`. Golden tests freeze that encoding. The +ordinary chunk size initially matches Lanspread's existing 128 MiB transfer +chunk so verification does not create a second chunking scheme. -#### Persistent transition table +The catalog publishing workflow must generate these manifests from the +canonical game packages, verify them by rereading the packages, and fail the +application build/release if a downloadable catalog entry lacks one. Runtime +peer consensus and “the only peer said this hash” are not substitutes for this +artifact. If real package inputs are unavailable during development, fixture +manifests may prove the code path, but the phase is not complete for production +games. -| 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. | +Peers advertise only that they can serve a catalog `content_id`. A peer counts +as a source for the local catalog game only when its advertised ID exactly +matches the receiver's expected ID. The receiver builds paths, sizes, chunks, +and expected hashes entirely from its local catalog. This replaces remote +manifest selection and majority-by-file-size consensus. -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. +For ordinary downloads: -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. +1. Select every currently reachable peer advertising the expected + `content_id`; there is no approval prompt. +2. Carry `PeerEndpoint { peer_id, addr }` and `content_id` through planning, + swarming, progress, and retry. +3. The sender serves only an exact catalog file/range for the requested + `(game_id, content_id)` and applies the same canonical/reserved-path policy + before opening a local file. A caller-supplied path can never expose + `local/` or another local file. +4. Hash each chunk while receiving it and compare it before marking that chunk + complete. Exact length, offset coverage, and the catalog file shape are also + mandatory. +5. On mismatch, invalidate that write, quarantine that `(PeerId, content_id)` + for the current runtime/transfer, and retry the chunk from another matching + peer. Do not create durable “trust” state. +6. Commit `version.ini` only after every catalog entry and chunk has completed + successfully. Failure leaves the game non-downloadable/non-installable and + preserves `local/`. -#### Locking and durability +Existing downloaded payloads are verified against the catalog in the +background before they are newly advertised or installed, with results cached +against the existing file fingerprint. The user sees ordinary “Verifying game +files” progress, not a security decision. -- 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 `/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. +Streamed install needs a catalog-owned extracted-file manifest because the +sender controls both today's RAR CRC32 metadata and extracted bytes. The +receiver accepts exactly the expected path set, sizes, and SHA-256 values in +isolated staging, then applies the documented local account/language rewrite +and promotes the transaction. CRC32 may remain as an early corruption check, +but it is not the security boundary. A game without a verified extracted +manifest does not offer Stream Install; there is no unverified fallback or +warning-through button. -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. +Hashing is performed in the existing streaming I/O path. The acceptance gate +measures end-to-end throughput on the standard LAN workload and avoids a +second full read when complete chunk coverage already proves the file bytes. -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. +### 3.3 Use a simple installation-local TLS identity -- 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 identity exists to bind a live peer and its changing address to TLS. It is +not exposed as a user credential. -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. +- Generate one self-issued TLS certificate/key pair in Tauri's + `app_data_dir()` and store it in one versioned application file with + restrictive permissions where the platform supports them. +- Prefer Ed25519 if the selected s2n-quic rustls provider supports the complete + responder-verification path. Otherwise use one supported P-256 TLS key. Do not + add a second signing identity or a custom certificate-extension binding. +- Define `PeerId` as lowercase unpadded base32 of + `SHA-256(canonical DER SubjectPublicKeyInfo)` from the actual TLS key. The + same key is therefore both the identity and the TLS proof-of-possession key. +- Validate on load that the private key, certificate SPKI, and derived ID + agree. Never log private material. +- A valid file is reused. A missing or corrupt file is regenerated + automatically (quarantining corrupt bytes best-effort) and produces at most + a diagnostic log entry. If persistence is unavailable, use a fresh in-memory + identity for that run and show a non-blocking diagnostic; LAN functionality + should not become a repair wizard. +- The peer CLI may accept an explicit deterministic identity file/seed for + repeatable tests. It does not probe keyrings or share a default container + identity accidentally. -#### Repair, backup, import, and reset +There is no OS-keyring backend, sidecar/backend reconciliation, migration +intent, identity lease, encrypted backup, import, reset, clone warning, or +continuity repair UI. If application data is lost, the installation simply +appears as a new nearby peer. Because authorization is not attached to the old +ID, nothing security-sensitive needs migration. -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. +### 3.4 Pin responders and make remote state pull-only -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 +Every outbound operation accepts a first-class endpoint: ```rust struct PeerEndpoint { @@ -463,960 +274,315 @@ struct PeerEndpoint { } ``` -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. +This endpoint is carried through discovery handshake, library refresh, +Call-to-Play refresh, metadata/content requests, chunk plans, retries, streamed +install, healing, liveness, and direct peer-CLI operations. Delete +address-derived IDs, unique-IP identity fallbacks, and address-only connects. -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 supplies bounded candidates containing `(peer_id, addr, protocol, +revision hints)`. It may cause a dial, but it never directly creates or +updates authenticated peer/library/Call-to-Play state. A candidate becomes a +peer only after a successful outgoing TLS connection to its advertised address +proves the expected `PeerId`. -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. +Use TLS 1.3 with a self-issued certificate. The custom client verifier must: -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. +1. parse only the selected certificate/SPKI shape; +2. derive the `PeerId` from that SPKI and compare the full value with the + endpoint's expected ID; and +3. perform real TLS 1.3 CertificateVerify validation under the presented key. -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. +The load-bearing negative test presents peer A's certificate/SPKI with peer +B's private key and requires the handshake to fail. Also reject a different +valid peer at a reused address. Use one version-bound ALPN, disable 0-RTT, and +start without TLS session resumption so every short-lived connection performs +the simple full proof. -### 7.5 Authenticated QUIC responder +The protocol is deliberately responder-authenticated rather than wrapping +every message in a signature: -Use the s2n-quic rustls provider with TLS 1.3 only. +- Requests that read public library/content state may be made by any LAN + client while Local network sharing is enabled. +- A response is authoritative only to the initiator that connected using the + expected `PeerEndpoint`; the TLS channel supplies integrity and request/ + response correlation. +- A state-bearing response contains only the responder's own state. It cannot + vouch for third parties. +- Inbound `LibraryChanged` or `CallToPlayChanged` messages are untrusted hints. + For a known claimed ID they schedule one coalesced, rate-limited pull from + that ID's already known endpoint. Their payload never merges directly. Hints + for unknown IDs are ignored and mDNS remains the discovery path. +- `Hello` becomes a pull-oriented exchange: the initiator sends no + authoritative identity or replicated state, and the pinned responder returns + its own current snapshot. -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. +This extra pull is one small LAN round trip and removes general signed +envelopes, canonical opaque payloads, nonce caches, replay semantics, inbound +client-certificate plumbing, and connect-back authority state. -The custom client verifier captures the expected `PeerId` from -`PeerEndpoint` and MUST: +An unproven address collision never evicts an authenticated peer. If a pinned +dial later proves that a different ID now owns the same address, atomically +replace address ownership and retire the old record only if it still names +that address/generation. A same-ID address move is likewise committed only +after pinning the new endpoint. Pings are also pinned, and a late ping result +may update/remove only the same endpoint generation it probed so it cannot +delete a peer that has already moved or reconnected. -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. +Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated +removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles +departure. -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. +### 3.5 Keep Call to Play direct and ephemeral -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. +Call to Play is coordination among people currently at the party. It does not +need a Byzantine replicated ledger. -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 -`.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: +Each runtime owns only its locally authored slice: ```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] -``` +CallId { creator: PeerId, random_nonce } -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, +CallToPlayAuthorSnapshot { + runtime_session_id + revision + display_name + events[] // actor ID is not a wire field } ``` -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 local core creates call/event IDs, increments the revision after each +accepted local action, and sends a cheap change hint to known peers. A receiver +coalesces the hint, connects to the author's known `PeerEndpoint`, and pulls +that author's complete current slice. Because the responder is pinned, the +receiver assigns the author ID itself. A peer cannot put another actor ID into +the wire object. -The exact signature input is: +Snapshots use replacement, not union/CRDT semantics. For each peer, permit one +in-flight refresh; a newer revision for the same runtime session replaces that +author's previous slice atomically. A new runtime session replaces the old +session after a fresh pinned handshake. Stale concurrent results cannot +overwrite the current session. -```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] -``` +Authority rules remain simple: -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. +- `Create`, `Start`, `Cancel`, and `AddTime` are effective only when the pinned + author equals `CallId.creator`. +- RSVP, ready/leave, and chat actions are attributed to the pinned author. They + become effective only while the referenced creator root is directly + present; an author slice pulled before its creator is retained within its + ordinary bound but remains hidden until that creator's direct pull arrives. +- A snapshot contains only events authored by its responder. Third-party + events are rejected rather than relayed. +- Display names never grant authority. -Boundary verification yields `VerifiedSender`; handlers no longer accept or -trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never -forwarded. +A newly arriving peer discovers and pulls directly from every live peer, so it +reconstructs calls from the people still present. If an author's peer goes +away, remove that author's slice. If the creator goes away, the call disappears +from the derived view. A participant who leaves naturally drops out. This is +the intended session model, not data loss. -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. +Keep the useful human-scale timers: active calls expire, unresolved expired +calls may remain visible for five minutes, and Start/Cancel results may remain +visible for fifteen minutes. After that, the author drops them from its current +snapshot. There are no session-long tombstones, rootless terminal records, +three-day history horizons, verification caches, or permanent anti-resurrection +state because no third party can replay an old author's history as authority. -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. +Retain straightforward schema and resource limits: bounded strings/chat, +bounded events and encoded bytes per author, bounded total live peers, and a +named control-frame maximum. Validate one author's snapshot off to the side and +accept or reject it as a unit; a bad/oversized peer cannot consume another +author's slice or the local author's capacity. Exact limits are set from the +existing three-peer and stress fixtures, not from an internet-scale adversary +model. -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.” +A malicious creator can show inconsistent versions of its own noncritical call +to different peers. This plan accepts that limit rather than adding signatures, +gossip, consensus, or permanent storage to a party invitation feature. -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. +### 3.6 Keep the UI about games and people -### 7.9 Independently signed Call-to-Play events +Add one visible `Local network sharing` setting, on by default for this +LAN-sharing application. When off, stop mDNS advertisement/discovery, the QUIC +listener, outbound refresh, and serving. The setting is durable and its state +is obvious in the main UI/settings. -Call-to-Play objects land before general signed envelopes so relayed authorship -is independently safe as early as possible. +Do not add per-peer source prompts. Every peer with the locally expected +`content_id` is an eligible swarm source; verification is automatic. -```text -SignedCallToPlayEvent { - protocol_version: u32, - event_id: EventId, - author: PeerId, - author_key: PublicKey, - body: opaque CallToPlayEventBody bytes, - signature: Signature, -} +Normal UI uses display names and peer count. A short PeerId suffix may +disambiguate duplicate names or appear in diagnostics, but there are no New, +Trusted, key-changed, backup, repair, or fingerprint-confirmation workflows. -CallToPlayEventBody { - call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce }, - actor_name, - at, - action, -} -``` +User-facing exceptional states are concrete: -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. +- `Verifying game files` while existing or newly received content is checked; +- `A source sent invalid data; retrying another nearby peer` when recovery is + in progress; +- `No nearby peer could provide the verified catalog version` after all + matching sources fail; +- `Nearby devices are running a different Lanspread version` when mDNS sees an + incompatible protocol; and +- a non-blocking networking diagnostic if the installation identity cannot be + persisted and will change next launch. -The exact event-signature transcript is: +Do not ask the user to solve a cryptographic implementation problem. -```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] -``` +## 4. One protocol cutover -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. +Develop the pieces behind internal APIs, then replace protocol 7 with one new +current protocol (protocol 8 if the version has not moved). Do not ship +intermediate protocol 8/9/10 designs and do not add compatibility decoding. -Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32: +The cutover includes: -```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) -``` +- `PeerId` derived from the TLS SPKI and `PeerEndpoint` required by every + outbound connection; +- version-bound ALPN and per-installation server certificates instead of the + repository-wide `cert.pem`/`key.pem`; +- mDNS candidate-only semantics and useful incompatible-version telemetry; +- responder-owned pull snapshots plus bounded change hints instead of inbound + state-bearing `Hello`, pushed `LibraryDelta`, and pushed/relayed + `CallToPlayEvents`; +- cryptographic `content_id` in game availability and catalog-driven chunk + requests; +- canonical forward-slash catalog paths; +- author-owned Call-to-Play snapshots; and +- removal of `Goodbye` and payload fields that pretend to identify an + authoritative sender. -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. +Peers on another protocol remain excluded, as required by project policy. To +reduce real LAN-party friction, make this one coordinated bump and surface the +version mismatch rather than failing silently. -Authority rules: +## 5. Code ownership -- `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`: +Keep the change inside existing crates unless implementation pressure proves a +real reusable boundary; a new identity crate is not required by the design. | 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-db` / `lanspread-compat` | Catalog content-manifest types and loading beside `game.db`. | +| `lanspread-proto` | `PeerId`, `PeerEndpoint`, `content_id`, pull snapshots, change hints, author-owned Call-to-Play wire types, and the one protocol version. No crypto or storage logic. | +| `lanspread-peer::identity` | Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection. | +| `lanspread-peer::network` | Per-endpoint rustls client config, full responder verification, ALPN, and no address-only connect. | +| discovery/handshake/liveness | Candidate-only mDNS, pinned pulls, hint coalescing, endpoint generations, and version-mismatch reporting. | +| `peer_db` | Authenticated endpoint/state records and exact `content_id` source lookup. | +| download/storage/stream install | Validated catalog plan, hash-as-received, source quarantine/retry, sentinel commit, and protected staging. | +| `call_to_play` | Local author slice, per-peer replacement snapshots, simple authority checks, timers, and bounds. | +| Tauri/frontend | Global sharing switch, verification/progress failures, incompatible-version notice, and replacement of the full derived Call-to-Play view. | +| peer CLI | Distinct deterministic identities, hostile TLS/content modes, and zero-prompt multi-peer scenarios. | -`lanspread-proto` owns wire-only fixed types, explicit serializers, transcript -builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data. +## 6. Implementation phases and gates -Principal `lanspread-peer` changes: +Every code phase runs `just fmt`, `just clippy`, and `just test`. Frontend or +Tauri phases also run `just frontend-test` and `just build`. Network/transfer +phases run focused peer-CLI scenarios during development and the unfiltered +`just peer-cli-tests` before completion. Manual alpha/bravo/charlie evidence +must use a freshly built image. -| 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 | +### Phase 1 — land filesystem confinement immediately -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. +Implement `ValidatedDownloadManifest`, make the UI submit only `game_id`, and +move all validation before transaction/storage mutation. Centralize reserved +paths and add the zero-mutation hostile tests from §3.1. Preserve current wire +bytes in this phase; it is an independent safety fix. -## 10. UI and operational semantics +Gate: standard Rust/Tauri checks, hostile descriptor tests, full peer-CLI suite, +and supported Windows path/reparse evidence. Linux-only results must not be +reported as Windows proof. -- **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. +### Phase 2 — establish real catalog content authority -## 11. Implementation phases and acceptance gates +Add the reproducible content-manifest generator and fixture manifests. Freeze +the versioned manifest/content-ID encoding with golden tests. Extend local +catalog state, build download plans only from that state, implement streaming +SHA-256 checks and source quarantine, and implement verified extracted +manifests for Stream Install. -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. +Do not claim completion from test fixtures alone: production catalog packages +must have independently generated manifests, and the release/build path must +reject a missing manifest. Benchmark hashing at normal LAN throughput. -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 3 — prove and implement simple responder identity -### Phase 0 — freeze contracts and complete spikes +Start with a bounded rustls/s2n-quic spike that proves self-issued certificate +support, SPKI extraction, expected-ID pinning, and real TLS 1.3 +CertificateVerify. The certificate-A/private-key-B negative is the go/no-go +gate. Choose Ed25519 or P-256 based on that proof, using one TLS identity key. -Record in the repository: +Then add simple load-or-generate persistence, deterministic CLI identities, +`PeerEndpoint`, and endpoint plumbing through every outbound consumer. Separate +mDNS candidates from authenticated peer records and make liveness removal +generation-conditional. No trust database or identity UI is introduced. -- 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 4 — make the single wire cutover -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. +Bump the current protocol once and activate all coupled wire behavior from +§4: pinned transport, catalog `content_id`, catalog-driven downloads, pull-only +library synchronization, bounded invalidation hints, author-owned +Call-to-Play snapshots, and no `Goodbye`. -### Prerequisite — download manifest confinement +This phase is not complete until: -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. +- three fresh peers discover each other with no prompts and see post-start + library changes; +- a new peer reconstructs active Call-to-Play state by pulling every live + author, and creator departure removes the call; +- every metadata, chunk, retry, stream-install, healing, liveness, and direct + CLI dial rejects the wrong key at the expected address; +- a forged mDNS record or inbound hint cannot create/rebind/remove peer state, + inject a library/Call-to-Play update, or bypass a pinned pull; +- an honest multi-source download swarms automatically and commits only the + catalog bytes; +- one bad source is quarantined and another source completes the chunk; +- all-bad/only-bad sources fail without committing `version.ini` or touching + `local/`; +- a streamed path/hash/set mismatch cannot promote staging; +- an oversized Call-to-Play snapshot affects only that remote author and local + publication still works; and +- protocol-7 peers are rejected while the UI receives enough information to + explain the version mismatch. -### Phase 1a — primitives and fake-backed groundwork +Update `ARCHITECTURE.md`, protocol docs, and CLI documentation in the same +phase; do not leave the shared-certificate or relayed-event description behind. -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 5 — finish the small user-facing surface and audit -### Phase 1b — durable identity and usable repair, no wire change +Add the global sharing switch and the concrete progress/error states from +§3.6. Run a first-run test with an empty app-data directory, a normal restart, +a corrupt identity file, and unwritable identity persistence; none may produce +a key-management workflow or prevent the ephemeral fallback from participating +for that run. -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. +Run all standard checks, the complete peer-CLI suite, fresh three-peer manual +scenarios, production builds/bundles on supported platforms, and a final audit +for: -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. +- raw remote manifests reaching storage; +- unhashed transfer completion or CRC32 presented as malicious-source proof; +- the shared repository TLS private key; +- address-only outbound connections or fabricated peer IDs; +- direct mutation from mDNS, inbound Hello, deltas, or change hints; +- relayed third-party Call-to-Play history or permanent tombstones; +- signed control envelopes, nonce/replay tables, keyring/backup/repair code, or + per-peer source authorization reappearing without a new product requirement; +- silent protocol-version failure; and +- user wording that calls a peer, display name, or executable “trusted” merely + because TLS or a hash check succeeded. -### Phase 2 — responder-authenticated transport and endpoints (protocol 8) +## 7. Success criteria -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. +The plan is complete when the following statement is true from a user's point +of view: -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. +> I opened Lanspread at a LAN party, immediately saw the people and games +> nearby, downloaded from all matching peers without approving devices, and +> Lanspread itself rejected any wrong data. I never had to know that it owns a +> TLS key. -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. +From the implementation point of view, that experience rests on only three +security boundaries: confined local paths, catalog-owned content hashes, and +responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model +and remains ephemeral instead of becoming a second distributed security +protocol.