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)
This commit is contained in:
2026-08-10 14:05:00 +02:00
parent 4a1b08db98
commit e0eafa6e33
4 changed files with 465 additions and 260 deletions
+169 -78
View File
@@ -10,37 +10,45 @@ It is designed to run headless other crates (most notably
- `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 catalog set gates which local game
roots are announced or served.
- `PeerCommand` represents the small control surface exposed to the UI layer:
`ListGames`, `GetGame`, `FetchLatestFromPeers`, `DownloadGameFiles`,
`StreamInstallGame`, `InstallGame`, `UninstallGame`, `RemoveDownloadedGame`,
`CancelDownload`, `SetGameDir`, and `GetPeerCount`.
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:
library snapshots, download/install/uninstall lifecycle updates, runtime
failures, and peer membership changes.
- `PeerGameDB` collects remote peer metadata. It aggregates discovered peers
`Game` definitions, tracks the latest ETI version per title, and keeps the
last seen list of `GameFileDescription` entries for each peer.
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 four long-lived tasks that run for the lifetime
of the process:
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 `Request::ListGames`, `Request::GetGame`,
`Request::GetGameFileData`, `Request::GetGameFileChunk`, and
`Request::StreamInstall` by reading from the local game directory.
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 discover other peers. The blocking mDNS work is executed on a dedicated
thread via `tokio::task::spawn_blocking` so that the Tokio runtime remains
responsive.
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`.
4. **Local game monitor** (`run_local_game_monitor`) watches the configured
game directory and each game root non-recursively, 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 missed events.
`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
@@ -48,65 +56,134 @@ of the process:
## Networking and File Transfer
- Transport is handled by [`s2n-quic`](https://github.com/aws/s2n-quic); TLS
cert/key material is compiled in from the repository root.
- Protocol messages are JSON-encoded structures defined in
`lanspread-proto::{Request, Response}`.
- Transport is handled by [`s2n-quic`](https://github.com/aws/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_data` sends entire files, while
`peer::send_game_file_chunk` services ranged requests.
`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 first issues `PeerCommand::GetGame` for a new download, or
`PeerCommand::FetchLatestFromPeers` for an update that must bypass local
archives. The selected peers are queried via
`request_game_details_from_peer`, and their file manifests are merged inside
`PeerGameDB`.
2. Once the UI receives `PeerEvent::GotGameFiles`, it requests the download by
game ID only. The peer core validates every source manifest before consensus,
chooses the complete authoritative description, and constructs one
root-confined `ValidatedDownloadManifest` before any filesystem mutation.
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 before preparing non-sentinel files.
It then emits `PeerEvent::DownloadGameFilesBegin` and builds a per-peer plan
(`build_peer_plans`) that round-robins file chunks across the available peers
that advertise the latest version.
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. The chunk writer
keeps existing data intact and only truncates when we intentionally fall back
to a full file transfer, which prevents corruption when multiple peers fill
different regions of the same file.
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. `version.ini` chunks are buffered in memory. After transfer, paths owned by
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. Transfer
failures are accumulated and retried (up to `MAX_RETRY_COUNT`) via
`retry_failed_chunks`.
7. Failure, cancellation, and startup recovery use the journal to remove only
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.
8. After a successful sentinel commit, `PeerEvent::DownloadGameFilesFinished` is
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 owns the whole operation: it refreshes
file metadata from catalog-version peers, runs the same majority file-size
validation used by normal downloads, selects a validated peer list, and emits
the regular download/install lifecycle events while streaming archive-expanded
bytes directly into a `StreamedInstallTransaction`.
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
@@ -116,7 +193,10 @@ 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
renamed to `local/`, post-promote intent or launch-settings cleanup failures are
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
@@ -135,15 +215,22 @@ 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 at
`games/<game_id>/download_ownership.json` in the peer state directory. It is
bound to the canonical configured games directory so switching library roots
cannot make an old record authorize deletion in a different tree. Downloaded-
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.
@@ -154,14 +241,15 @@ 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 catalog set, and the user-selected
game directory.
`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 `PeerCommand`s. In particular, `update_game_directory` validates the
filesystem path before storing it, loads the bundled catalog on first use,
kicks off the peer runtime on demand, and mirrors the installed/uninstalled
state into the UI-facing database.
into `PeerCommand`s. 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 `PeerEvent`s 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
@@ -170,14 +258,17 @@ The Tauri application embeds this crate in
## Security & Operational Notes
- All QUIC connections are TLS encrypted; the shipped certificates are suitable
for local-network trust but should be rotated for production deployments.
- Peer discovery is restricted to the local link via mDNS.
- Long-running blocking mDNS calls are isolated on dedicated threads which keeps
the async runtime responsive even when discovery takes a long time.
- File writes are chunk-safe: partial chunk downloads open files without
truncating existing data, and root-level `version.ini` is written only after
the rest of the download has succeeded.
- 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
@@ -185,7 +276,7 @@ The Tauri application embeds this crate in
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 or failures into account when distributing work.
throughput into account when distributing initial work.
Refer to the source (particularly `src/lib.rs`) for the exact message shapes and
state machines.