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

35 KiB

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:

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:

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:

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:

CallId { creator: PeerId, random_nonce }

CallToPlayAuthorSnapshot {
    runtime_session_id
    revision
    display_name
    events[]              // actor ID is not a wire field
}

The local core creates call/event IDs, increments the revision after each accepted local action, and sends a cheap change hint to known peers. A receiver coalesces the hint, connects to the author's known PeerEndpoint, and pulls that author's complete current slice. Because the responder is pinned, the receiver assigns the author ID itself. A peer cannot put another actor ID into the wire object.

Snapshots use replacement, not union/CRDT semantics. For each peer, permit one in-flight refresh; a newer revision for the same runtime session replaces that author's previous slice atomically. A new runtime session replaces the old session after a fresh pinned handshake. Stale concurrent results cannot overwrite the current session.

Authority rules remain simple:

  • Create, Start, Cancel, and AddTime are effective only when the pinned author equals CallId.creator.
  • RSVP, ready/leave, and chat actions are attributed to the pinned author. They become effective only while the referenced creator root is directly present; an author slice pulled before its creator is retained within its ordinary bound but remains hidden until that creator's direct pull arrives.
  • A snapshot contains only events authored by its responder. Third-party events are rejected rather than relayed.
  • Display names never grant authority.

A newly arriving peer discovers and pulls directly from every live peer, so it reconstructs calls from the people still present. If an author's peer goes away, remove that author's slice. If the creator goes away, the call disappears from the derived view. A participant who leaves naturally drops out. This is the intended session model, not data loss.

Keep the useful human-scale timers: active calls expire, unresolved expired calls may remain visible for five minutes, and Start/Cancel results may remain visible for fifteen minutes. After that, the author drops them from its current snapshot. There are no session-long tombstones, rootless terminal records, three-day history horizons, verification caches, or permanent anti-resurrection state because no third party can replay an old author's history as authority.

Retain straightforward schema and resource limits: bounded strings/chat, bounded events and encoded bytes per author, bounded total live peers, and a named control-frame maximum. Validate one author's snapshot off to the side and accept or reject it as a unit; a bad/oversized peer cannot consume another author's slice or the local author's capacity. Exact limits are set from the existing three-peer and stress fixtures, not from an internet-scale adversary model.

A malicious creator can show inconsistent versions of its own noncritical call to different peers. This plan accepts that limit rather than adding signatures, gossip, consensus, or permanent storage to a party invitation feature.

3.6 Keep the UI about games and people

Add one visible Local network sharing setting, on by default for this LAN-sharing application. When off, stop mDNS advertisement/discovery, the QUIC listener, outbound refresh, and serving. The setting is durable and its state is obvious in the main UI/settings.

Do not add per-peer source prompts. Every peer with the locally expected content_id is an eligible swarm source; verification is automatic.

Normal UI uses display names and peer count. A short PeerId suffix may disambiguate duplicate names or appear in diagnostics, but there are no New, Trusted, key-changed, backup, repair, or fingerprint-confirmation workflows.

User-facing exceptional states are concrete:

  • Verifying downloaded chunks while newly received content is checked;
  • A source sent invalid data; retrying another nearby peer when recovery is in progress;
  • No nearby peer could provide the verified catalog version after all matching sources fail;
  • Nearby devices are running a different Lanspread version when mDNS sees an incompatible protocol; and
  • a non-blocking networking diagnostic if the installation identity cannot be persisted and will change next launch.

Do not ask the user to solve a cryptographic implementation problem.

4. One protocol cutover

Develop the pieces behind internal APIs, then replace protocol 7 with one new current protocol (protocol 8 if the version has not moved). Do not ship intermediate protocol 8/9/10 designs and do not add compatibility decoding.

The cutover includes:

  • PeerId derived from the TLS SPKI and PeerEndpoint required by every outbound connection;
  • version-bound ALPN and per-installation server certificates instead of the repository-wide cert.pem/key.pem;
  • mDNS candidate-only semantics and useful incompatible-version telemetry;
  • responder-owned pull snapshots, revision-bearing Pong, and bounded change hints instead of inbound state-bearing Hello, pushed LibraryDelta, and pushed/relayed CallToPlayEvents;
  • cryptographic content_id in game availability and catalog-driven chunk requests;
  • canonical forward-slash catalog paths;
  • author-owned Call-to-Play snapshots; and
  • removal of Goodbye and payload fields that pretend to identify an authoritative sender.

Peers on another protocol remain excluded, as required by project policy. To reduce real LAN-party friction, make this one coordinated bump and surface the version mismatch rather than failing silently.

5. Code ownership

Keep the change inside existing crates unless implementation pressure proves a real reusable boundary; a new identity crate is not required by the design.

Area Responsibility
lanspread-db / lanspread-compat Catalog content-manifest types and loading beside game.db.
lanspread-proto PeerId, PeerEndpoint, content_id, pull snapshots, change hints, author-owned Call-to-Play wire types, and the one protocol version. No crypto or storage logic.
lanspread-peer::identity Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection.
lanspread-peer::network Per-endpoint rustls client config, full responder verification, ALPN, and no address-only connect.
discovery/handshake/liveness Candidate-only mDNS, pinned pulls, hint coalescing, revision reconciliation on ping, endpoint generations, and version-mismatch reporting.
peer_db Authenticated endpoint/state records and exact content_id source lookup.
download/storage/stream install Validated catalog plan, hash-as-received, source quarantine/retry, sentinel commit, and protected staging.
call_to_play Local author slice, per-peer replacement snapshots, simple authority checks, timers, and bounds.
Tauri/frontend Global sharing switch, verification/progress failures, incompatible-version notice, and replacement of the full derived Call-to-Play view.
peer CLI Distinct deterministic identities, hostile TLS/content modes, and zero-prompt multi-peer scenarios.

