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:
+169
-78
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user