Files
ddidderr e0eafa6e33 docs(peer): document authenticated sharing architecture
Update user and developer documentation for the protocol-8 system: persistent
SPKI-derived identities, exact catalog ContentId authority, pinned responder
pulls, structured runtime ownership, direct-author Call to Play, and the global
local-network sharing switch.

Remove active descriptions of repository-wide certificates, pushed deltas,
relayed histories, and metadata consensus. Keep operational and UI boundaries
aligned with the implementation, including the fail-closed production catalog
gate.

Test Plan:
- `just fmt` (passed)
- `git diff --cached --check` (passed)
2026-08-10 14:05:00 +02:00
..

lanspread-peer

lanspread-peer is the networking runtime that lets Lanspread nodes find each other on the local network, exchange library metadata, and transfer game files. It is designed to run headless other crates (most notably lanspread-tauri-deno-ts) embed it and drive it through a channel-based API.

Runtime Overview

  • start_peer(game_dir, tx_events, peer_game_db, unpacker, catalog) boots the asynchronous runtime in the background and returns a PeerRuntimeHandle whose sender controls the peer. The injected Unpacker keeps archive extraction out of the peer crate's platform layer, and the immutable CatalogBundle gates which local game roots are announced or served.
  • PeerCommand is the UI-facing control surface for complete library views, exact-content downloads and installs, game-directory changes, direct typed endpoint connections, and locally authored Call-to-Play intents.
  • PeerEvent enumerates everything the peer runtime reports back to the UI: wholesale remote-library and Call-to-Play views, download/install/uninstall lifecycle updates, runtime failures, and authenticated peer membership changes.
  • PeerGameDB collects metadata only for TLS-authenticated peer endpoints. It records the endpoint generation, runtime session, per-domain revisions, and exact GameAvailability { game_id, content_id } rows supplied by that pinned responder. It does not accept remote file manifests or descriptions.

Internally the peer runtime owns five coordinated services that run for the lifetime of the process:

  1. Server component (run_server_component) listens for QUIC connections, advertises via mDNS, and serves bounded Ping, Hello, change-hint, exact-content chunk, and Stream Install requests.
  2. Discovery loop (run_peer_discovery) uses the lanspread-mdns helper to observe candidate endpoints. A dedicated joinable OS thread owns the blocking browser. Its nonblocking ingress queue and the async set of active candidate negotiations are each bounded at 64; repeated observations that overflow ingress are coalesced by dropping the hint.
  3. Ping service (run_ping_service) periodically issues QUIC ping requests to keep peer liveness up to date and prunes stale entries from PeerGameDB. Every probe captures the authenticated endpoint generation; success, failure, and stale removal are conditional on that exact endpoint and generation, so delayed work cannot affect a reauthenticated peer.
  4. Local game monitor (run_local_game_monitor) polls a bounded non-recursive metadata snapshot of the configured game directory and its direct game roots once per second, gates per-ID rescans while operations are active, emits local-library changes separately from active-operation snapshots, and runs a 300-second fallback scan for broader reconciliation.
  5. State-sync scheduler (run_state_sync) coalesces lossy revision hints, compares authenticated Pong revisions, and performs bounded Hello pulls from exact pinned responders. Its queue and peer table are each capped at 64 entries, with at most eight pulls and eight hint sends running concurrently.

scan_local_library maintains a lightweight on-disk index and produces both a GameDB and protocol summaries. A game is downloaded only when its root-level version.ini sentinel exists; local/ being a directory is the install signal.

Networking and File Transfer

  • Transport is handled by s2n-quic with its rustls provider. Every outbound operation requires a typed PeerEndpoint { peer_id, addr } and TLS 1.3 pins the responder's complete DER SPKI to that expected ID before delegating the real CertificateVerify signature to rustls. ALPN is lanspread/8; resumption, tickets, 0-RTT, and half-RTT data are disabled.
  • Protocol messages are strict, fallible JSON structures defined in lanspread-proto::{Request, Response}. Protocol 8 is the only accepted wire shape. A control stream carries exactly one request frame followed by EOF and at most one response; frames are capped at 8 MiB and control I/O is bounded by ten-second deadlines.
  • File transfers stream raw bytes over dedicated bidirectional QUIC streams. peer::send_game_file_chunk services catalog-authorized ranges. The bounded server admits at most 64 unauthenticated handshakes, 64 established connection scopes, and 32 control-stream tasks per connection.

Each installation owns one Ed25519 key and one self-issued certificate. PeerId is lowercase unpadded RFC 4648 base32 of BLAKE3 over the certificate's complete canonical DER SPKI. The desktop app loads or creates the strict version-1 peer-identity-v1.json record in Tauri's app-data directory. Record loading checks the exact version, algorithm, canonical base64, size bounds, certificate/key match, derived SPKI ID, and exact SAN. Opens and publication do not follow symlinks or reparse points, creation does not clobber an existing winner, corrupt bytes are preserved in quarantine before replacement, and Unix private material is mode 0600.

