Replace the earlier peer-authentication proposal with the reviewed, implementation-oriented design. The plan now records the identity-storage state machine, pinned TLS and endpoint rules, signed Call-to-Play objects, download-source authorization, resource limits, safe protocol phases, and phase-owned acceptance gates. Remove the standalone review after incorporating its findings and follow-up adjudication into the authoritative plan, including an explicit closure matrix. This avoids maintaining two documents with conflicting severity and guidance. Test Plan: - `git diff --cached --check` -- passed - Code tests not run; documentation-only change
1423 lines
80 KiB
Markdown
1423 lines
80 KiB
Markdown
# Peer authentication, identity continuity, and download-source authorization
|
||
|
||
## Status
|
||
|
||
Architecture approved; implementation plan.
|
||
|
||
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.
|
||
|
||
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 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. Executive summary
|
||
|
||
The design has six connected parts:
|
||
|
||
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.
|
||
|
||
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 |
|
||
|---|---|---|
|
||
| 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. |
|
||
|
||
## 6. Prerequisite: confine download preparation before persistent identity work
|
||
|
||
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.
|
||
|
||
### 6.1 Validated manifest boundary
|
||
|
||
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.
|
||
|
||
For the entire list, validation 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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 6.2 Mandatory proof
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
## 7. Normative design
|
||
|
||
### 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:
|
||
|
||
```text
|
||
Present(secret) | NoEntry | Locked | Denied | Unavailable | Corrupt
|
||
```
|
||
|
||
Only `NoEntry` proves absence. An I/O error, locked store, denied access,
|
||
unavailable service, or corrupt record never authorizes generation.
|
||
|
||
#### Persistent transition table
|
||
|
||
| 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. |
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
#### Locking and durability
|
||
|
||
- 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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
- 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 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.
|
||
|
||
#### Repair, backup, import, and reset
|
||
|
||
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.
|
||
|
||
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
|
||
|
||
```rust
|
||
struct PeerEndpoint {
|
||
peer_id: PeerId,
|
||
addr: SocketAddr,
|
||
}
|
||
```
|
||
|
||
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.
|
||
|
||
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 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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### 7.5 Authenticated QUIC responder
|
||
|
||
Use the s2n-quic rustls provider with TLS 1.3 only.
|
||
|
||
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.
|
||
|
||
The custom client verifier captures the expected `PeerId` from
|
||
`PeerEndpoint` and MUST:
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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:
|
||
|
||
```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]
|
||
```
|
||
|
||
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,
|
||
}
|
||
```
|
||
|
||
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 exact signature input is:
|
||
|
||
```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]
|
||
```
|
||
|
||
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.
|
||
|
||
Boundary verification yields `VerifiedSender`; handlers no longer accept or
|
||
trust payload `peer_id` fields. Control envelopes are hop-by-hop and are never
|
||
forwarded.
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.”
|
||
|
||
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.
|
||
|
||
### 7.9 Independently signed Call-to-Play events
|
||
|
||
Call-to-Play objects land before general signed envelopes so relayed authorship
|
||
is independently safe as early as possible.
|
||
|
||
```text
|
||
SignedCallToPlayEvent {
|
||
protocol_version: u32,
|
||
event_id: EventId,
|
||
author: PeerId,
|
||
author_key: PublicKey,
|
||
body: opaque CallToPlayEventBody bytes,
|
||
signature: Signature,
|
||
}
|
||
|
||
CallToPlayEventBody {
|
||
call_ref: CallRef { creator_key: PublicKey, create_nonce: CallNonce },
|
||
actor_name,
|
||
at,
|
||
action,
|
||
}
|
||
```
|
||
|
||
The body excludes `actor_id`, `call_id`, `event_id`, and independent chat
|
||
`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.
|
||
|
||
The exact event-signature transcript is:
|
||
|
||
```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]
|
||
```
|
||
|
||
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.
|
||
|
||
Derived IDs use full SHA-256 digests encoded as 52-character lowercase base32:
|
||
|
||
```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)
|
||
```
|
||
|
||
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.
|
||
|
||
Authority rules:
|
||
|
||
- `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`:
|
||
|
||
| 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-proto` owns wire-only fixed types, explicit serializers, transcript
|
||
builders, `PeerEndpoint`, `SignedEnvelope`, and signed Call-to-Play wire data.
|
||
|
||
Principal `lanspread-peer` changes:
|
||
|
||
| 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 |
|
||
|
||
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.
|
||
|
||
## 10. UI and operational semantics
|
||
|
||
- **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.
|
||
|
||
## 11. Implementation phases and acceptance gates
|
||
|
||
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.
|
||
|
||
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 0 — freeze contracts and complete spikes
|
||
|
||
Record in the repository:
|
||
|
||
- 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 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.
|
||
|
||
### Prerequisite — download manifest confinement
|
||
|
||
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.
|
||
|
||
### Phase 1a — primitives and fake-backed groundwork
|
||
|
||
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 1b — durable identity and usable repair, no wire change
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
### Phase 2 — responder-authenticated transport and endpoints (protocol 8)
|
||
|
||
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.
|
||
|
||
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.
|
||
|
||
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.
|