Files
lanspread/organize/planning/PEER_AUTH_PLAN.md
T
2026-08-09 17:10:31 +02:00

621 lines
35 KiB
Markdown

# Pragmatic LAN safety, peer identity, and content integrity
## Status
Revised implementation plan; not yet implemented.
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.
The normal user journey must remain:
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.
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 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. Product and architecture decisions
| Area | Decision | User-visible result |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 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 BLAKE3 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, and carry current revisions on the liveness ping that already runs so a lost hint self-heals. | 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. |
The resulting data flow is intentionally small:
```text
mDNS candidate -> responder-pinned TLS -> peer-owned snapshot or file bytes
bundled content manifest -> validated local download plan
-> BLAKE3 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
pinned liveness ping -> responder's own current revisions
-> pull that one responder only on mismatch
```
## 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 creation/truncation, preallocation, or cleanup. Storage functions accept
that validated type, never raw `GameFileDescription` values from Tauri or a
peer.
Validation is for the complete list and fails without any mutation. It must:
- require a known catalog `game_id` and resolve every destination relative to
exactly `<games_folder>/<game_id>`;
- 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 Tauri command supplies only the selected `game_id`. The peer core chooses
the complete authoritative plan. A UI-echoed file list is never authority.
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.
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.
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.
### 3.2 Make the bundled catalog the content authority
`game.db` is already the application's authority for game identity and version.
Add reproducibly generated per-game companion manifest artifacts, located at
`manifests/<game_id>.json` (loaded on-demand when downloading or serving a
game), and package them with both the desktop application and peer-CLI fixtures.
For each supported `(game_id, game_version)`, the manifest artifact contains:
```text
CatalogContentManifest {
schema_version
game_id
game_version
chunk_size
files: [
{ canonical_path, kind, size, file_blake3, chunk_blake3[] }
]
streamed_install_files: [
{ canonical_path, kind, size, file_blake3 }
]
content_id
}
```
Entries are sorted by canonical path. `content_id` is BLAKE3 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
matches Lanspread's 128 MiB transfer chunk.
The catalog publishing workflow must generate these per-game 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.
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 manifest. This replaces
remote manifest selection and majority-by-file-size consensus.
For ordinary downloads:
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 with BLAKE3 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/`.
No background disk-scanning or pre-hashing of existing files is required. Chunks
are verified strictly as they stream in during an active transfer.
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 BLAKE3 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.
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.
### 3.3 Use a simple installation-local TLS identity
The identity exists to bind a live peer and its changing address to TLS. It is
not exposed as a user credential.
- 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
`BLAKE3(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.
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.
### 3.4 Pin responders and make remote state pull-only
Every outbound operation accepts a first-class endpoint:
```rust
struct PeerEndpoint {
peer_id: PeerId,
addr: SocketAddr,
}
```
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.
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`.
Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
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.
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.
The protocol is deliberately responder-authenticated rather than wrapping every
message in a signature:
- 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.
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.
Hints are a latency optimization, never a correctness requirement. The liveness
ping that already runs is the reconciliation channel: `Pong` carries the
responder's own `(runtime_session_id, library_revision, call_to_play_revision)`.
The initiator compares them against what it has cached for that endpoint
generation and, on any mismatch or a new session ID, schedules exactly the
coalesced pinned pull a hint would have scheduled. A hint that was dropped,
never sent, or discarded by rate limiting therefore converges within
`PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS`, because the idle threshold is
only evaluated on interval ticks; that is 50s at the current 30s/20s settings,
not one interval. Assert against the constants rather than a literal bound.
Reconciliation still adds no new timer, no mDNS payload growth, and no periodic
full-state polling, and 50s is acceptable for the rare lost-hint case.
Freshness is tracked per peer as `last_revision_check`, stamped only by an
exchange that actually returned that peer's current revisions: a `Pong` or a
completed pull. Content transfers must not stamp it. A large download is a long
run of outbound pinned exchanges that carry no revisions, and it is exactly when
reconciliation must not be postponed. Inbound activity must not stamp it either:
`ping_idle_peers` currently gates on `last_seen`, which
`update_last_seen_by_addr` refreshes from traffic arriving from that peer, so a
peer that keeps talking to us would suppress the very check that detects our
staleness about it. `last_seen` keeps its existing stale-peer pruning role and
is not reused here. Inbound traffic is not evidence of freshness, for the same
reason it is not evidence of identity.
Revisions on `Pong` are a staleness signal, not content authority; the pull
remains the authoritative step. A responder that inflates its revision only
causes pulls of its own state, bounded by the same coalescing and rate limits. A
responder that understates it leaves the initiator stale about that responder
alone, which it could already achieve by changing nothing.
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.
Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated
removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles
departure.
### 3.5 Keep Call to Play direct and ephemeral
Call to Play is coordination among people currently at the party. It does not
need a Byzantine replicated ledger.
Each runtime owns only its locally authored slice:
```text
CallId { creator: PeerId, random_nonce }
CallToPlayAuthorSnapshot {
runtime_session_id
revision
display_name
events[] // actor ID is not a wire field
}
```
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.
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.
Authority rules remain simple:
- `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.
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.
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.
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.
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.
### 3.6 Keep the UI about games and people
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.
Do not add per-peer source prompts. Every peer with the locally expected
`content_id` is an eligible swarm source; verification is automatic.
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.
User-facing exceptional states are concrete:
- `Verifying downloaded chunks` while 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.
Do not ask the user to solve a cryptographic implementation problem.
## 4. One protocol cutover
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.
The cutover includes:
- `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, revision-bearing `Pong`, and 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.
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.
## 5. Code ownership
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 |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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, revision reconciliation on ping, 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. |
## 6. Implementation phases and gates
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.
### Phase 1 — land filesystem confinement immediately
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.
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.
### Phase 2 — establish real catalog content authority
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
BLAKE3 checks and source quarantine, and implement verified extracted manifests
for Stream Install.
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.
### Phase 3 — prove and implement simple responder identity
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.
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.
### Phase 4 — make the single wire cutover
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`.
This phase is not complete until:
- 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;
- a change hint that is dropped, never sent, or rate-limited away still
converges within `PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS`, and neither
inbound traffic nor an in-flight content transfer defers that peer's revision
check;
- 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.
Update `ARCHITECTURE.md`, protocol docs, and CLI documentation in the same
phase; do not leave the shared-certificate or relayed-event description behind.
### Phase 5 — finish the small user-facing surface and audit
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.
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:
- 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.
## 7. Success criteria
The plan is complete when the following statement is true from a user's point of
view:
> 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.
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.