Read, permission, quarantine, or write failure returns a typed Ephemeral persistence outcome and uses a fresh identity for that runtime. The peer CLI's --identity-file selects an existing record strictly and never repairs, quarantines, replaces, or generates that explicit path. Identity persistence supports one normal app-data owner and has no cross-process lease; a concurrent same-user writer can cause availability or continuity loss, but retained no-follow handles contain traversal and publication never overwrites a concurrent winner.

Responder-owned state and Call to Play

Hello is an empty pull request. A successful pinned responder returns only its own PeerStateSnapshot: one runtime-session ID, a revisioned local library slice, and a revisioned local Call-to-Play author slice. The receiver validates the library and Call-to-Play domains independently and commits them only while the authenticated endpoint generation is still current. A peer never relays another peer's library or Call-to-Play state.

Local changes send cheap LibraryChanged or CallToPlayChanged revision hints. Hints are untrusted and lossy: an unknown claimed peer is ignored, and a known hint can only coalesce a full pull from that peer's pinned endpoint. Pong carries the responder's runtime session and both revisions, so idle liveness also heals a dropped hint.

Call-to-Play is stored as bounded per-author slices. The core, not the UI, creates event/call nonces and timestamps. Wire events contain a typed CallId, event ID, timestamp, and action; author identity comes from the pinned responder and the display name belongs to the author snapshot. Whole snapshots replace a newer revision from the same runtime session. Participant events may be retained but remain hidden until the call creator's direct author slice is present; no event push, acknowledgement, union merge, relay, or tombstone path exists.

Download Pipeline

When the UI asks to download a game:

  1. The UI submits only the game ID. The peer core loads that game's immutable CatalogContentManifest and constructs one root-confined ValidatedDownloadManifest before any filesystem mutation. Remote file descriptions do not choose local paths, sizes, chunks, or hashes.
  2. Source selection requires a peer to advertise the exact catalog (game_id, content_id) pair. Every chunk request repeats that ContentId, a typed canonical catalog path, and the exact catalog range; a version string alone never makes a peer eligible.
  3. download_game_files recovers any earlier attempt, parks an old version.ini as .version.ini.discarded, and durably journals the exact old and proposed downloader-owned file sets and their catalog content IDs before preparing non-sentinel files. It then emits PeerEvent::DownloadGameFilesBegin and builds a per-peer plan (build_peer_plans) that round-robins catalog chunks across the eligible peers.
  4. Each plan is executed in its own task (download_from_peer). Chunk requests use per-chunk QUIC streams and write into pre-created files. Every chunk, including version.ini, must have the catalog length and BLAKE3 digest. One absolute ten-minute deadline covers opening, requesting, receiving, and the post-receive checks for each ordinary chunk.
  5. DownloadProgressTracker samples byte counters, transfer speed, and the number of unique peers that are actively streaming chunks. The Tauri UI sees those values together through the regular download-progress event.
  6. Integrity failures quarantine the runtime-local (PeerId, content_id) pair; changing the peer's address does not clear it. Transport failures remain retryable and do not create durable trust state. A failed chunk is tried at most once against every distinct eligible peer identity, with no separate numeric retry cap.
  7. version.ini chunks are buffered in memory. After transfer, paths owned by the previous successful download but absent from the new manifest are removed. Payload files and their directories are synced before the new sentinel is committed last via .version.ini.tmp followed by an atomic rename. A sentinel rename whose directory sync fails leaves ownership pending for recovery instead of being reported as a durable success.
  8. Failure, cancellation, and startup recovery use the journal to remove only exact downloader-owned files. Unknown user files, local/, and install transaction state are preserved. A regular version.ini beside a pending journal proves that the final rename landed; otherwise recovery aborts the incomplete payload.
  9. After a successful sentinel commit, PeerEvent::DownloadGameFilesFinished is emitted and the peer auto-runs the install transaction.

The no-transfer local shortcut requires both a catalog-matching local sentinel and settled ownership whose committed content ID exactly matches the requested catalog manifest. A version string or pre-content-ID ownership record alone is never treated as verified local content.

Streamed Install Pipeline

Low-disk installs use PeerCommand::StreamInstallGame instead of the normal archive download pipeline. The peer core loads the catalog's extracted-file manifest, rejects games that do not have one, selects sources advertising the exact catalog ContentId, and emits the regular download/install lifecycle events while streaming archive-expanded bytes directly into an isolated StreamedInstallTransaction. Every Stream Install control-frame path, including archive names, is a CanonicalCatalogPath; malformed framing is distinct from an explicit sender error.

