Record the completed protocol-8 implementation, its security and lifecycle decisions, and the final local acceptance evidence. Mark protocol-7 Call to Play relay reviews as historical so they cannot be mistaken for current design. Keep production acceptance honest by recording the unavailable canonical 186-game manifest corpus, real Windows/NTFS confinement and durability proof, and representative physical-LAN evidence as external release prerequisites. Test Plan: - `just fmt` (passed) - `just test` (passed; 708 workspace tests, including peer 480 and Tauri 56) - `just clippy` (passed) - `just frontend-test` (passed; 91/91) - `just build` (passed; fixture-backed no-bundle build) - `LANSPREAD_S37_MIN_MIB_PER_S=100 just peer-cli-tests` (passed; S1-S49) - `git diff --cached --check` (passed)
629 lines
36 KiB
Markdown
629 lines
36 KiB
Markdown
# Pragmatic LAN safety, peer identity, and content integrity
|
|
|
|
## Status
|
|
|
|
The code and local test surface described by Phases 1-5 are implemented in this
|
|
checkout. The standard Rust, Tauri, and frontend gates pass, and the current
|
|
fresh-image S1-S49 Docker matrix is recorded in
|
|
`organize/testing/PEER_CLI_SCENARIOS.md`. This is not yet full
|
|
production-release acceptance because three external evidence gates remain:
|
|
|
|
- the canonical 186-game package corpus and production manifests are absent, so
|
|
the production catalog and bundle gates remain fail-closed;
|
|
- no run on a supported Windows/NTFS system yet proves reparse-point confinement
|
|
and file/directory durability; and
|
|
- no representative physical-LAN three-peer and throughput run has been
|
|
recorded. Docker-host throughput is local acceptance evidence only.
|
|
|
|
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 was implemented first because it fixed 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.
|
|
|
|
Historically, this validator contained protocol-7 remote descriptions through a
|
|
narrow adapter that required and removed exactly one matching leading `game_id/`
|
|
component. The current protocol constructs the same validated type directly from
|
|
the bundled content manifest; remote descriptions no longer define local paths.
|
|
|
|
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
|
|
|
|
The coordinated cutover replaced protocol 7 with current protocol 8. There is no
|
|
intermediate 8/9/10 design and no 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.
|