improve PEER_AUTH_PLAN.md and re-organize files
This commit is contained in:
@@ -0,0 +1,615 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user