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