6. Implementation phases and gates

Every code phase runs just fmt, just clippy, and just test. Frontend or Tauri phases also run just frontend-test and just build. Network/transfer phases run focused peer-CLI scenarios during development and the unfiltered just peer-cli-tests before completion. Manual alpha/bravo/charlie evidence must use a freshly built image.

Phase 1 — land filesystem confinement immediately

Implement ValidatedDownloadManifest, make the UI submit only game_id, and move all validation before transaction/storage mutation. Centralize reserved paths and add the zero-mutation hostile tests from §3.1. Preserve current wire bytes in this phase; it is an independent safety fix.

Gate: standard Rust/Tauri checks, hostile descriptor tests, full peer-CLI suite, and supported Windows path/reparse evidence. Linux-only results must not be reported as Windows proof.

Phase 2 — establish real catalog content authority

Add the reproducible content-manifest generator and fixture manifests. Freeze the versioned manifest/content-ID encoding with golden tests. Extend local catalog state, build download plans only from that state, implement streaming BLAKE3 checks and source quarantine, and implement verified extracted manifests for Stream Install.

Do not claim completion from test fixtures alone: production catalog packages must have independently generated manifests, and the release/build path must reject a missing manifest. Benchmark hashing at normal LAN throughput.

Phase 3 — prove and implement simple responder identity

Start with a bounded rustls/s2n-quic spike that proves self-issued certificate support, SPKI extraction, expected-ID pinning, and real TLS 1.3 CertificateVerify. The certificate-A/private-key-B negative is the go/no-go gate. Choose Ed25519 or P-256 based on that proof, using one TLS identity key.

Then add simple load-or-generate persistence, deterministic CLI identities, PeerEndpoint, and endpoint plumbing through every outbound consumer. Separate mDNS candidates from authenticated peer records and make liveness removal generation-conditional. No trust database or identity UI is introduced.

Phase 4 — make the single wire cutover

Bump the current protocol once and activate all coupled wire behavior from §4: pinned transport, catalog content_id, catalog-driven downloads, pull-only library synchronization, bounded invalidation hints, author-owned Call-to-Play snapshots, and no Goodbye.

This phase is not complete until:

  • three fresh peers discover each other with no prompts and see post-start library changes;
  • a new peer reconstructs active Call-to-Play state by pulling every live author, and creator departure removes the call;
  • every metadata, chunk, retry, stream-install, healing, liveness, and direct CLI dial rejects the wrong key at the expected address;
  • a forged mDNS record or inbound hint cannot create/rebind/remove peer state, inject a library/Call-to-Play update, or bypass a pinned pull;
  • a change hint that is dropped, never sent, or rate-limited away still converges within PEER_PING_IDLE_SECS + PEER_PING_INTERVAL_SECS, and neither inbound traffic nor an in-flight content transfer defers that peer's revision check;
  • an honest multi-source download swarms automatically and commits only the catalog bytes;
  • one bad source is quarantined and another source completes the chunk;
  • all-bad/only-bad sources fail without committing version.ini or touching local/;
  • a streamed path/hash/set mismatch cannot promote staging;
  • an oversized Call-to-Play snapshot affects only that remote author and local publication still works; and
  • protocol-7 peers are rejected while the UI receives enough information to explain the version mismatch.

Update ARCHITECTURE.md, protocol docs, and CLI documentation in the same phase; do not leave the shared-certificate or relayed-event description behind.

Phase 5 — finish the small user-facing surface and audit

Add the global sharing switch and the concrete progress/error states from §3.6. Run a first-run test with an empty app-data directory, a normal restart, a corrupt identity file, and unwritable identity persistence; none may produce a key-management workflow or prevent the ephemeral fallback from participating for that run.

Run all standard checks, the complete peer-CLI suite, fresh three-peer manual scenarios, production builds/bundles on supported platforms, and a final audit for:

  • raw remote manifests reaching storage;
  • unhashed transfer completion or CRC32 presented as malicious-source proof;
  • the shared repository TLS private key;
  • address-only outbound connections or fabricated peer IDs;
  • direct mutation from mDNS, inbound Hello, deltas, or change hints;
  • relayed third-party Call-to-Play history or permanent tombstones;
  • signed control envelopes, nonce/replay tables, keyring/backup/repair code, or per-peer source authorization reappearing without a new product requirement;
  • silent protocol-version failure; and
  • user wording that calls a peer, display name, or executable “trusted” merely because TLS or a hash check succeeded.

7. Success criteria

The plan is complete when the following statement is true from a user's point of view:

I opened Lanspread at a LAN party, immediately saw the people and games nearby, downloaded from all matching peers without approving devices, and Lanspread itself rejected any wrong data. I never had to know that it owns a TLS key.

From the implementation point of view, that experience rests on only three security boundaries: confined local paths, catalog-owned content hashes, and responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model and remains ephemeral instead of becoming a second distributed security protocol.