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 ## Status
Architecture approved; implementation plan. Revised implementation plan; not yet implemented.
This document incorporates the prior holistic review and subsequent review This plan deliberately treats Lanspread as what it is: a desktop utility for
dialogue. It replaces the previous version of this file and friends and other attendees at a LAN party to discover each other, share a
supersedes the statement in `CALL_TO_PLAY_FIXES_PLAN.md` that cryptographic known game catalog at LAN speed, and coordinate a match. It is not an account
peer identities should not be introduced. system, a global untrusted file-sharing network, or a device-administration
product.
Nothing in this document is implemented merely because it is specified here. The normal user journey must remain:
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 project has one current wire version and no compatibility paths. Every 1. Open Lanspread.
wire-changing phase bumps `PROTOCOL_VERSION`; peers on any other version are 2. See nearby people and their available games automatically.
rejected. 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 ## 1. Product and architecture decisions
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.
There is also one independent prerequisite. The current download preparation | Area | Decision | User-visible result |
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 |
|---|---|---| |---|---|---|
| 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. | | 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. |
| T2 | MITM an outbound QUIC connection | Fails responder pinning and/or TLS 1.3 CertificateVerify. | | 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. |
| T3 | Evict a peer remotely | `Goodbye` is removed. Conditional authenticated liveness is the only remote-removal path. | | 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. |
| 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. | | Transport | Pin every outbound QUIC connection to the expected `PeerId`. | An address spoof or MITM cannot impersonate the peer selected as a source. |
| T5 | Start, cancel, or extend another creator's call | Creator actions require the `CallRef.creator_key` signature. | | 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. |
| T6 | Chat or RSVP as another participant | Participant actions require that participant's signature and a valid call root. | | 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. |
| 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. | | Privacy | Provide one global Local network sharing switch. | Participation is easy to understand; no per-peer policy matrix. |
| 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. | | Protocol rollout | Make one cutover to the new current protocol. | Mixed versions are explained clearly, without maintaining legacy paths. |
| 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. |
## 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 ```text
lands before Phase 1b persistent identity work; Phase 1a may proceed in mDNS candidate -> responder-pinned TLS -> peer-owned snapshot or file bytes
parallel.
### 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 The peer core constructs a `ValidatedDownloadManifest` before
`begin_version_ini_transaction`, `prepare_game_storage`, directory creation, `begin_version_ini_transaction`, `prepare_game_storage`, directory creation,
file open/truncate/resize, or any other filesystem mutation. Storage accepts file creation/truncation, preallocation, or cleanup. Storage functions accept
only that validated type; neither Tauri nor a remote peer can pass raw that validated type, never raw `GameFileDescription` values from Tauri or a
`GameFileDescription` values to it. 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; - require a known catalog `game_id` and resolve every destination relative to
- resolve destinations relative to exactly `<games_folder>/<game_id>`, not the exactly `<games_folder>/<game_id>`;
whole games directory; - use one canonical forward-slash relative-path form and reject empty,
- at this standalone no-version-bump boundary, leave protocol-7 producer bytes absolute, drive-qualified, UNC, NUL, `.`, `..`, mixed-separator, and
unchanged and normalize its current platform all-`/` or all-`\\` separator non-normalized paths;
form exactly once before validation; reject mixed - reject duplicate paths, file/directory conflicts, and platform aliases such
separators and ambiguous/empty components. Accept and discard only the as Windows case, trailing-dot/space, device-name, and alternate-data-stream
current exact redundant root descriptor collisions;
`{ relative_path: game_id, is_dir: true, size: 0 }`; reject every other empty - reject `local/`, `.local.*`, download/install intent state, legacy state,
game-relative entry. All absolute, drive-qualified, UNC, NUL, `.`/`..`, scratch sentinels, and every other path owned by installation or recovery,
parent, cross-game, and non-normalized results are rejected; while allowing the intended root `version.ini`;
- reject duplicate normalized paths and conflicting file/directory shapes; - require the game root to be one direct non-symlink child of the configured
- require directory size to be zero and exactly one regular root games directory and avoid following symlink or reparse components while
`version.ini` where the existing transaction requires it; opening destinations;
- use one shared `is_reserved_game_path` policy in scanning, serving, - enforce descriptor-count, individual-size, and aggregate-size limits; and
preparation, and discard. It rejects `local/`, `.local.*`, `.sync`, - require the exact root/file shape needed for a complete downloadable game,
`.lanspread/`, `.lanspread.json`, `.softlan_game_installed`, including one regular root `version.ini`.
`.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.
The UI supplies only `{ game_id }`; this plan does not support partial game The Tauri command supplies only the selected `game_id`. The peer core chooses
selection. The peer core selects the complete backend-authoritative manifest, the complete authoritative plan. A UI-echoed file list is never authority.
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.
Before committing `version.ini`, transactionally remove or quarantine every After a successful complete transfer, remove download-owned files absent from
download-owned, nonreserved path under the requested game that is absent from the authoritative manifest before committing `version.ini`. Preserve
the complete selected manifest. Preserve `local/` and all reserved transaction `local/`, install staging/backup state, and user-owned files in all success,
state. This prevents a stale `.eti` from an older, failed, revoked, or failure, cancellation, and recovery paths.
unapproved transfer from being installed or served alongside the newly
approved payload.
### 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 Required proof includes hostile descriptors placed after valid descriptors,
game's `local/`, then submit malicious descriptors and prove that no existing cross-game paths, both requested and other-game `local/` sentinels, reserved
file changes and no new path is created. They also cover a valid first paths, duplicates and aliases, symlink/reparse destinations, oversized lists,
descriptor followed by an invalid later descriptor, traversal/UNC/drive and and stale download-owned files. Every rejection must prove zero filesystem
case variants, reserved paths, duplicates, root-shape errors, game-ID mismatch, mutation.
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.
The gate is `just fmt`, `just clippy`, `just test`, `just build`, targeted ### 3.2 Make the bundled catalog the content authority
download/hostile-descriptor peer-CLI scenarios during development, and the
unfiltered `just peer-cli-tests` before completion.
## 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 For each supported `(game_id, game_version)`, the artifact contains:
- 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:
```text ```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, Entries are sorted by canonical path. `content_id` is SHA-256 over a
unavailable service, or corrupt record never authorizes generation. 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 | 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
| Valid | Selected backend is `Present` and derived key/ID match | Load only that backend; do not probe or switch. | matches the receiver's expected ID. The receiver builds paths, sizes, chunks,
| 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. | and expected hashes entirely from its local catalog. This replaces remote
| Missing | Exactly one readable secret exists and every other supported backend conclusively reports `NoEntry` | Reconstruct the sidecar from that secret and load it. | manifest selection and majority-by-file-size consensus.
| 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. |
On a genuinely fresh Linux system without Secret Service, the GUI may For ordinary downloads:
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.
The supported default keyring locator—service `network.paul.lanspread`, account 1. Select every currently reachable peer advertising the expected
`peer-identity`—means one default identity per OS account. Multi-profile, `content_id`; there is no approval prompt.
container, and simultaneous test identities use explicit files/seeds. If that 2. Carry `PeerEndpoint { peer_id, addr }` and `content_id` through planning,
scope changes, Phase 0 must first define a deterministic, recoverable locator; swarming, progress, and retry.
two stores must not silently implement different identity scopes. 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 Streamed install needs a catalog-owned extracted-file manifest because the
before its first backend probe and holds it through repair, import/reset, sender controls both today's RAR CRC32 metadata and extracted bytes. The
peer-runtime stop/restart, and application exit. The lease exists even while receiver accepts exactly the expected path set, sizes, and SHA-256 values in
networking is stopped. File-backed state uses `<state_dir>/identity.lock`; isolated staging, then applies the documented local account/language rewrite
explicit identity files use an adjacent lease. The fixed OS-account keyring and promotes the transaction. CRC32 may remain as an early corruption check,
locator uses one canonical account-wide lease independent of caller state but it is not the security boundary. A game without a verified extracted
directory. Default persistent keyring use is supported only through that manifest does not offer Stream Install; there is no unverified fallback or
canonical application profile; every other/headless profile uses an explicit warning-through button.
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.
Backend migration additionally uses a small non-secret Hashing is performed in the existing streaming I/O path. The acceptance gate
`identity.migration.json { version, from, to, target_fingerprint, stage }` measures end-to-end throughput on the standard LAN workload and avoids a
intent, written before touching the target and cleared only after sidecar second full read when complete chunk coverage already proves the file bytes.
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.
Every intent stage is durably updated by atomic rename. A corrupt or ### 3.3 Use a simple installation-local TLS identity
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.
- A crash after secret write and before sidecar write is recoverable by the The identity exists to bind a live peer and its changing address to TLS. It is
missing-sidecar table. A replacement crash can yield old-sidecar/new-secret not exposed as a user credential.
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 GUI uses Tauri `app_data_dir()`. `~/.lanspread` is only the core default - Generate one self-issued TLS certificate/key pair in Tauri's
when no state directory is supplied. Docs provide an update/uninstall matrix `app_data_dir()` and store it in one versioned application file with
per package rather than promising universal reinstall survival. 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 ### 3.4 Pin responders and make remote state pull-only
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.
Export emits only a versioned authenticated passphrase-encrypted seed envelope. Every outbound operation accepts a first-class endpoint:
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
```rust ```rust
struct PeerEndpoint { struct PeerEndpoint {
@@ -463,960 +274,315 @@ struct PeerEndpoint {
} }
``` ```
The endpoint is captured at discovery/source selection and carried through This endpoint is carried through discovery handshake, library refresh,
handshake, library/manifest fetch, ordinary downloads, chunk plans, retries, Call-to-Play refresh, metadata/content requests, chunk plans, retries, streamed
streamed install, healing, liveness, and direct peer-CLI operations. install, healing, liveness, and direct peer-CLI operations. Delete
Every outbound connection requires it or equivalent mandatory `(expected_id, address-derived IDs, unique-IP identity fallbacks, and address-only connects.
addr)` arguments.
Delete `remote_peer::ensure_peer_id_for_addr`, fabricated `addr-*` IDs, mDNS supplies bounded candidates containing `(peer_id, addr, protocol,
unique-IP identity fallbacks, and address-only direct connect. The peer-CLI revision hints)`. It may cause a dial, but it never directly creates or
`ConnectPeer` operation requires both full ID and address or is removed; there updates authenticated peer/library/Call-to-Play state. A candidate becomes a
is no desktop TOFU bootstrap UI to design for that test-only command. 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 Use TLS 1.3 with a self-issued certificate. The custom client verifier must:
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.
Candidate retries are deduplicated with jittered backoff while the 1. parse only the selected certificate/SPKI shape;
advertisement remains live. Reconciliation cannot depend on mDNS emitting a 2. derive the `PeerId` from that SPKI and compare the full value with the
second identical `ServiceResolved`; a still-live candidate can re-establish a endpoint's expected ID; and
peer after transient failure or liveness removal. 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 The load-bearing negative test presents peer A's certificate/SPKI with peer
nonzero; address is neither unspecified, multicast, nor IPv4 broadcast; B's private key and requires the handshake to fail. Also reject a different
loopback is accepted only in explicit test mode; and IPv6 link-local addresses valid peer at a reused address. Use one version-bound ALPN, disable 0-RTT, and
carry the observed interface scope. Global IPv6 and private/ULA addresses are start without TLS session resumption so every short-lived connection performs
not rejected merely because they are not syntactically “private”; interface the simple full proof.
provenance and the bounded dialing policy define LAN reachability.
### 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 This extra pull is one small LAN round trip and removes general signed
`s2n-quic-rustls`. If RPK works, use the identity public key directly. Otherwise envelopes, canonical opaque payloads, nonce caches, replay semantics, inbound
use a self-issued Ed25519 X.509 leaf whose SPKI is exactly the identity key. client-certificate plumbing, and connect-back authority state.
CA, CN, SAN, hostname, and self-signature are not authentication inputs; the
expected `PeerId` is.
The custom client verifier captures the expected `PeerId` from An unproven address collision never evicts an authenticated peer. If a pinned
`PeerEndpoint` and MUST: 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; Remove `Goodbye`. It is unnecessary for correctness and an unauthenticated
2. extract the raw identity key; removal hint is unsafe. mDNS expiry plus responder-pinned liveness handles
3. derive its full `PeerId` and compare it to the expected value; and departure.
4. cryptographically verify TLS 1.3 CertificateVerify against that presented
key using rustls/webpki's verification helper.
It may return `HandshakeSignatureValid::assertion()` only after step 4 ### 3.5 Keep Call to Play direct and ephemeral
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.
RPK mode requires `requires_raw_public_keys() == true`, a raw-key server Call to Play is coordination among people currently at the party. It does not
resolver (`AlwaysResolvesServerRawPublicKeys` or equivalent), and need a Byzantine replicated ledger.
`verify_tls13_signature_with_raw_key`. X.509 mode uses the certificate-specific
signature helper. The selected mode and mismatched-mode failures are tested.
Disable TLS resumption/session tickets and QUIC 0-RTT/early application data. Each runtime owns only its locally authored slice:
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:
```text ```text
b"lanspread/tls-key-binding/v1\0" CallId { creator: PeerId, random_nonce }
|| u32_be(PROTOCOL_VERSION)
|| identity_key[32]
|| u32_be(tls_spki_der_length)
|| tls_spki_der[tls_spki_der_length]
```
The verifier derives and matches the expected identity, verifies this binding, CallToPlayAuthorSnapshot {
then verifies CertificateVerify under the P-256 SPKI. A missing/tampered runtime_session_id
extension or statement delivered later inside `Hello` is rejected. If this revision
pre-channel binding is not practical, the fallback is forbidden. display_name
events[] // actor ID is not a wire field
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,
} }
``` ```
All fields are required; unknown fields are rejected. The inner request or The local core creates call/event IDs, increments the revision after each
response is serialized once with `serde_json::to_vec`, stored as explicit accepted local action, and sends a cheap change hint to known peers. A receiver
base64url payload bytes, signed, and verified before deserialization. The outer coalesces the hint, connects to the author's known `PeerEndpoint`, and pulls
JSON is not canonical and is not itself signed. 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 Authority rules remain simple:
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]
```
Ed25519 signs these bytes directly. Receivers require the current - `Create`, `Start`, `Cancel`, and `AddTime` are effective only when the pinned
`PROTOCOL_VERSION`, exact fixed lengths, expected context/direction, author equals `CallId.creator`.
`recipient == local_peer_id`, `derive_peer_id(sender_key) == sender`, and a - RSVP, ready/leave, and chat actions are attributed to the pinned author. They
valid signature before parsing the payload or dispatching a handler. A client become effective only while the referenced creator root is directly
also requires `Response.sender` to equal the TLS-pinned responder. A response present; an author slice pulled before its creator is retained within its
sets its recipient to the verified request sender, copies the verified request ordinary bound but remains hidden until that creator's direct pull arrives.
nonce, and the initiator requires equality, binding the pair. - 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 A newly arriving peer discovers and pulls directly from every live peer, so it
trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never reconstructs calls from the people still present. If an author's peer goes
forwarded. 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 Keep the useful human-scale timers: active calls expire, unresolved expired
only after signature verification, and in memory only. Cache loss or eviction calls may remain visible for five minutes, and Start/Cancel results may remain
can cause a duplicate to be processed, so state-changing handlers remain visible for fifteen minutes. After that, the author drops them from its current
idempotent or revision/event-ID guarded. There is no wall-clock signature snapshot. There are no session-long tombstones, rootless terminal records,
expiry and no claim of durable replay prevention. 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 Retain straightforward schema and resource limits: bounded strings/chat,
intentionally reuses it. The duplicate cache is capped at 64 entries per bounded events and encoded bytes per author, bounded total live peers, and a
sender/context, 256 represented senders, and 8192 entries globally; admission named control-frame maximum. Validate one author's snapshot off to the side and
of a new sender/cache entry is rejected when the applicable bound cannot be accept or reject it as a unit; a bad/oversized peer cannot consume another
met after normal LRU expiry. A response copies rather than generates the author's slice or the local author's capacity. Exact limits are set from the
request nonce. existing three-peer and stress fixtures, not from an internet-scale adversary
model.
Bulk responder-to-initiator chunk and stream-install frames remain unsigned A malicious creator can show inconsistent versions of its own noncritical call
inside the responder-pinned TLS channel. That deliberate asymmetry preserves to different peers. This plan accepts that limit rather than adding signatures,
throughput. Every retry still uses the selected `PeerEndpoint`; TLS must never gossip, consensus, or permanent storage to a party invitation feature.
fall back to “whoever is at this address.”
The length-delimited control codec gets a named maximum below its current 8 ### 3.6 Keep the UI about games and people
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.
### 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 Do not add per-peer source prompts. Every peer with the locally expected
is independently safe as early as possible. `content_id` is an eligible swarm source; verification is automatic.
```text Normal UI uses display names and peer count. A short PeerId suffix may
SignedCallToPlayEvent { disambiguate duplicate names or appear in diagnostics, but there are no New,
protocol_version: u32, Trusted, key-changed, backup, repair, or fingerprint-confirmation workflows.
event_id: EventId,
author: PeerId,
author_key: PublicKey,
body: opaque CallToPlayEventBody bytes,
signature: Signature,
}
CallToPlayEventBody { User-facing exceptional states are concrete:
call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce },
actor_name,
at,
action,
}
```
The body excludes `actor_id`, `call_id`, `event_id`, and independent chat - `Verifying game files` while existing or newly received content is checked;
`message_id`. The author is derived from `author_key`; the redundant outer - `A source sent invalid data; retrying another nearby peer` when recovery is
`author` must match. Core publication creates the 32-byte CSPRNG nonce for in progress;
`Create`, resolves the retained `CallRef` for later actions, chooses identity - `No nearby peer could provide the verified catalog version` after all
and IDs, and signs. The frontend never supplies those authority fields. 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 ## 4. One protocol cutover
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]
```
The body encoding is UTF-8 RFC 8259 JSON with the exact field/action names in Develop the pieces behind internal APIs, then replace protocol 7 with one new
the versioned proto schema, integer timestamps only, explicit URL-safe current protocol (protocol 8 if the version has not moved). Do not ship
unpadded-base64 adapters for `PublicKey` and the 32-byte `CallNonce`, and no intermediate protocol 8/9/10 designs and do not add compatibility decoding.
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.
Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32: The cutover includes:
```text - `PeerId` derived from the TLS SPKI and `PeerEndpoint` required by every
call_id = H("lanspread/ctp-call/v1\0" || creator_key || create_nonce) outbound connection;
event_id = H("lanspread/ctp-event-id/v1\0" || u32_be(protocol_version) - version-bound ALPN and per-installation server certificates instead of the
|| author_key || u32_be(body_length) || body) 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 Peers on another protocol remain excluded, as required by project policy. To
recomputes both IDs and verifies the signature before parsing/admission. The reduce real LAN-party friction, make this one coordinated bump and surface the
event signature covers the transcript above. The content-derived event ID makes adversarial version mismatch rather than failing silently.
same-ID/different-body conflicts unreachable; the enclosing `event_id` is also
the chat/UI message identity.
Authority rules: ## 5. Code ownership
- `Create`, `Start`, `Cancel`, and `AddTime` require Keep the change inside existing crates unless implementation pressure proves a
`author_key == call_ref.creator_key`. real reusable boundary; a new identity crate is not required by the design.
- `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`:
| Area | Responsibility | | Area | Responsibility |
|---|---| |---|---|
| `key` | Ed25519 secret/public key, full PeerId derivation, zeroizing secret record | | `lanspread-db` / `lanspread-compat` | Catalog content-manifest types and loading beside `game.db`. |
| `store` | Explicit modes, normalized backend results, transition table, lifetime lease, keyring/file storage, sidecar, repair API | | `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. |
| `sign` | Exact transcript signing and verification, verified sender/event construction | | `lanspread-peer::identity` | Simple key/certificate load-or-generate, SPKI-derived ID, and test identity injection. |
| `tls` | RPK/X.509 material and pinned rustls client/server configuration | | `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 ## 6. Implementation phases and gates
builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data.
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 | ### Phase 1 — land filesystem confinement immediately
|---|---|
| `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 |
The peer CLI gains deterministic explicit identities, full endpoint input, and Implement `ValidatedDownloadManifest`, make the UI submit only `game_id`, and
hostile modes. Tauri gains minimal Phase 1 repair and Phase 2 source-approval move all validation before transaction/storage mutation. Centralize reserved
surfaces before the complete trust UI. 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, ### Phase 2 — establish real catalog content authority
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.
## 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 Do not claim completion from test fixtures alone: production catalog packages
phases also run `just frontend-test` and `just build`. Every protocol/peer phase must have independently generated manifests, and the release/build path must
runs targeted `just peer-cli-tests` during development and the unfiltered suite reject a missing manifest. Benchmark hashing at normal LAN throughput.
before phase completion. Wire phases run the honest alpha/bravo/charlie matrix
using a freshly built image.
Fix the `justfile` stale-image hazard first: manual `peer-cli-run`, alpha, ### Phase 3 — prove and implement simple responder identity
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 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; ### Phase 4 — make the single wire cutover
- 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 1a may run in parallel. Its identity primitives need only the frozen Bump the current protocol once and activate all coupled wire behavior from
identity/transcript slice; persistent writes and runtime identity replacement §4: pinned transport, catalog `content_id`, catalog-driven downloads, pull-only
wait for the storage table, while unrelated TLS/manifest measurements may library synchronization, bounded invalidation hints, author-owned
finish concurrently. 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 - three fresh peers discover each other with no prompts and see post-start
before Phase 1b writes persistent identity. Phase 1a may remain in parallel. library changes;
Mandatory hostile path, limit, absent-from-authoritative-selection, and - a new peer reconstructs active Call-to-Play state by pulling every live
zero-mutation tests land here, including the cross-game `local/` sentinel. Run author, and creator departure removes the call;
the full peer-CLI suite and `just build` because the Tauri-to-download - every metadata, chunk, retry, stream-install, healing, liveness, and direct
integration is in the path. Windows alias/reparse behavior requires supported CLI dial rejects the wrong key at the expected address;
Windows CI or recorded manual evidence; Linux-only results are labelled as - a forged mDNS record or inbound hint cannot create/rebind/remove peer state,
such rather than generalized. 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 ### Phase 5 — finish the small user-facing surface and audit
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 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 Run all standard checks, the complete peer-CLI suite, fresh three-peer manual
backends, secret-first sidecar recovery, override isolation, correct Tauri scenarios, production builds/bundles on supported platforms, and a final audit
`app_data_dir`, encrypted backup/import/reset, and the minimal repair UI. Keep for:
the protocol-7 runtime/advertised UUID until the Phase 2 wire cutover; do not
populate security trust from it.
Mandatory tests include every backend state, no-fallthrough on - raw remote manifests reaching storage;
locked/denied/unavailable/corrupt, sidecar mismatch, absent/corrupt sidecar - unhashed transfer completion or CRC32 presented as malicious-source proof;
recovery, unsupported-version zero writes, valid-sidecar no unselected probe, - the shared repository TLS private key;
multiple secrets, write-failure reprobe, crash after secret before sidecar, - address-only outbound connections or fabricated peer IDs;
replacement mismatch repair, every backend-migration intent stage and target - direct mutation from mDNS, inbound Hello, deltas, or change hints;
conflict, account-wide lease held throughout repair, two - relayed third-party Call-to-Play history or permanent tombstones;
simultaneous starts, file permissions/atomic replacement, CLI-over-environment - signed control envelopes, nonce/replay tables, keyring/backup/repair code, or
precedence and zero default-store access in explicit modes, per-peer source authorization reappearing without a new product requirement;
tampered/wrong-passphrase backup, selected-backend reset/import failure without - silent protocol-version failure; and
fallback, successful export/import fingerprint round trip, header/decoded-size - user wording that calls a peer, display name, or executable “trusted” merely
and KDF-cost bounds rejected before expensive allocation/work with zero writes, because TLS or a hash check succeeded.
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.
### Phase 2 — responder-authenticated transport and endpoints (protocol 8) ## 7. Success criteria
Land the selected RPK/X.509 mode, real CertificateVerify, mandatory expected The plan is complete when the following statement is true from a user's point
endpoint, all endpoint plumbing, mDNS candidate separation/retry, of view:
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.
Client-originated controls remain unauthenticated. Library/legacy notifications > I opened Lanspread at a LAN party, immediately saw the people and games
cause only bounded pinned resync. Protocol 8 disables all network Call-to-Play > nearby, downloaded from all matching peers without approving devices, and
ingestion/sync; local CTP remains available and Phase 3 restores networking > Lanspread itself rejected any wrong data. I never had to know that it owns a
with self-authenticating events. The phase-current scenario matrix explicitly > TLS key.
expects this temporary gate instead of retaining contradictory S48/S49
third-party-relay expectations.
Mandatory negatives: cert/SPKI A with private key B, wrong expected ID, From the implementation point of view, that experience rests on only three
disallowed/mismatched RPK or X.509 mode, resumption/0-RTT disabled, copied security boundaries: confined local paths, catalog-owned content hashes, and
public-key inbound claim, invalid callback socket class/source-IP, every responder-pinned TLS. Call to Play deliberately reuses the pinned-pull model
per-source/per-target/global callback and candidate-cap axis, advertised Ack and remains ephemeral instead of becoming a second distributed security
ID/key mismatch and address redirect, callback/resync terminal behavior with no protocol.
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.