The sender admits Stream Install only when its catalog authorizes the feature and the direct regular root .eti set exactly matches the catalog archive set; missing, extra, or unsupported archives are rejected before provider work begins. The receiver independently requires the exact archive set and the complete catalog path/kind/size set, hashes every regular extracted file with BLAKE3 while writing, and rejects any missing, unknown, repeated, or mismatched file. Repeated directory entries are harmless and may appear across archives, but a regular extracted path may appear only once globally.

The sender-side StreamInstallProvider writes control and chunk frames through a cancellable StreamInstallFrameSink. If the QUIC writer fails because the receiver cancelled or disconnected, the sink wakes any producer blocked on the bounded frame channel and lets the transfer guard drop normally.

Each failed peer attempt rolls back its staging directory before trying the next validated peer. A transaction that created a previously missing game root removes that root again when rollback leaves it empty. Once staging has been fully verified, the account, language, and persona settings are rewritten in that staging tree before it is promoted to local/. The one-shot settings marker is written only after successful promotion; if that marker write fails, first play safely retries the rewrite. Post-promote intent cleanup failures are logged for startup recovery rather than reported as a failed install.

PeerCommand::CancelDownload cancels the tracked download token for an active transfer. The transfer task remains responsible for clearing active_operations, discarding partial payload files, and refreshing the settled local snapshot, so the UI continues to treat active-operation snapshots as the single source of truth for whether a download is still running.

Install Transactions

Install, update, uninstall, and install-side startup recovery live under src/install/. Install-side operation intent is stored atomically under the configured peer state directory, at games/<game_id>/install_intent.json. Game roots still use Lanspread-owned .local.installing/ and .local.backup/ directories marked by .lanspread_owned. Startup recovery combines the recorded intent with the observed filesystem state and only deletes reserved directories when intent or marker ownership proves they belong to Lanspread.

Download provenance is stored separately under games/<game_id>/download_ownership/v1-<root_digest>/record.json in the peer state directory. Each namespace is derived from the canonical configured games directory, while the record retains the full lossless root identity for collision and binding checks. Switching roots therefore preserves independent recovery and removal authority for the same game ID in each tree. Downloaded- file removal is deliberately separate from uninstall: it refuses installed or in-flight roots, journals an empty pending generation, and deletes only the regular sentinel plus paths proven by the last committed ownership set. Unknown files, directories, and the game root remain untouched.

Ownership schema 2 binds committed and pending file generations to their exact catalog content IDs. A former singleton ownership record is migrated once into its derived root namespace using copy-first durable publication; it is not kept as a second live format. Split or conflicting migration evidence fails closed.

Legacy launcher-owned files in game directories are migrated by a dedicated pre-start phase. Normal install, recovery, scan, and transfer paths use only the configured state directory for launcher-owned metadata.

Integration with lanspread-tauri-deno-ts

The Tauri application embeds this crate in crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs:

  • LanSpreadState holds onto the peer control channel, the latest aggregated GameDB, per-game operation state, the immutable catalog bundle, and the user-selected game directory.
  • The Tauri commands (request_games, install_game, update_game, remove_downloaded_game, and update_game_directory) translate UI actions into PeerCommands. Tauri loads and validates the packaged game.db plus companion manifests once during setup; update_game_directory validates the filesystem path before storing it, starts the peer runtime on demand with that same bundle, and mirrors installed/uninstalled state into the separate UI-facing database.
  • A background task consumes PeerEvents and fans them out to the front-end via Tauri publish/subscribe events (games-list-updated, game-download-*, game-install-*, game-uninstall-*, peer-*). The Tauri crate now only provides the unrar sidecar through the injected Unpacker; rollback and cleanup live in the peer transaction code.

Security & Operational Notes

  • Outbound TLS authenticates the exact responder endpoint. Inbound hints carry no authority, state snapshots are accepted only from a pinned pull, and file requests are admitted against the sender's local catalog and exact ContentId. There is no address-only dial or repository-shared certificate.
  • mDNS is a link-local source of bounded candidates, not roster authority. A candidate reaches peer/UI/library state only after the pinned outbound handshake commits its still-current negotiation lease.
  • Long-running blocking mDNS calls are isolated on an owned worker thread whose shutdown is explicitly joined.
  • File writes are catalog-verified per chunk, and root-level version.ini is buffered and written only after the rest of the download has succeeded.

Known Limitations

  • PeerGameDB currently models the latest metadata that other peers advertise. If the UI needs to surface titles that only exist locally, additional merging with the locally scanned GameDB will be required.
  • The download planner uses a simple round-robin and does not yet take per-peer throughput into account when distributing initial work.

Refer to the source (particularly src/lib.rs) for the exact message shapes and state machines.