PEER_AUTH_PLAN simplified drastically. GPT 5.6 Sol (ultra) was doing a great job, but with my poor prompting it completely overengineered.

This commit is contained in:
2026-08-09 12:47:14 +02:00
parent a886e64fc7
commit 8dae9dfe75
+471 -1305
View File
@@ -1,460 +1,271 @@
# Peer authentication, identity continuity, and download-source authorization
# Pragmatic LAN safety, peer identity, and content integrity
## Status
Architecture approved; implementation plan.
Revised implementation plan; not yet implemented.
This document incorporates the prior holistic review and subsequent review
dialogue. It replaces the previous version of this file and
supersedes the statement in `CALL_TO_PLAY_FIXES_PLAN.md` that cryptographic
peer identities should not be introduced.
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.
Nothing in this document is implemented merely because it is specified here.
Each phase below has its own implementation and acceptance gate. Phase 1a
scaffolding can proceed once this plan is accepted; persistent identity writes
wait for the identity transition table in §7.2 to be encoded in tests, and the
network runtime does not switch from the legacy UUID to the derived PeerId
until Phase 2's protocol bump.
The normal user journey must remain:
The project has one current wire version and no compatibility paths. Every
wire-changing phase bumps `PROTOCOL_VERSION`; peers on any other version are
rejected.
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.
## 1. Executive summary
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 design has six connected parts:
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. Each Lanspread runtime has a long-lived Ed25519 identity. `PeerId` is
derived from the public key; it is never a caller-supplied UUID.
2. Every outbound connection takes a `PeerEndpoint { peer_id, addr }`. TLS
pins the responder to that identity and performs real TLS 1.3
CertificateVerify validation. An address is never used to discover an
identity after connecting.
3. mDNS and inbound `Hello` messages are candidate hints. They cannot mutate
authenticated peer state. Listener ownership is proven by a bounded pinned
connect-back.
4. Control requests and responses use exact signed envelopes. Independently
relayed Call-to-Play events carry their own signatures, creator commitment,
and content-derived IDs.
5. User-visible continuity, blocking, and permission to download from a device
are separate trust decisions. New devices are not remote byte sources until
explicitly allowed.
6. Transfer-authoritative manifests and bytes come only from allowed,
responder-pinned identities. This is source authorization, not content
integrity: an allowed malicious device can still send hostile same-sized
bytes until catalog-owned hashes or signatures exist.
## 1. Product and architecture decisions
There is also one independent prerequisite. The current download preparation
path can truncate another game's `local/` data from an attacker-supplied file
description before any peer connection occurs. §6 specifies a standalone fix
that lands before persistent/runtime identity work.
## 2. Current security and correctness facts
- The repository-wide `cert.pem` and `key.pem` authenticate only “some build of
Lanspread.” Their private key is public, so they do not authenticate a peer.
- The current `peer_id` is a self-asserted UUID. mDNS, `Hello`, request bodies,
peer-table rebinding, liveness, and Call-to-Play authority ultimately trust
that string.
- `peer_db` can evict an existing address owner during a claimed collision.
Removing only that eviction branch would corrupt the `peers`/`addr_index`
invariant; the replacement must reject conflicts atomically.
- `handshake.rs` currently records advertised listener addresses rather than
consistently retaining the endpoint actually reached.
- Download, retry, streamed-install, healing, and direct-CLI paths sometimes
retain only an address or fabricate an address-derived peer ID. That is
incompatible with responder pinning.
- A custom rustls `ServerCertVerifier` has two separate duties: inspect and pin
the presented public key, and cryptographically verify TLS 1.3
CertificateVerify. Returning `HandshakeSignatureValid::assertion()` without
the second operation lets an attacker replay another peer's public
certificate with the attacker's private key.
- Call-to-Play snapshots are relayed. Authenticating the relay does not prove
the authorship of third-party events.
- Current terminal compaction intentionally retains session-long tombstones,
but rootless tombstone handling loses the selected terminal's order key and
fresh stores cannot ingest such tombstones correctly. Signed events must fix
that replication state, not claim replication remains untouched.
- The current active-history capacity tests let terminal actions reduce state,
but there is no hard total tombstone/byte bound and no reserve for ordinary
local publication.
- `LengthDelimitedCodec::new()` already has an 8 MiB default maximum; it is not
unbounded. Protocol-specific frame and batch limits still need to be explicit
and lower where the bounded payload permits.
- The GUI uses Tauri's `app_data_dir()`, not necessarily `~/.lanspread`.
Update and uninstall/reinstall persistence therefore depends on the platform
and packaging behavior.
- `prepare_game_storage` currently receives the whole games directory and may
call `create(true).truncate(true)` for a cross-game or protected `local/`
path before a source connection is attempted. This is a live data-loss bug,
independent of peer authentication.
## 3. Goals
1. Give every runtime a stable cryptographic identity whose public ID is
derived, not asserted.
2. Preserve that identity across normal updates and address changes, while
making storage failures and platform-dependent reinstall behavior honest.
3. Authenticate every selected responder and prove possession of its TLS
private key.
4. Remove address-only identity throughout the peer, metadata, download,
retry, streamed-install, healing, liveness, and CLI paths.
5. Attribute every state-changing control message to a verified key.
6. Make stored and forwarded Call-to-Play events independently verifiable.
7. Prevent a blocked author from regaining influence through an allowed relay.
8. Bound peer/authentication and Call-to-Play work without evicting permanent
anti-resurrection state.
9. Default remote download sources to user approval, while leaving public
browsing and outbound serving appropriate for a LAN party.
10. Expose identity continuity, new-key/name conflicts, blocking, source
permission, overload, and repair states clearly in the UI.
11. Land each negative security scenario with the phase that creates its trust
boundary.
## 4. Non-goals and accepted limits
- No PKI, accounts, CA, or internet service. Trust is self-certifying identity,
first-seen continuity, and explicit local policy.
- A key proves continuity, not a human name. “Alice” remains a signed
self-assertion; the UI shows the fingerprint and warns when a familiar name
appears under a new key.
- Authentication does not exclude a hostile guest. A fresh key is a valid new
identity. Default-deny source permission adds a human authorization step but
does not make that device's files trustworthy.
- No complete game-content integrity in this plan. Catalog-owned hashes or
signatures remain separate urgent work.
- No durable, global replay ledger. TLS prevents passive capture, recipient
binding prevents cross-peer reuse, and a bounded in-process nonce set
suppresses duplicates. Operations must still be idempotent or sequenced.
- No receiver-relative age expiry for stored signatures. Historical events and
session tombstones remain valid when relayed later.
- No Byzantine convergence guarantee after adversarial global capacity
exhaustion. Call-to-Play memory and merge work remain bounded; remote
availability and exact convergence may stop until the user
blocks/quarantines authors and resets or restarts party state.
- No comprehensive accept-connection/control-stream/disk-read/serve-operation
scheduler is added here. A hostile LAN host can still exhaust application
tasks below physical link saturation through anonymous public serving in the
intermediate phases. This is an explicit availability risk for separate
server-hardening work, not a guarantee hidden behind T9; the concrete new
amplification surfaces introduced by this plan remain bounded.
- No anonymity or metadata privacy. Peer IDs, keys, names, and library summaries
are public to the local segment by design.
- No promise that uninstall/reinstall preserves a file-backed identity on
every platform. The UI and docs describe the actual package behavior and
recommend an encrypted backup.
## 5. Threat model and resulting guarantees
The attacker is on the same L2 segment, can send arbitrary packets, run a
modified build, create many identities, and knows the repository's current TLS
private key. The attacker does not have code execution or OS-secret access on a
victim.
| # | Attack | Result after the owning phase |
| Area | Decision | User-visible result |
|---|---|---|
| T1 | Impersonate a selected responder | Requires the responder's private key. During Phase 2, inbound initiators are not authenticated, so their claims cannot mutate state. |
| T2 | MITM an outbound QUIC connection | Fails responder pinning and/or TLS 1.3 CertificateVerify. |
| T3 | Evict a peer remotely | `Goodbye` is removed. Conditional authenticated liveness is the only remote-removal path. |
| T4 | Rebind or collide a listener address | mDNS and advertised addresses are hints. Only a successful pinned dial binds an endpoint; a cross-ID collision is rejected without eviction. |
| T5 | Start, cancel, or extend another creator's call | Creator actions require the `CallRef.creator_key` signature. |
| T6 | Chat or RSVP as another participant | Participant actions require that participant's signature and a valid call root. |
| T7 | Reuse a captured control object | TLS prevents passive capture, local-recipient/context checks prevent cross-peer reuse, request/response correlation and an atomic bounded nonce set suppress duplicates. No durable universal replay guarantee is claimed. |
| T8 | Exhaust Call-to-Play state | One key cannot exceed its count/byte quota and remote traffic cannot consume the local reserve. Sybil keys can fill the bounded remote pool; overload is surfaced and availability/convergence are then explicitly not guaranteed. |
| T9 | Flood candidate/state admission | Candidate, live-peer, trust, nonce, and connect-back work/state are bounded and deduplicated. Generic accept-loop/link saturation remains an explicit availability limit. |
| T10 | Reuse a familiar display name under a new key | The UI retains the old pin, marks the key as new, and shows a name conflict. It does not claim the name is verified. |
| T11 | Serve hostile game bytes | An unapproved identity cannot become a manifest or byte source. An explicitly allowed malicious identity can still serve hostile content; catalog integrity is unsolved. |
| T12 | Steal a local identity | OS secret storage is preferred; file and explicit-file modes are labelled honestly. Local compromise remains out of scope. |
| T13 | Destroy another game's protected files through a manifest | The standalone manifest-confinement fix rejects the complete manifest before any filesystem or `version.ini` transaction mutation. |
| 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 SHA-256 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. | 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. |
## 6. Prerequisite: confine download preparation before persistent identity work
The resulting data flow is intentionally small:
This is a standalone safety fix, not a protocol or cryptography change, and it
lands before Phase 1b persistent identity work; Phase 1a may proceed in
parallel.
```text
mDNS candidate -> responder-pinned TLS -> peer-owned snapshot or file bytes
### 6.1 Validated manifest boundary
bundled content manifest -> validated local download plan
-> SHA-256 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
```
## 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 open/truncate/resize, or any other filesystem mutation. Storage accepts
only that validated type; neither Tauri nor a remote peer can pass raw
`GameFileDescription` values to it.
file creation/truncation, preallocation, or cleanup. Storage functions accept
that validated type, never raw `GameFileDescription` values from Tauri or a
peer.
For the entire list, validation must:
Validation is for the complete list and fails without any mutation. It must:
- require every descriptor's `game_id` to equal the requested game;
- resolve destinations relative to exactly `<games_folder>/<game_id>`, not the
whole games directory;
- at this standalone no-version-bump boundary, leave protocol-7 producer bytes
unchanged and normalize its current platform all-`/` or all-`\\` separator
form exactly once before validation; reject mixed
separators and ambiguous/empty components. Accept and discard only the
current exact redundant root descriptor
`{ relative_path: game_id, is_dir: true, size: 0 }`; reject every other empty
game-relative entry. All absolute, drive-qualified, UNC, NUL, `.`/`..`,
parent, cross-game, and non-normalized results are rejected;
- reject duplicate normalized paths and conflicting file/directory shapes;
- require directory size to be zero and exactly one regular root
`version.ini` where the existing transaction requires it;
- use one shared `is_reserved_game_path` policy in scanning, serving,
preparation, and discard. It rejects `local/`, `.local.*`, `.sync`,
`.lanspread/`, `.lanspread.json`, `.softlan_game_installed`,
`.version.ini.tmp`, `.version.ini.discarded`, and all current
version-transaction temporary/discard paths while allowing the one intended
root `version.ini`;
- on Windows, compare duplicates and reserved names case-insensitively and
reject alternate-data-stream colons, DOS device components, and trailing-dot
or trailing-space aliases before filesystem lookup;
- require the game root to be a direct non-symlink child of the games folder
and avoid following a symlink or reparse component at the destination; and
- enforce explicit descriptor-count, per-file-size, and aggregate-size bounds
chosen from real catalog measurements in Phase 0. Phase 2b also enforces
those bounds on raw remote manifests before retaining them in `peer_db`,
consensus state, or Tauri output.
- 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 UI supplies only `{ game_id }`; this plan does not support partial game
selection. The peer core selects the complete backend-authoritative manifest,
and only a complete successfully written payload may commit `version.ini` or
become installable. A UI-echoed description is never authority. If partial
downloads are introduced later, they need an explicit dependency closure and
must not commit the installable sentinel.
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.
Before committing `version.ini`, transactionally remove or quarantine every
download-owned, nonreserved path under the requested game that is absent from
the complete selected manifest. Preserve `local/` and all reserved transaction
state. This prevents a stale `.eti` from an older, failed, revoked, or
unapproved transfer from being installed or served alongside the newly
approved payload.
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.
### 6.2 Mandatory proof
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.
Tests put sentinel bytes in `OtherGame/local/save.dat` and the requested
game's `local/`, then submit malicious descriptors and prove that no existing
file changes and no new path is created. They also cover a valid first
descriptor followed by an invalid later descriptor, traversal/UNC/drive and
case variants, reserved paths, duplicates, root-shape errors, game-ID mismatch,
and symlink/reparse destinations.
They also seed an unlisted download-owned `stale.eti`, complete an authoritative
download, and prove that it is gone before installation/serving while every
protected `local/` sentinel remains.
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.
The gate is `just fmt`, `just clippy`, `just test`, `just build`, targeted
download/hostile-descriptor peer-CLI scenarios during development, and the
unfiltered `just peer-cli-tests` before completion.
### 3.2 Make the bundled catalog the content authority
## 7. Normative design
`game.db` is already the application's authority for game identity and
version. Add a reproducibly generated companion catalog artifact, for example
`content-manifests-v1.json`, and package it with both the desktop application
and peer-CLI fixtures.
### 7.1 Identity primitives, IDs, and crate graph
- Identity algorithm: Ed25519 using the existing AWS-LC cryptographic stack.
- `PeerId` is lowercase RFC 4648 base32 without padding of the full
`SHA-256(raw_ed25519_public_key)` digest: 52 fixed ASCII characters. It fits a
DNS label and retains 128-bit generic collision strength.
- UI fingerprints group the full ID for copying and show an unambiguous short
prefix only alongside a display name. Protocol policy never compares short
IDs.
- `lanspread-proto` owns dumb wire types and exact transcript builders:
`PeerId`, `PublicKey([u8; 32])`, `Signature([u8; 64])`,
`Nonce([u8; 16])`, `CallNonce([u8; 32])`, signed envelope/event types, and
explicit base64url serde adapters. It has no crypto or storage dependency.
- `lanspread-identity` depends on `lanspread-proto` and owns derivation,
signing/verifying, secret storage, and TLS identity material.
- `lanspread-peer` depends on both. `lanspread-proto` never depends on
`lanspread-identity`; there is no contradictory two-way dependency.
- Key, signature, nonce, and opaque payload JSON fields use
URL-safe base64 without padding and exact decoded lengths. They must not use
`Bytes`' default JSON numeric-array representation.
- Core code constructs all author, call, event, and message IDs. Frontend and
CLI commands express an action against a selected call; they do not supply
trusted identity or event-ID fields.
### 7.2 Identity storage is a state machine, not a fallback chain
#### Explicit modes
CLI flags take precedence over environment variables. Seed and file modes are
mutually exclusive; conflicting settings return a typed configuration error.
| Mode | Rule |
|---|---|
| `--identity-seed` or `LANSPREAD_IDENTITY_SEED` | Require one exact valid seed, never persist it, and do not read or modify default keyring/file/sidecar state. |
| `--identity-file` or `LANSPREAD_IDENTITY_FILE` | The named versioned secret file is the sole authority for that run. Missing, inaccessible, or corrupt means failure; creation requires a separate explicit operation. No fallback. |
| No explicit mode | Use the persistent-store table below. |
The versioned secret record contains at least `{ version, seed, created_at }`.
The reconstructible non-secret `<state_dir>/identity.json` contains
`{ version, peer_id, public_key, created_at, backend }`. Its ID and key are
always recomputed from the secret before use.
Persistent backend observations normalize to:
For each supported `(game_id, game_version)`, the artifact contains:
```text
Present(secret) | NoEntry | Locked | Denied | Unavailable | Corrupt
CatalogContentManifest {
schema_version
game_id
game_version
chunk_size
files: [
{ canonical_path, kind, size, file_sha256, chunk_sha256[] }
]
streamed_install_files: [
{ canonical_path, kind, size, file_sha256 }
]
content_id
}
```
Only `NoEntry` proves absence. An I/O error, locked store, denied access,
unavailable service, or corrupt record never authorizes generation.
Entries are sorted by canonical path. `content_id` is SHA-256 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 initially matches Lanspread's existing 128 MiB transfer
chunk so verification does not create a second chunking scheme.
#### Persistent transition table
The catalog publishing workflow must generate these 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.
| Sidecar | Backend observations | Required action |
|---|---|---|
| Valid | Selected backend is `Present` and derived key/ID match | Load only that backend; do not probe or switch. |
| Valid | Selected backend is missing, locked, denied, unavailable, corrupt, or mismatched | Stop the peer runtime and enter the corresponding typed repair state. Never fall through or generate. |
| Missing | Exactly one readable secret exists and every other supported backend conclusively reports `NoEntry` | Reconstruct the sidecar from that secret and load it. |
| Missing | More than one secret exists, even if identities match | Enter repair and require an explicit authoritative-backend choice. |
| Missing | Readable secrets derive different identities | `AmbiguousIdentity`; show backend names and public fingerprints, never seed material. |
| Missing | No secret exists and every supported backend conclusively reports `NoEntry` | Fresh install: create in the preferred keyring backend. |
| Missing | Any backend is locked, denied, unavailable, or corrupt | Enter repair; do not generate or fall through. |
| Corrupt | Exactly one readable supported-version secret exists and all others are conclusively absent | Reconstruct the derivative sidecar and load. |
| Corrupt | Otherwise | Enter repair. Even all-`NoEntry` does not silently generate because the sidecar proves prior state existed. |
| Unsupported sidecar or secret version | Any | Return `UnsupportedVersion` without probing beyond what identified the version and without writing anything. An older binary never reconstructs or overwrites newer state. |
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. This replaces remote
manifest selection and majority-by-file-size consensus.
On a genuinely fresh Linux system without Secret Service, the GUI may
explicitly offer “Create a file-backed identity.” Headless use chooses a seed
or explicit identity file. A failed keyring write is followed by a read probe;
it never immediately falls through because the write may have partly
succeeded.
For ordinary downloads:
The supported default keyring locator—service `network.paul.lanspread`, account
`peer-identity`—means one default identity per OS account. Multi-profile,
container, and simultaneous test identities use explicit files/seeds. If that
scope changes, Phase 0 must first define a deterministic, recoverable locator;
two stores must not silently implement different identity scopes.
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 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/`.
#### Locking and durability
Existing downloaded payloads are verified against the catalog in the
background before they are newly advertised or installed, with results cached
against the existing file fingerprint. The user sees ordinary “Verifying game
files” progress, not a security decision.
- The application identity session acquires an exclusive OS advisory lease
before its first backend probe and holds it through repair, import/reset,
peer-runtime stop/restart, and application exit. The lease exists even while
networking is stopped. File-backed state uses `<state_dir>/identity.lock`;
explicit identity files use an adjacent lease. The fixed OS-account keyring
locator uses one canonical account-wide lease independent of caller state
directory. Default persistent keyring use is supported only through that
canonical application profile; every other/headless profile uses an explicit
seed or identity file.
- Lock contention returns `IdentityBusy`; it never triggers generation.
- Creation/import/reset/backend migration validates input first. A file backend
writes a restrictive unique temporary file, flushes it, atomically renames
it over the selected record, and syncs the containing directory where
supported. A keyring backend uses the platform's atomic record replacement,
then reads it back and verifies the derived key and ID. Platforms that cannot
provide atomic record replacement do not enable in-place keyring
import/reset; they require explicit backend migration or backup/repair.
Initial creation and backend migration write and verify the new secret before
atomically writing the reconstructible sidecar; migration retires the old
backend only after the sidecar commits.
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 SHA-256 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.
Backend migration additionally uses a small non-secret
`identity.migration.json { version, from, to, target_fingerprint, stage }`
intent, written before touching the target and cleared only after sidecar
switch plus old-backend cleanup. When present, startup ignores the ordinary
valid-sidecar fast path, probes only the named source/target, and idempotently
resumes or explicitly rolls back. Before target write, `to` must be `NoEntry`
or contain the same identity; a different identity is a repair conflict, never
overwritten. This closes both crash windows without a generation-numbered
two-resource commit protocol.
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.
Every intent stage is durably updated by atomic rename. A corrupt or
unsupported intent is a typed non-mutating repair state and is never ignored.
Resume/rollback deletes a backend record only after rereading it and matching
the intent fingerprint.
### 3.3 Use a simple installation-local TLS identity
- A crash after secret write and before sidecar write is recoverable by the
missing-sidecar table. A replacement crash can yield old-sidecar/new-secret
mismatch; startup stops and offers explicit completion or backup restore.
No generation-numbered two-resource transaction is required.
- Same-backend keyring replacement may leave either old or new secret after a
crash; a sidecar mismatch is an explicit recoverable state, never a reason to
generate or fall back. The UI requires a verified encrypted backup before a
destructive in-place replacement. File replacement and backend migration do
not retire the old selected secret before the replacement is safely exposed.
- Phase 1b leaves the legacy UUID `peer_id` as the protocol-7 runtime/advertised
ID even after the cryptographic identity is durable. Phase 2's protocol bump
atomically activates the derived PeerId for networking and only then deletes
the UUID. It is never cryptographic continuity evidence.
The identity exists to bind a live peer and its changing address to TLS. It is
not exposed as a user credential.
The GUI uses Tauri `app_data_dir()`. `~/.lanspread` is only the core default
when no state directory is supplied. Docs provide an update/uninstall matrix
per package rather than promising universal reinstall survival.
- 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
`SHA-256(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.
#### Repair, backup, import, and reset
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.
Phase 1 ships a typed repair surface, not a log-only startup failure: retry and
unlock guidance, import backup, choose an unambiguous discovered backend,
deliberate file-backed creation on a genuinely fresh system, and deliberate
reset. The shell may start, but networking remains stopped until repair
succeeds.
### 3.4 Pin responders and make remote state pull-only
Export emits only a versioned authenticated passphrase-encrypted seed envelope.
Phase 0 freezes its magic/version, exact KDF and cost/salt encoding, exact AEAD
and nonce/tag encoding, authenticated header, and maximum decoded size before
Phase 1b implements it. Raw seed material never reaches frontend state, logs,
events, or errors. The UI calls this a **backup** and warns that importing it
creates two cryptographically indistinguishable devices. “Move” means verify
the destination fingerprint and deliberately retire/reset the source; stale
copies cannot be revoked without later key rotation. Reset shows old/new
fingerprints and explains that all remote continuity records are invalidated.
Import over an existing identity requires old/new fingerprint confirmation and
replaces the selected backend unless the user separately chooses backend
migration. Reset/import fails if that backend cannot be replaced or retired;
it never creates a fallback identity. Identity replacement first stops
networking, commits under the still-held application lease, rebuilds all
runtime identity/TLS state, and only then restarts networking.
### 7.3 Trust state and source permission
Trust is keyed by verified full `PeerId` and public key, never by display name
or address. A record contains at least:
```text
public_key
first_seen, last_seen
observed_names, pinned_name
acknowledged_at // null means persistently “new”
blocked // overrides every other policy
download_from // prompt | allowed | denied
rotated_from // later continuity feature
```
- First contact stores `download_from = prompt`, which is not eligible to
source a transfer. Only an explicit user action sets `allowed`.
- The UI wording is “Allow downloads from this device,” never “trust this
device/content.”
- A familiar name under a new key creates a separate new record and a conflict
warning. It never inherits acknowledgement, block, or source permission.
- Setting `blocked` atomically changes `download_from` to `denied`, suppresses
candidate retries, rejects direct authenticated protocol actions, and rejects
independently signed objects from that author even when relayed by an
allowed peer. Existing signed objects are quarantined inside the same bounded
CTP store, remain charged to the author/global quotas, are excluded from
reduction and relay, and preserve tombstone evidence. Unblocking leaves
source permission denied and triggers an authenticated resync before
quarantined state is reconsidered.
- Block, pin/acknowledgement, source permission, import, and reset are written
atomically and durably before success is reported. Only observational data
such as `last_seen` and name telemetry may be debounced.
- Final-state serving remains open to authenticated, nonblocked peers; party
admission can narrow that later. During Phases 23, inbound read-only
requesters are deliberately anonymous and serving remains public even to a
caller that might hold a blocked key. Direct requester blocking becomes
enforceable at the Phase 4 envelope boundary. This intermediate limitation
affects what others may pull from us, not local state mutation or which
devices we download from. Local/catalog-matching already-downloaded data
remains usable without any remote-source approval.
Hard trust/admission bounds prevent sequential Sybil growth: at most 1024
persistent trust records, eight observed names per record, and 256
unacknowledged relay/direct-new observations. A directly verified identity or
relay-only author does not create durable trust state when the relevant bound
is full; acknowledged, blocked, or explicitly source-authorized records are
never silently evicted. Relay-only authors remain bounded ephemeral metadata
unless the user acknowledges or blocks them or direct verified contact occurs.
The UI reports capacity and lets the user remove records deliberately.
### 7.4 First-class endpoints and discovery candidates
Every outbound operation accepts a first-class endpoint:
```rust
struct PeerEndpoint {
@@ -463,960 +274,315 @@ struct PeerEndpoint {
}
```
The endpoint is captured at discovery/source selection and carried through
handshake, library/manifest fetch, ordinary downloads, chunk plans, retries,
streamed install, healing, liveness, and direct peer-CLI operations.
Every outbound connection requires it or equivalent mandatory `(expected_id,
addr)` arguments.
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.
Delete `remote_peer::ensure_peer_id_for_addr`, fabricated `addr-*` IDs,
unique-IP identity fallbacks, and address-only direct connect. The peer-CLI
`ConnectPeer` operation requires both full ID and address or is removed; there
is no desktop TOFU bootstrap UI to design for that test-only command.
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`.
mDNS owns a bounded candidate directory separate from authenticated peer
records. Initial values are 256 candidates, 128 simultaneously verified live
peers, 120 new candidate insertions/minute globally, and 8/minute per
advertised target IP (the address exposed by the current mDNS wrapper, not an
authenticated packet source). Phase 0 confirms those values and candidate TTL/backoff against the honest
three-peer harness. At capacity, expired candidates are removed first; a new
candidate/live peer is otherwise rejected without evicting an authenticated
record. It may advertise `(peer_id, addr, public_key, revision hints)`, but a
key/ID consistency check does not prove possession or address control. mDNS
addition, expiry, or change never directly upserts, rebinds, removes, merges a
library, or refreshes authenticated liveness. A blocked ID is not retried.
Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
Candidate retries are deduplicated with jittered backoff while the
advertisement remains live. Reconciliation cannot depend on mDNS emitting a
second identical `ServiceResolved`; a still-live candidate can re-establish a
peer after transient failure or liveness removal.
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.
One shared socket predicate applies to mDNS, Hello, and Ack candidates: port is
nonzero; address is neither unspecified, multicast, nor IPv4 broadcast;
loopback is accepted only in explicit test mode; and IPv6 link-local addresses
carry the observed interface scope. Global IPv6 and private/ULA addresses are
not rejected merely because they are not syntactically “private”; interface
provenance and the bounded dialing policy define LAN reachability.
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.
### 7.5 Authenticated QUIC responder
The protocol is deliberately responder-authenticated rather than wrapping
every message in a signature:
Use the s2n-quic rustls provider with TLS 1.3 only.
- 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.
Phase 0 spikes RFC 7250 raw public keys end to end through
`s2n-quic-rustls`. If RPK works, use the identity public key directly. Otherwise
use a self-issued Ed25519 X.509 leaf whose SPKI is exactly the identity key.
CA, CN, SAN, hostname, and self-signature are not authentication inputs; the
expected `PeerId` is.
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.
The custom client verifier captures the expected `PeerId` from
`PeerEndpoint` and MUST:
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.
1. require the selected RPK/SPKI shape and allowed signature scheme;
2. extract the raw identity key;
3. derive its full `PeerId` and compare it to the expected value; and
4. cryptographically verify TLS 1.3 CertificateVerify against that presented
key using rustls/webpki's verification helper.
Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated
removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles
departure.
It may return `HandshakeSignatureValid::assertion()` only after step 4
succeeds. It must advertise only schemes it actually verifies. The load-bearing
negative test presents peer A's certificate/SPKI with peer B's private key and
requires the handshake to fail.
### 3.5 Keep Call to Play direct and ephemeral
RPK mode requires `requires_raw_public_keys() == true`, a raw-key server
resolver (`AlwaysResolvesServerRawPublicKeys` or equivalent), and
`verify_tls13_signature_with_raw_key`. X.509 mode uses the certificate-specific
signature helper. The selected mode and mismatched-mode failures are tested.
Call to Play is coordination among people currently at the party. It does not
need a Byzantine replicated ledger.
Disable TLS resumption/session tickets and QUIC 0-RTT/early application data.
Every connection performs current proof of possession; the in-memory nonce
cache is not durable replay protection. SNI remains
`<expected_peer_id>.lanspread`, but the verifier's captured full expected ID is
authoritative.
Client and server advertise exactly one version-bound ALPN:
`b"lanspread-peer/" || ASCII_decimal(PROTOCOL_VERSION)`. Any absent or different
ALPN fails the handshake. TLS 1.2 is disabled and
`verify_tls12_signature` always rejects. In RPK mode the transmitted key is the
canonical RFC 7250 Ed25519 DER SubjectPublicKeyInfo, while `PeerId` derivation
still hashes the extracted raw 32-byte key.
Keep P-256 TLS as a last fallback until the RPK/Ed25519 certificate spike
succeeds. A split P-256/Ed25519 design is allowed only if the verifier receives
and validates a required critical certificate extension before channel
acceptance. The Ed25519 signature input is exactly:
Each runtime owns only its locally authored slice:
```text
b"lanspread/tls-key-binding/v1\0"
|| u32_be(PROTOCOL_VERSION)
|| identity_key[32]
|| u32_be(tls_spki_der_length)
|| tls_spki_der[tls_spki_der_length]
```
CallId { creator: PeerId, random_nonce }
The verifier derives and matches the expected identity, verifies this binding,
then verifies CertificateVerify under the P-256 SPKI. A missing/tampered
extension or statement delivered later inside `Hello` is rejected. If this
pre-channel binding is not practical, the fallback is forbidden.
If X.509 is used, configure
`rcgen = { default-features = false, features = ["aws_lc_rs"] }` (plus `pem`
only if actually needed) so the plan does not silently add `ring` as a second
crypto stack.
### 7.6 Outbound binding, inbound Hello, and connect-back
A successful outgoing handshake records the endpoint actually dialled under
the pinned key. `HelloAck.listen_addr` and every other advertised address are
new candidates only; they never redirect the successful binding.
Phase 2 authenticates the responder, not an incoming client. Therefore an
inbound `Hello` is non-authoritative:
- protocol 8 `Hello` is candidate-only: current protocol, claimed ID/key,
listener, bounded revision/feature hints, and
`request_connect_back: bool`; it carries no library or CTP vector;
- perform only bounded cheap syntax/version checks;
- it may receive the responder-authenticated Ack and read-only public data
allowed by policy;
- do not upsert/remove/rebind a peer, merge its library or Call-to-Play state,
create trust continuity, or refresh listener reachability; and
- enqueue at most one bounded, deduplicated connect-back to the claimed
`PeerEndpoint` when `request_connect_back` is true.
Discovery's first probe sets `request_connect_back = true`. A connect-back or
ordinary pinned resync sets it to false and is terminal: its responder returns
the state-bearing Ack but never schedules another callback. Because inbound
Hello is non-authoritative in both modes, an attacker setting false gains no
state-mutation shortcut. Each side learns the other only from its own outbound
pinned connection, so the initial probe plus one callback establishes both
directions without recursion.
The connect-back requires the claimed ID as the TLS-pinned responder. Only its
authenticated response may merge state and bind the endpoint that was actually
dialled. A claimed target must be a nonzero unicast LAN socket whose IP equals
the inbound QUIC source IP. mDNS candidates use their separate bounded dialing
path; a forged advertisement is not callback authorization. Permit at most one
in-flight callback per endpoint, two per source IP, and 16 globally, with the
candidate admission rates above, deduplication, expiry, and jittered backoff.
These controls bound rather than eliminate LAN scan/reflection and
work-amplification risk. Hello, mDNS, and legacy library hints all feed one
coalescing scheduler with these per-source/advertised-target, per-PeerId, and
global budgets; blocked IDs are dropped before scheduling. A legacy library
hint can target only an already verified recorded `PeerEndpoint`; its claimed
address and delta body are ignored, and an unknown claimed ID is dropped.
Before independently signed events land, no client-originated state-changing
object is authoritative. A legacy library notification must trigger the
bounded pinned resync so post-start honest library updates still propagate;
legacy CTP networking is disabled for protocol 8. During
Phase 3, only independently verified signed Call-to-Play objects may mutate
their own store through an otherwise unauthenticated outer request; outer
sender/address claims remain non-authoritative. All other client-originated
state changes wait for Phase 4 envelopes.
Peer binding is a typed atomic operation. If an address is already bound to a
different ID, reject the candidate and preserve both maps and the established
peer. A same-ID move is accepted only after pinning the new endpoint and
establishing that the old record is no longer current/reachable, then both
indexes change atomically. If old and new endpoints simultaneously prove
possession of the same key, preserve the established binding, quarantine the
candidate, and surface a duplicate-identity/clone conflict instead of flapping
between them. An unproven collision never evicts anyone.
### 7.7 Liveness and removal
Remove `Goodbye` from the protocol, handlers, shutdown flow, threat claims,
and tests. Authenticated liveness expiry is the sole remote-removal mechanism.
Liveness pings take `PeerEndpoint` and pin the responder. Successful outbound
authenticated contact proves endpoint reachability. An inbound signed frame
may prove identity activity after envelopes land, but it does not prove that
the advertised listener is reachable and does not refresh that endpoint's
reachability timeout.
Every liveness task snapshots the peer-record revision together with the ID,
address, and reachability timestamp. The revision increments on every
successful endpoint-reachability refresh and every rebind, including activity
while an older probe is pending. A late success or failure may update or remove
only if the current record still has that revision, endpoint, and observed
reachability timestamp.
`remove_peer_if_current` or equivalent compare-and-remove logic prevents a
stale probe from deleting a rebound/reconnected peer or cancelling its active
downloads.
The same conditional comparison applies to asynchronous ping failures and the
periodic stale-prune sweep. Phase 2 removes pre-dispatch
`note_peer_activity`/address-based attribution entirely.
### 7.8 Exact signed control envelopes
Every control `Request` and `Response` uses:
```text
SignedEnvelope {
protocol_version: u32,
sender: PeerId,
sender_key: PublicKey,
recipient: PeerId,
nonce: Nonce,
context: request | response,
payload: opaque bytes,
signature: Signature,
CallToPlayAuthorSnapshot {
runtime_session_id
revision
display_name
events[] // actor ID is not a wire field
}
```
All fields are required; unknown fields are rejected. The inner request or
response is serialized once with `serde_json::to_vec`, stored as explicit
base64url payload bytes, signed, and verified before deserialization. The outer
JSON is not canonical and is not itself signed.
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.
The exact signature input is:
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.
```text
b"lanspread/control-envelope/v1\0"
|| u32_be(protocol_version)
|| context_tag // request = 0x01, response = 0x02
|| sender_ascii[52]
|| sender_key[32]
|| recipient_ascii[52]
|| nonce[16]
|| u32_be(payload_length)
|| payload[payload_length]
```
Authority rules remain simple:
Ed25519 signs these bytes directly. Receivers require the current
`PROTOCOL_VERSION`, exact fixed lengths, expected context/direction,
`recipient == local_peer_id`, `derive_peer_id(sender_key) == sender`, and a
valid signature before parsing the payload or dispatching a handler. A client
also requires `Response.sender` to equal the TLS-pinned responder. A response
sets its recipient to the verified request sender, copies the verified request
nonce, and the initiator requires equality, binding the pair.
- `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.
Boundary verification yields `VerifiedSender`; handlers no longer accept or
trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never
forwarded.
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.
The nonce table is per sender/context, bounded, checked-and-inserted atomically
only after signature verification, and in memory only. Cache loss or eviction
can cause a duplicate to be processed, so state-changing handlers remain
idempotent or revision/event-ID guarded. There is no wall-clock signature
expiry and no claim of durable replay prevention.
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.
Every request initiator generates a fresh 16-byte CSPRNG nonce and never
intentionally reuses it. The duplicate cache is capped at 64 entries per
sender/context, 256 represented senders, and 8192 entries globally; admission
of a new sender/cache entry is rejected when the applicable bound cannot be
met after normal LRU expiry. A response copies rather than generates the
request nonce.
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.
Bulk responder-to-initiator chunk and stream-install frames remain unsigned
inside the responder-pinned TLS channel. That deliberate asymmetry preserves
throughput. Every retry still uses the selected `PeerEndpoint`; TLS must never
fall back to “whoever is at this address.”
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.
The length-delimited control codec gets a named maximum below its current 8
MiB default after Phase 0 measures the largest bounded library/manifest
message. Any bounded payload that cannot fit must be paginated rather than
raising the frame indefinitely. The encoded outer-frame cap is enforced before
frame allocation; a bounded base64 visitor checks computed decoded length
before allocating the decoded payload. Both happen before signature work and
inner deserialization.
### 3.6 Keep the UI about games and people
### 7.9 Independently signed Call-to-Play events
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.
Call-to-Play objects land before general signed envelopes so relayed authorship
is independently safe as early as possible.
Do not add per-peer source prompts. Every peer with the locally expected
`content_id` is an eligible swarm source; verification is automatic.
```text
SignedCallToPlayEvent {
protocol_version: u32,
event_id: EventId,
author: PeerId,
author_key: PublicKey,
body: opaque CallToPlayEventBody bytes,
signature: Signature,
}
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.
CallToPlayEventBody {
call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce },
actor_name,
at,
action,
}
```
User-facing exceptional states are concrete:
The body excludes `actor_id`, `call_id`, `event_id`, and independent chat
`message_id`. The author is derived from `author_key`; the redundant outer
`author` must match. Core publication creates the 32-byte CSPRNG nonce for
`Create`, resolves the retained `CallRef` for later actions, chooses identity
and IDs, and signs. The frontend never supplies those authority fields.
- `Verifying game files` while existing or 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.
The exact event-signature transcript is:
Do not ask the user to solve a cryptographic implementation problem.
```text
b"lanspread/call-to-play-event/v1\0"
|| u32_be(protocol_version)
|| author_ascii[52]
|| author_key[32]
|| u32_be(body_length)
|| body[body_length]
```
## 4. One protocol cutover
The body encoding is UTF-8 RFC 8259 JSON with the exact field/action names in
the versioned proto schema, integer timestamps only, explicit URL-safe
unpadded-base64 adapters for `PublicKey` and the 32-byte `CallNonce`, and no
unknown or duplicate fields or trailing non-whitespace data. The local emitter
uses compact `serde_json` with declared struct-field order. Verification and
forwarding always use the exact received bytes and never reconstruct them; the
store retains body and signature verbatim. Fixed vectors freeze both the local
encoding and exact-byte verification behavior.
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.
Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32:
The cutover includes:
```text
call_id = H("lanspread/ctp-call/v1\0" || creator_key || create_nonce)
event_id = H("lanspread/ctp-event-id/v1\0" || u32_be(protocol_version)
|| author_key || u32_be(body_length) || body)
```
- `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 plus 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.
The receiver first requires `event.protocol_version == PROTOCOL_VERSION`, then
recomputes both IDs and verifies the signature before parsing/admission. The
event signature covers the transcript above. The content-derived event ID makes adversarial
same-ID/different-body conflicts unreachable; the enclosing `event_id` is also
the chat/UI message identity.
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.
Authority rules:
## 5. Code ownership
- `Create`, `Start`, `Cancel`, and `AddTime` require
`author_key == call_ref.creator_key`.
- `Respond`/RSVP, `Leave`, and `SendMessage` may be signed by a participant but
require a retained or same-batch valid `Create` for the same `CallRef`.
- A rooted nonterminal action, including `AddTime`, has reducer effect only
when it sorts strictly after canonical `Create` by `(at, event_id)`. A
correctly signed candidate with some valid root but currently before the
canonical Create remains retained/inactive within quotas so a later earlier
canonical Create can trigger deterministic recomputation. Only a
creator-signed `Start`/`Cancel` has root-independent burn semantics.
- A rootless `AddTime` or participant event is `NeedHistory`, not
independently applicable.
- A valid creator-signed rootless `Start` or `Cancel` is an independently
verifiable irrevocable burn certificate for that `CallRef`.
- Names remain signed self-assertions and never grant authority.
For a creator that equivocates, canonical `Create` is the minimum
`(at, event_id)`. Among all rooted or rootless `Start`/`Cancel` certificates,
the canonical terminal is also the minimum `(at, event_id)`. Rootless state
retains that exact winning terminal and order key, not only a set of call IDs.
An earlier valid terminal arriving later atomically replaces a later tombstone;
losers and all later nonterminal/root data are obsolete. This selection is
arrival- and grouping-independent. A stale `Create` can never resurrect a
burned `CallRef`.
Compaction retains the current rooted display history for 15 minutes, then the
single canonical terminal. Fresh peers accept and relay that self-proving
tombstone. This deliberately changes and repairs the current replication
contract. A rootless tombstone is internal anti-resurrection state and is not
rendered as a nomination because it lacks the root's game/deadline data. Later
valid rooted history may hydrate terminal display but never reopen the call.
The verification cache is a bounded LRU keyed by a digest of the exact signed
object, including context, author key, body, and signature. A hit may reuse only
the canonical object already verified; it must never bless an incoming
alternate signature merely because an event ID matches. Eviction changes
performance only.
Do not reject relayed events because `now - at` is large. Every timestamp,
deadline, and `ready_at` is an integer in
`1..=8_640_000_000_000_000` milliseconds (the positive ECMAScript Date range,
also exactly representable as an integer in JavaScript), and all arithmetic is
checked for overflow. Replicated validation uses only these deterministic
signed-field rules:
- every event `at` is positive;
- `Create.deadline > Create.at` and
`Create.deadline - Create.at <= 3 days`;
- `Create.scheduled_for` is absent or exactly equals `Create.deadline`;
- `AddTime.deadline` is strictly greater than the event's `at`; it has reducer
effect only when it also exceeds the effective deadline immediately before
it in the canonical fold; and
`AddTime.deadline - canonical_Create.at <= 3 days`; and
- `Respond.ready_at` is absent or lies in the inclusive interval
`[Respond.at, canonical_Create.at + 3 days]`.
Local direct publication may apply a one-sided future-skew check before
signing; receivers do not make signature validity depend on their current
clock.
To compute extensions, sort all otherwise valid `AddTime` events after the
canonical Create by bytewise-ASCII `(at, event_id)`, fold from
`Create.deadline`, and apply only strict deadline increases satisfying the
rules above. Retain structurally valid rooted candidates within quotas even
when currently ineffective. Rebuild that fold and every dependent-action
classification over the retained union whenever an earlier Create/extension
arrives. Missing-root objects remain `NeedHistory`; normal expiry or canonical
terminal compaction eventually removes inactive candidates. Backend and
frontend use the same numeric
timestamp and bytewise lowercase-base32 ID comparator; the frontend must not
use locale-dependent collation.
#### Call-to-Play limits and merge semantics
Initial hard limits, adjusted only through a measured Phase 0 decision:
| Limit | Value |
|---|---:|
| Exact encoded signed event | 4 KiB |
| Total stored events | 4096 |
| Total exact encoded stored bytes | 4 MiB |
| Per author, including local | 256 events and 256 KiB |
| Remote-author identities represented | 256 |
| Reserved inside global limits for local author | 256 events and 256 KiB |
| Verification cache | 4096 exact-object entries |
| Incoming CTP vector before signature work | 4096 events and 4 MiB |
All count and byte limits are conjunctive and include active events, rooted
terminal history, rootless tombstones, and quarantined blocked objects. Quotas
are charged to the signed author, not the transport relay; a locally authored
object relayed back still counts as local. Tombstones are never evicted for any
admission.
The remote pool stops at 3840 events and 4 MiB minus 256 KiB, preserving the
local reserve; the local author still has the same 256-event/256-KiB author cap.
The 256 represented-remote-author limit counts identities with retained remote
objects, not an ever-seen set. A creator terminal
may settle an already-retained rooted call at capacity if post-reduction
compaction brings usage within hard limits; it may also replace an existing
tombstone with the canonical earlier winner only when the replacement also
fits the byte cap. A new rootless terminal consumes normal global/per-author
capacity—Create+Cancel spam cannot bypass quotas. Under overload, rooted
display retention may compact immediately to the canonical terminal to
preserve the state-reducing action and hard bound.
Merge processing is:
1. enforce frame, vector, object-size, and structural bounds before signatures;
2. independently authenticate and schema/block-classify each object;
3. compute canonical/state-reducing replacements and a dependency-closed
candidate set, allowing a same-batch `Create`
regardless of vector order and the explicit rootless-terminal exception;
4. reduce and pressure-compact on a clone, then apply per-author and represented
author limits to capacity-increasing groups without letting one author
poison another;
5. after every author/represented-author/capacity filter, recompute dependency
closure to a fixed point (or admit a whole dependency component), so a
rejected Create cannot leave an admitted dependent participant action;
6. commit state reducers first. Admit the remaining capacity-increasing groups
only if they collectively fit global count/byte/remote-pool limits;
otherwise admit none of those groups (never an arrival-order prefix); and
7. canonicalize and swap the store atomically once.
Atomic commit remains a feature; before the explicit collective-global-
exhaustion condition, one hostile event does not reject every unrelated
author. After Sybil exhaustion, emit a persistent typed warning that
history is full and updates may be incomplete. Do not accuse the transport
relay. Recovery is block/quarantine followed by deliberate party-state reset or
restart and authenticated resync. Deterministic over-quota quarantine is later
hardening.
Responder-authenticated transport never confers third-party authorship.
Protocol 8 takes the simplest safe option: it disables all network
Call-to-Play ingestion/synchronization while leaving local UI state available.
Phase 3 restores network propagation with signed objects. A paginated Phase 3
snapshot uses a random snapshot ID, page index/count, declared total
event/decoded-byte counts, and full-snapshot digest; pages are assembled only
within one pinned handshake/session under the 4096-event/4-MiB cap and merged
once. Missing, duplicate, mixed-ID, digest-mismatched, expired, or over-limit
assemblies are discarded. Independently signed event notifications may be
merged directly. Neither an inner event author nor an unauthenticated outer
relay refreshes listener liveness; after Phase 4 only the verified envelope
sender may record identity activity, never endpoint reachability.
### 7.10 Download-source authorization
Browse/library summaries from nonblocked `prompt` or `denied` peers may remain
visible. They are not transfer authority.
At transfer start, the peer core—not Tauri—builds the authoritative manifest
from direct responses received over responder-pinned connections from
`download_from = allowed` identities. Every accepted tuple
`(game_id, canonical path, is_dir, size)` has exact attestation from at least
one allowed identity. Transfer consensus excludes unapproved peers; an
unapproved vote cannot introduce or select a descriptor that causes filesystem
mutation.
For ordinary downloads, the validated manifest maps each exact descriptor to
the allowed `PeerEndpoint`s that attested it. The plan, progress state, and
retry paths preserve that provenance. A retry chooses only an allowed identity
that advertised that exact descriptor. It never falls back from a file-specific
source set to a global address list. An address may refresh only by resolving
and pinning the same selected `PeerId`.
Streamed install has a distinct boundary: ordinary metadata describes root
archives, while `FileBegin` dynamically introduces extracted paths. A streamed
source is eligible only if it is allowed, TLS-pinned, and attested the complete
selected root-archive manifest—not merely one consensus file. Its extracted
frames stay inside isolated staging and are checked with §6's canonical,
reserved-path, symlink/reparse, count, and byte rules before each staged create.
There is no per-extracted-file fallback. Retrying the stream restarts it from an
allowed identity that attested the same complete archive manifest. A future
pre-attested extracted-manifest preamble would be a separate wire change, not
an assumption in this phase. No staged path is promoted until the terminal
`Complete` frame validates the observed entry count, aggregate bytes, archive
set, and stream result; failure discards the entire staging transaction.
Validate the complete authoritative/selected manifest using §6 before
`begin_version_ini_transaction` or any preparation. Then and only then may
storage mutate the requested game root.
Changing `allowed` to `denied`, or blocking a peer, durably changes policy
before success is reported and cancels the entire normal or streamed transfer
if it uses that source. Remove it from retries, restore/leave the installation
sentinel in the existing incomplete state, invoke the ordinary-download
discard/cleanup path for partial peer-owned payload, and cleanly discard
streamed staging; a new user-initiated attempt may replan from remaining
eligible sources. User-owned `local/` remains untouched.
This boundary stops an unapproved peer from supplying transfer metadata or
bytes. It does not verify bytes from an approved peer.
## 8. Wire and protocol evolution
- Transport identity and endpoint semantics: protocol 8.
- Independently signed Call-to-Play objects: protocol 9.
- Signed control envelopes: protocol 10.
- Optional party admission/rotation: a later bump only if implemented.
Exact numeric versions are rebased if the repository's current version changes
before implementation; the ordering and no-compatibility policy are fixed.
`Request`/`Response` become inner payloads of `SignedEnvelope`; identity fields
that duplicate the verified sender are removed. `Goodbye` is deleted. Protocol
8/9 transitional `Hello` carries a claimed identity only to request the pinned
connect-back. Transitional `HelloAck.peer_id/public_key` MUST exactly equal the
TLS-pinned identity or the response is rejected; binding and library merge are
always keyed from the pin, never those redundant fields.
Protocol 10 removes those duplicate inner sender ID/key fields and uses the
verified envelope sender (plus the TLS pin for responses) as the sole identity
authority. Advertised listener addresses remain candidates requiring proof.
Call-to-Play snapshots carry `SignedCallToPlayEvent` and use the bounded atomic
snapshot assembly in §7.9.
Protocol 9's `CallToPlayEvents` request contains only signed event objects or
snapshot-page data; it removes the legacy relay/sender `peer_id` field. The
outer transport origin grants no event authority. Remaining per-request sender
fields disappear with all control envelopes in protocol 10.
Protocol 8 standardizes newly produced manifest paths to `/`-separated
canonical form. Because that is inside the version bump, protocol-8 receivers
need no legacy producer fallback; the standalone protocol-7 validator's
one-time normalization exists only until the cutover.
| Version | Inbound control dispatch |
|---|---|
| 8 | `Hello` is candidate-only; Ping/browse/metadata/transfer reads are anonymous/public; LibraryDelta is a coalesced pinned-resync hint; network CTP is disabled; no inbound activity attribution; no `Goodbye`. |
| 9 | Version 8 behavior, except independently signed CTP objects/snapshot assemblies may mutate only the CTP store after per-object verification. |
| 10 | Every control request/response requires a verified envelope; block policy runs before inner dispatch. Bulk chunk/stream frames remain unsigned only inside the responder-pinned TLS connection created by that verified request. |
## 9. Code ownership map
New `crates/lanspread-identity`:
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 |
|---|---|
| `key` | Ed25519 secret/public key, full PeerId derivation, zeroizing secret record |
| `store` | Explicit modes, normalized backend results, transition table, lifetime lease, keyring/file storage, sidecar, repair API |
| `sign` | Exact transcript signing and verification, verified sender/event construction |
| `tls` | RPK/X.509 material and pinned rustls client/server configuration |
| `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, 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. |
`lanspread-proto` owns wire-only fixed types, explicit serializers, transcript
builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data.
## 6. Implementation phases and gates
Principal `lanspread-peer` changes:
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.
| Area | Required change |
|---|---|
| `identity.rs`, startup | Durable derived identity and typed repair; no silent UUID regeneration |
| `config.rs`, TLS assets | Remove shared `CERT_PEM`/`KEY_PEM` and repository key material after Phase 2 |
| `network.rs`, remote peer helpers | Mandatory expected endpoint; remove address-derived identity |
| `services/server.rs` | Per-peer rustls TLS server and bounded candidate/connect-back work; generic accept-loop DoS remains an accepted limit |
| `services/discovery.rs` | Candidate directory/retry only; no authenticated-state mutation |
| `services/handshake.rs` | Non-authoritative inbound Hello; pinned callback; record dialled endpoint |
| `services/stream.rs` | Envelope verification boundary and `VerifiedSender`; no payload-trusted IDs or `Goodbye` |
| `services/liveness.rs` | Pinned endpoint probes and revision-conditional update/removal |
| `peer_db.rs` | Atomic verified bind/collision rejection; first-class endpoint/index invariants |
| metadata/download/stream install | Allowed-source manifest provenance through selection, storage, retries, and revocation |
| `call_to_play.rs` | Signed event store, CallRef authority, canonical rootless terminals, exact cache, count/byte/reserve policy |
| errors/events | Typed auth, identity-repair, source-denied, and overload states reaching the UI |
### Phase 1 — land filesystem confinement immediately
The peer CLI gains deterministic explicit identities, full endpoint input, and
hostile modes. Tauri gains minimal Phase 1 repair and Phase 2 source-approval
surfaces before the complete trust UI.
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.
## 10. UI and operational semantics
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.
- **Identity repair (Phase 1):** typed failure, retry/unlock, import backup,
explicit backend selection where safe, deliberate file-backed fresh create,
and deliberate reset. Networking stays stopped during repair.
- **Source approval (with enforcement):** show full/short fingerprint and
“Allow downloads from this device.” New devices default to prompt. The UI
must not call this content trust.
- **Peer identity:** display name + short fingerprint, persistent New marker
until acknowledged, and a prominent same-name/new-key conflict.
- **Blocking:** immediate durable block/unblock, direct and relayed enforcement,
visible recomputation, and resync on unblock. Unblock does not restore source
permission automatically.
- **Identity settings:** full copyable fingerprint, honest backend label,
backup/import/reset consequences, and platform-specific persistence wording.
- **Call-to-Play overload:** persistent typed “history limit reached; updates
may be incomplete” warning, signed offending authors when known, and reset /
restart recovery guidance.
- **Diagnostics:** rejected auth/source objects surface typed reason codes and
fingerprints, never secret material or misleading generic network errors.
### Phase 2 — establish real catalog content authority
## 11. Implementation phases and acceptance gates
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
SHA-256 checks and source quarantine, and implement verified extracted
manifests for Stream Install.
Every code phase runs `just fmt`, `just clippy`, and `just test`. UI/Tauri
phases also run `just frontend-test` and `just build`. Every protocol/peer phase
runs targeted `just peer-cli-tests` during development and the unfiltered suite
before phase completion. Wire phases run the honest alpha/bravo/charlie matrix
using a freshly built image.
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.
Fix the `justfile` stale-image hazard first: manual `peer-cli-run`, alpha,
bravo, and charlie recipes must depend on a readiness target that builds the
current `peer-cli-image` and network. A manual run against an old image is not
acceptance evidence.
### Phase 3 — prove and implement simple responder identity
### Phase 0 — freeze contracts and complete spikes
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.
Record in the repository:
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.
- the identity transition table and backend scope;
- exact backup-envelope KDF/AEAD/header parameters;
- `PeerEndpoint`, socket-admissibility, candidate/verified-record, clone, and
liveness-generation invariants;
- candidate TTL/backoff and all candidate/live-peer/connect-back/trust/nonce
capacity and rate values;
- trust/source-permission schema and block transitions;
- exact envelope and event transcripts;
- the protocol 8/9/10 dispatch matrix (protocol 8 no network CTP or
client-authoritative controls; protocol 9 self-signed CTP only; protocol 10
envelope-authenticated controls);
- Call-to-Play count/byte/reserve values and accepted exhaustion behavior;
- manifest count/size bounds, Windows/path policy, and control/snapshot
frame/pagination decision;
- maximum-size history/manifest and honest download/handshake workloads plus
objective peak-memory, latency, and throughput-regression thresholds for the
final performance gate;
- the dependency rule: `lanspread-identity` depends on `lanspread-proto`, never
the reverse; and
- the completed RFC 7250 RPK / Ed25519 X.509 spike result, disabled
resumption/0-RTT configuration, and exact legal fallback if one is needed.
### Phase 4 — make the single wire cutover
Phase 1a may run in parallel. Its identity primitives need only the frozen
identity/transcript slice; persistent writes and runtime identity replacement
wait for the storage table, while unrelated TLS/manifest measurements may
finish concurrently.
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`.
### Prerequisite — download manifest confinement
This phase is not complete until:
After Phase 0 freezes manifest bounds, implement §6 with no wire change and
before Phase 1b writes persistent identity. Phase 1a may remain in parallel.
Mandatory hostile path, limit, absent-from-authoritative-selection, and
zero-mutation tests land here, including the cross-game `local/` sentinel. Run
the full peer-CLI suite and `just build` because the Tauri-to-download
integration is in the path. Windows alias/reparse behavior requires supported
Windows CI or recorded manual evidence; Linux-only results are labelled as
such rather than generalized.
- 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;
- 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.
### Phase 1a — primitives and fake-backed groundwork
Update `ARCHITECTURE.md`, protocol docs, and CLI documentation in the same
phase; do not leave the shared-certificate or relayed-event description behind.
Add the identity crate, Ed25519/PeerId/signature primitives, versioned
zeroizing secret type, normalized backend outcomes, fake backend, fixed-seed
golden transcript/signature vectors, secret-redaction tests, and
keyring/platform feasibility. Golden vectors include exact control request and
correlated response bytes/signatures plus one CTP event and assert canonical
base64url string fields. Certificate DER/cross-language vectors are not
required. No persistent writes, runtime-ID replacement, legacy deletion, or
trust population.
### Phase 5 — finish the small user-facing surface and audit
### Phase 1b — durable identity and usable repair, no wire change
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.
Implement every §7.2 transition-table cell, lifetime lease, keyring/file
backends, secret-first sidecar recovery, override isolation, correct Tauri
`app_data_dir`, encrypted backup/import/reset, and the minimal repair UI. Keep
the protocol-7 runtime/advertised UUID until the Phase 2 wire cutover; do not
populate security trust from it.
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:
Mandatory tests include every backend state, no-fallthrough on
locked/denied/unavailable/corrupt, sidecar mismatch, absent/corrupt sidecar
recovery, unsupported-version zero writes, valid-sidecar no unselected probe,
multiple secrets, write-failure reprobe, crash after secret before sidecar,
replacement mismatch repair, every backend-migration intent stage and target
conflict, account-wide lease held throughout repair, two
simultaneous starts, file permissions/atomic replacement, CLI-over-environment
precedence and zero default-store access in explicit modes,
tampered/wrong-passphrase backup, selected-backend reset/import failure without
fallback, successful export/import fingerprint round trip, header/decoded-size
and KDF-cost bounds rejected before expensive allocation/work with zero writes,
runtime stop/rebuild/restart on replacement, legacy UUID preservation
on every failed path, and successful import/reset still advertising the same
protocol-7 UUID/shared TLS identity rather than prematurely activating the new
key, secret redaction, and “shell visible/network stopped”
repair behavior. Give every Docker/peer-CLI recipe a distinct deterministic
seed/file before this gate; test restart stability and no accidental shared
identity. Run `just frontend-test`, `just build`, and the unfiltered peer-CLI
suite. Real supported keyring/app-data backends require platform CI or recorded
manual evidence.
- 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.
### Phase 2 — responder-authenticated transport and endpoints (protocol 8)
## 7. Success criteria
Land the selected RPK/X.509 mode, real CertificateVerify, mandatory expected
endpoint, all endpoint plumbing, mDNS candidate separation/retry,
non-authoritative inbound Hello, bounded/deduplicated connect-back, atomic
collision rejection, removal of address fallbacks and `Goodbye`, and
revision-conditional liveness. Delete the shared repository certificate/key and
compiled constants regardless of which selected per-peer TLS mode lands.
After the durable identity loads successfully, protocol 8 activates its derived
PeerId for runtime/advertising and deletes the legacy UUID as part of this
versioned cutover—not earlier.
The plan is complete when the following statement is true from a user's point
of view:
Client-originated controls remain unauthenticated. Library/legacy notifications
cause only bounded pinned resync. Protocol 8 disables all network Call-to-Play
ingestion/sync; local CTP remains available and Phase 3 restores networking
with self-authenticating events. The phase-current scenario matrix explicitly
expects this temporary gate instead of retaining contradictory S48/S49
third-party-relay expectations.
> 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.
Mandatory negatives: cert/SPKI A with private key B, wrong expected ID,
disallowed/mismatched RPK or X.509 mode, resumption/0-RTT disabled, copied
public-key inbound claim, invalid callback socket class/source-IP, every
per-source/per-target/global callback and candidate-cap axis, advertised Ack
ID/key mismatch and address redirect, callback/resync terminal behavior with no
recursive callback, ALPN/version mismatch, address collision
with intact indexes, simultaneous same-key clone,
mDNS-only mutation, candidate expiry/retry without a new mDNS event, stale ping
or timeout-prune after same-address activity/rebind/reconnect, inbound traffic
not refreshing an unreachable listener, wrong key at reused address, and no
address-derived fallback. If P-256 is selected, also mutate/remove every
pre-channel binding field/signature/SPKI and reject a Hello-only binding.
The resumption negative performs a second connection/early-data attempt and
proves the full verifier and CertificateVerify path runs again. A flood mixing
Hello, mDNS, and legacy library hints proves the unified scheduler remains
within every budget.
Send forged candidate Hello bodies containing library/CTP data and forged
legacy LibraryDelta/CTP payloads; they must not merge, create/rebind/remove a
peer, or refresh liveness. Only the separately pinned resync response may
merge. Protocol-8 Hello's candidate-only decoder rejects the obsolete full
state shape before retaining it.
Positive tests prove pinned resync propagates post-start library changes, a
genuinely dead current endpoint eventually emits `PeerLost` and applies the
defined active-download cancellation, and every endpoint consumer (metadata,
chunks, retry, streamed install, healing, liveness, direct CLI) pins the
captured ID. Import/reset under per-peer TLS stops networking, rebuilds the
derived ID/certificate/advertisement, rejects the old ID/key, and reconnects
under the new identity. The former Goodbye shutdown scenario is rewritten around liveness.
Run `just build`, the unfiltered phase-current peer-CLI suite, and fresh
three-peer matrix. Update the relevant architecture, threat, and CLI docs in
this phase.
### Phase 2b — default-deny transfer sources and minimal approval UI
Using Phase 2 identities, implement the durable trust/source state and outbound
source-enforcement portions of §§7.3 and 7.10: approved-only raw-manifest
bounds/consensus, ordinary exact provenance, streamed root-archive eligibility
and staging validation, and mid-transfer revocation. Public browse remains
open; inbound serving remains anonymous/public until Phase 4, so requester
blocking is not claimed here. Relayed-author blocking belongs to Phase 3.
Mandatory tests: a prompt/denied sole source creates nothing; browse still
works; approval enables download; unapproved metadata cannot enter transfer
consensus; UI selection absent from approved manifests fails; wrong identity at
a reused address fails; retry stays within exact attesters; manifest mismatch
fails before mutation; and deny/block cancels and cleans both ordinary and
streamed transfers without touching `local/`. Streamed hostile tests require
complete root-archive attestation and reject extracted traversal/reserved/
symlink-reparse paths, count/byte overflow including a late invalid entry, and
invalid `Complete` without promoting any staging. Also test raw count/byte limits,
failed security-state writes/restart, and that same-name/new-key or address
reuse by a different key never inherits `allowed`, while the same verified
PeerId moving to a newly pinned address retains it; existing honest download
scenarios explicitly approve their sources. Saturate total trust records,
observed-name history, and unacknowledged observations and prove that
acknowledged, blocked, and allowed records are never silently evicted. Run
frontend/build and full peer-CLI gates, and update
source-permission/user-facing docs here.
Catalog-matching already-downloaded data is explicitly tested for offline
install/reinstall and local serving with no remote approval, so source policy
cannot accidentally gate local provenance.
### Phase 3 — independently signed Call-to-Play objects (protocol 9)
Implement §7.9, including core-created CallRef/IDs, verbatim relay, creator and
participant authority, canonical rooted/rootless terminal state, deterministic
horizon checks, exact-object cache, block-at-merge, hard count/byte/author
limits, local reserve, dependency-closed classification, and overload UI.
Third-party relay is restored only for valid signed objects.
Mandatory tests cover wrong creator/participant, CallRef mismatch, competing
same-CallRef Creates under arrival/group/partition permutations, fresh-store
rootless terminal, noncreator tombstone, rooted/rootless terminal permutations
and partitions, later-arriving earlier winner, stale resurrection, 15-minute
compaction, rootless AddTime/participant `NeedHistory`, rootless no-display and
later hydration, old and two-day history, timestamp equality/overflow/ready-at
and exact ECMAScript-range boundaries, AddTime/Create recomputation under
arrival/group/partition permutations, bytewise Rust/TypeScript ordering, and
receiver-clock-independent three-day rules, every
body/key/signature/ID/protocol-version mutation, observed-ID preseed, invalid signature after
cache hit/compaction, bounded cache, count/byte/per-author/tombstone/Sybil
limits, local reserve, terminal at capacity, blocked author relayed by a
nonblocked peer regardless of that relay's source permission, blocking
already-retained creator and participant state,
quarantine accounting, unblock authenticated resync, mixed
invalid/blocked/over-quota author isolation, all-or-none collective global
overflow, over-quota Create removing its under-quota dependent while preserving
an unrelated call, sequential-versus-single-batch arrival where an initially
inactive action becomes effective after an earlier canonical Create,
same-batch fixed-point dependency closure, bounded snapshot assembly, and
explicit bounded-divergence recovery. Frontend tests prove chat uses enclosing
event ID and rootless state stays hidden. Saturate relay-only author metadata
and prove it remains ephemeral and bounded without growing the durable trust
store. Run `just frontend-test`, `just
build`, the full hostile peer-CLI suite (restoring late-join/relay scenarios),
and honest matrix. Update CTP architecture and UI claims here.
### Phase 4 — signed control envelopes (protocol 10)
Implement the exact §7.8 envelope, explicit byte serializers, request/response
nonce correlation, boundary verification, `VerifiedSender`, removal of
payload identity fields, bounded duplicate suppression, and control frame cap
or pagination. Reject a blocked `VerifiedSender` before every inner handler,
including Hello, browse, and download serving; associate long-lived streamed
and ordinary chunk serving with that identity and cancel every active outbound
serve operation if the identity becomes blocked.
Mandatory tests mutate every signed field and cover unsigned frames, wrong
signer/derived ID, protocol, recipient, context, pinned response sender,
request nonce, missing/unknown fields, padded/wrong-alphabet base64,
numeric-array bytes, wrong decoded lengths, explicit base64 round trip,
concurrent duplicates, sender/context isolation, invalid-signature nonce-cache
poisoning, eviction with idempotent replay, oversized frame/payload, forged
library delta, every valid blocked state-changing sender before handler
dispatch, blocked Hello/read/stream serving and unsigned-Hello bypass, block
cancellation of active ordinary-chunk and streamed serving, and liveness
attribution. Honest
library, download, and event flows
must remain green. Run `just build`, the full peer-CLI suite, and fresh
three-peer matrix. Update protocol/trust documentation here.
### Phase 5 — complete trust and identity UX
Add polished identity/pin/new/reviewed/name-conflict displays, source permission
management, block/unblock, honest storage labels, and refined repair/backup
flows. Source enforcement, relayed-object blocking, and direct requester
blocking already exist in Phases 2b, 3, and 4 respectively; this phase improves
visibility and management.
Test reducers and Tauri failure paths, immediate sensitive-state durability,
relayed blocking, existing-state recomputation, unblock resync, permission
persistence, same-name/new-key behavior, duplicate identity warnings, and proof
that unblock leaves download permission denied. Saturation must be visible;
the user can deliberately remove an eligible unacknowledged record without
evicting protected state, after which an honest new identity can be admitted
and approved. Run frontend/build and full peer-CLI gates. Update UX
documentation in the same phase.
### Phase 6 — optional hardening
Separately designed features may include dual-signed key rotation, party
admission, connection retry/address tokens, and deterministic over-quota author
quarantine. Each feature owns a protocol bump and hostile tests if it lands.
Before implementation it freezes its own contract; its gate includes positive
end-to-end behavior, applicable persistence/failure/recovery cases, negative
security cases, and same-phase documentation. A reject-all implementation does
not satisfy the gate.
Catalog content hashes/signatures remain separate, non-optional future work,
not a claim completed by this phase.
### Phase 7 — final audit, documentation, and performance
This is not the first hostile-test or documentation phase. Audit the already
updated `ARCHITECTURE.md`, README files, threat claims, peer-CLI docs, and UI
wording; run every standard gate,
unfiltered `just peer-cli-tests`, a fresh three-peer matrix, and download/
handshake/full-history performance checks against Phase 0's fixed workloads and
failure thresholds. Build supported-platform production bundles with
`just bundle` and smoke-test packaged startup, identity/keyring/app-data
selection, repair, and restart; the no-bundle output from `just build` alone is
not shipping-artifact evidence.
Audit for absence of the shared TLS key, `Goodbye`, address-only connects,
`ensure_peer_id_for_addr`, fabricated IDs, payload-trusted sender fields,
unbounded auth/CTP inputs, deferred negative tests, and any “verified/trusted
content” wording.
## 12. Dependencies
Prefer the existing AWS-LC stack.
| Crate | Purpose | Constraint |
|---|---|---|
| `rustls` 0.23 | Full verifier/config construction | Version aligned with s2n-quic-rustls |
| `aws-lc-rs` | Ed25519, SHA-256, KDF/AEAD as needed | Existing crypto backend |
| `rcgen` if X.509 is selected | Self-issued leaf generation | `default-features = false`, AWS-LC feature only |
| `x509-parser` if needed | Strict SPKI/binding extraction | Parsing only; reject unsupported shapes |
| `keyring` 3 | OS secret stores | Normalize locked/denied/unavailable distinctly |
| `zeroize` | Secret hygiene | Seed-bearing values only |
| `data-encoding` or existing equivalent | base32/base64url | One exact encoding implementation |
`s2n-quic` uses the rustls provider and retains only required provider features.
The new crate keeps `unsafe_code = "forbid"`.
## 13. Explicit risks and decisions
- **TLS mode uncertainty:** Phase 2 does not begin implementation around an
assumed raw-key path; Phase 0 chooses RPK or Ed25519 X.509 and proves the
wrong-private-key feasibility first.
- **Linux keyring availability:** unavailable is not absent. Fresh GUI users
can deliberately choose a labelled file backend; headless runs choose an
explicit identity source.
- **Identity loss and clones:** backup/import are necessary recovery tools but
can clone a key. The UI cannot claim automatic clone revocation.
- **Endpoint plumbing size:** this is real cross-cutting work, not hidden behind
`connect_to_peer(expected)`. Phase 2 inventories every caller and tests
retries/streamed installs explicitly.
- **Authenticated does not mean harmless:** self-signed identities and source
approval still leave approved malicious content and link/application-task
DoS, including the explicitly accepted generic server-fan-out risk in §4.
- **Bounded state versus Byzantine convergence:** this plan chooses finite
memory and explicit overload over distributed consensus machinery for a
noncritical LAN-party feature.
- **Permanent tombstones:** their anti-resurrection purpose is preserved. They
are bounded through admission, never arrival-ordered eviction.
- **Scope pressure:** each phase owns its negative tests and safe intermediate
restrictions. No phase gets security credit for a later phase's mechanism.
## 14. Closure of prior review findings
| Finding | Resolution in this plan |
|---|---|
| F1 signed listener/address collision | §§7.47.6: dialled endpoint authority, non-authoritative claims, pinned connect-back, atomic conflict rejection without eviction. |
| F2 responder-only Phase 2 / relayed authority gap | §§7.6, 7.9 and phases 24: protocol 8 disables network CTP and treats client claims as hints; protocol 9 admits only independently signed CTP objects; all other client authority waits for envelopes. |
| F3 compacted tombstone creator proof | §7.9: CallRef in every body, creator-signed rootless burn certificates, canonical retained rootless terminal, fresh-peer propagation. |
| F4 arbitrary event IDs/cache bypass | §7.9: full content-derived IDs and bounded exact-signed-object cache. |
| F5 invalid ±10-minute history rule | §7.9: no receiver-age rejection; deterministic three-day field horizon and local-only publication skew policy. |
| F6 resource exhaustion | §§4, 7.6, 7.8, 7.9: concrete new-amplification/batch/store/cache bounds, author quotas, local reserve, rejection/no tombstone eviction, and honest exhaustion behavior. The review's comprehensive connection/stream/disk-read scheduler is explicitly not adopted and remains an accepted separate availability risk. |
| F7 replay/Goodbye | §§7.77.8: delete Goodbye, narrow duplicate suppression claim, local recipient/context/pinned response, conditional liveness removal. |
| F8 storage crash/fallback state | §7.2 and Phase 1: explicit transition table, conclusive absence, lifetime lease, secret-first derivative sidecar, early repair, correct GUI path. |
| F9 expected identity not threaded | §7.4 and Phase 2: first-class endpoint through all consumers; delete address-derived/fabricated identity and require CLI fingerprint. |
| F10 trust/block/lifecycle | §§7.27.3, 7.97.10: separate review/block/source states, durable writes, relay blocking, Phase 1 repair, honest backup/clone semantics. |
| F11 incomplete crypto/wire contract | §§7.1, 7.5, 7.87.9: exact transcripts/encodings/crate direction, real CertificateVerify, wrong-key test, AWS-LC rcgen, pre-channel fallback binding. |
| F12 late tests/docs | §11: phase-owned negatives, full peer-CLI gates, GUI build gates, fresh manual images, final-audit-only Phase 7. |
Additional dialogue findings are also closed: §6 handles the live cross-game
`local/` truncation bug; §7.7 handles the stale liveness-probe race; §7.9
handles rootless terminal ordering and chat `message_id`; §§7.3 and 7.10 add
default-deny source admission without misrepresenting it as content integrity.
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.