616 lines
32 KiB
Markdown
616 lines
32 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 one ping
|
|
interval, with no new timer, no mDNS payload growth, and no periodic full-state
|
|
polling.
|
|
|
|
Freshness for that check must be measured from the local peer's own last pinned
|
|
exchange with the remote peer, never from inbound activity. The current
|
|
`ping_idle_peers` skips any peer whose `last_seen` was refreshed by traffic
|
|
arriving from it (`update_last_seen_by_addr`), so a peer that keeps talking to
|
|
us would suppress the very check that detects our staleness about it. Track the
|
|
last completed outbound pinned exchange separately from `last_seen`, which
|
|
continues to serve stale-peer pruning. 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 one ping interval, and inbound traffic from a peer does not
|
|
defer 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.
|