Peer auth plan
This commit is contained in:
@@ -0,0 +1,583 @@
|
|||||||
|
# Rework peer authentication: keys, signed events, real identity
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposal. Supersedes the line in `CALL_TO_PLAY_FIXES_PLAN.md` that said not to
|
||||||
|
introduce cryptographic peer identities: Call to Play's event/merge design
|
||||||
|
stays exactly as it is, but the identities it is keyed on become real.
|
||||||
|
|
||||||
|
Nothing in this document is implemented yet.
|
||||||
|
|
||||||
|
## 1. Where we are today
|
||||||
|
|
||||||
|
Everything a peer claims about itself is a string it made up, and every trust
|
||||||
|
decision in the peer runtime is made on those strings.
|
||||||
|
|
||||||
|
- **One shared TLS identity for the entire product.** `config.rs:48-51`
|
||||||
|
compiles `cert.pem` and `key.pem` from the repository root into the binary.
|
||||||
|
The QUIC server presents it (`services/server.rs:31`) and the client trusts
|
||||||
|
exactly it, under the server name `localhost` (`network.rs:70-78`). The
|
||||||
|
private key is in git. Any machine on the LAN can therefore terminate,
|
||||||
|
impersonate, and man-in-the-middle any peer connection: the transport
|
||||||
|
authenticates "some build of Lanspread", not "this peer".
|
||||||
|
- **`peer_id` is a self-asserted UUID.** `identity.rs:11-26` generates a
|
||||||
|
UUIDv7 and stores it in `<state_dir>/peer_id`. It is announced in mDNS TXT
|
||||||
|
records (`services/advertise.rs:96`), in `Hello`/`HelloAck`, and re-sent as a
|
||||||
|
field inside individual request bodies.
|
||||||
|
- **The peer table is mutated from unauthenticated input.**
|
||||||
|
`services/discovery.rs:165-182` upserts a peer record and its library
|
||||||
|
revision straight from mDNS TXT data, before any handshake.
|
||||||
|
`peer_db.rs:78-117` rebinds a known `peer_id` to whatever address the caller
|
||||||
|
supplies, and evicts whichever peer previously held that address.
|
||||||
|
- **Request envelopes carry the sender's identity as payload.**
|
||||||
|
`services/stream.rs:89-116` reads `peer_id` out of `Request::LibraryDelta`,
|
||||||
|
`Request::CallToPlayEvents` and `Request::Goodbye`. Concretely:
|
||||||
|
- `handle_goodbye` (`services/stream.rs:497-504`) removes any peer named in
|
||||||
|
the request and ignores the transport address entirely. Any LAN host can
|
||||||
|
evict any peer from anyone's peer list, repeatedly.
|
||||||
|
- `handle_library_delta` (`services/stream.rs:223`) attributes a library
|
||||||
|
delta to the named peer.
|
||||||
|
- `handle_call_to_play_events` (`services/stream.rs:124-138`) validates that
|
||||||
|
each event's `actor_id` equals the `peer_id` **from the same request body**,
|
||||||
|
which is a self-consistency check, not authentication.
|
||||||
|
- `note_peer_activity` (`services/stream.rs:178-185`) refreshes liveness for
|
||||||
|
whoever opened the stream.
|
||||||
|
- **Call to Play authority is a string comparison.** `HistoryIndex::build`
|
||||||
|
(`call_to_play.rs:246-321`) treats the earliest `Create` event's `actor_id` as
|
||||||
|
the creator and accepts `Start`, `Cancel`, and `AddTime` from that string.
|
||||||
|
A hostile peer can cancel or start anyone's call, chat as anyone, and — since
|
||||||
|
`MAX_EVENTS` (`call_to_play.rs:20`) is a single global 4096-event bound — fill
|
||||||
|
the shared history until legitimate local publishes fail with `HistoryFull`.
|
||||||
|
- **`ARCHITECTURE.md` already says this out loud** in the Call to Play
|
||||||
|
replication and streamed-install sections: current checks prevent accidental
|
||||||
|
identity mixing, not a hostile LAN peer.
|
||||||
|
|
||||||
|
Two of those sentences are the reason for this plan. A LAN party is a
|
||||||
|
semi-trusted network: the people are fine, the network is a hotel/venue/dorm
|
||||||
|
segment with unknown machines on it, and "griefing the guy who cancelled my
|
||||||
|
match" is a five-line Python script today.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. Every peer has a **long-lived cryptographic identity** it controls, and
|
||||||
|
`peer_id` is derived from that key rather than asserted.
|
||||||
|
2. The identity **survives app updates, reinstalls, and IP changes**, and is
|
||||||
|
stored with platform-appropriate protection at rest.
|
||||||
|
3. **The transport authenticates the peer**, so QUIC connections cannot be
|
||||||
|
impersonated or man-in-the-middled by another LAN host.
|
||||||
|
4. **Every control message is attributable** to the key that produced it, and
|
||||||
|
every trust decision in the runtime is made on the verified identity instead
|
||||||
|
of a payload field.
|
||||||
|
5. **Stored and forwarded objects carry their own signatures.** Call to Play
|
||||||
|
events are relayed by third parties in handshake snapshots, so channel
|
||||||
|
authentication alone is not enough for them.
|
||||||
|
6. The trust state is **visible and manageable by the user**: who is here,
|
||||||
|
which key that is, is this the same "Alice" as last time, block this peer.
|
||||||
|
7. Failure modes are honest: no silent identity regeneration, no silent
|
||||||
|
downgrade to a weaker store, no "verified" badge that means nothing.
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- **No PKI, no CA, no accounts, no internet dependency.** Self-certifying keys
|
||||||
|
only; trust is first-use pinning plus explicit user action.
|
||||||
|
- **No content trust.** Game bytes stay sender-controlled; a peer that is
|
||||||
|
authenticated is not thereby a peer that ships honest archives. That needs
|
||||||
|
catalog-owned hashes (`NEXT_STEPS.md:37`, `ARCHITECTURE.md` streamed-install
|
||||||
|
section) and is a separate piece of work. This plan must not imply it is
|
||||||
|
solved.
|
||||||
|
- **No wire compatibility.** Per `CLAUDE.md`, there is one wire version. Each
|
||||||
|
wire-affecting phase bumps `PROTOCOL_VERSION`; older builds are simply out.
|
||||||
|
- **No anonymity or metadata privacy.** Peer keys, ids, and display names are
|
||||||
|
public on the segment by design.
|
||||||
|
- **No clock trust.** Wall-clock skew tolerance stays a documented assumption,
|
||||||
|
as it already is for Call to Play deadlines.
|
||||||
|
|
||||||
|
## 4. Threat model
|
||||||
|
|
||||||
|
Attacker sits on the same L2 segment, can send and receive arbitrary packets,
|
||||||
|
can run a modified Lanspread build, and knows everything in the git repository
|
||||||
|
(including today's `key.pem`). The attacker does not have local code execution
|
||||||
|
on a victim machine and is not an OS-level adversary on that machine.
|
||||||
|
|
||||||
|
| # | Attack | Today | After |
|
||||||
|
|---|---|---|---|
|
||||||
|
| T1 | Impersonate peer X to peer Y | Trivial: copy `peer_id`, use the shipped cert | Requires X's private key |
|
||||||
|
| T2 | MITM a peer-to-peer QUIC connection | Trivial: shipped key, no pinning | Client pins the responder's key; MITM cannot present it |
|
||||||
|
| T3 | Evict peers with forged `Goodbye` | Trivial | Rejected: `Goodbye` must be signed by its subject |
|
||||||
|
| T4 | Rebind a peer's address to the attacker's host | Trivial via mDNS TXT | mDNS becomes an unauthenticated hint; only a verified handshake mutates the peer table |
|
||||||
|
| T5 | Cancel/Start/AddTime someone else's call | Trivial | Requires the root event author's key |
|
||||||
|
| T6 | Chat or RSVP as another player | Trivial | Requires that player's key |
|
||||||
|
| T7 | Replay a captured control message | Works | Freshness window, per-sender nonce cache, recipient binding |
|
||||||
|
| T8 | Exhaust Call to Play history so local publishes fail | Works (global 4096 cap) | Per-author quota inside the global cap |
|
||||||
|
| T9 | Sybil-flood the peer list | Works | Bounded known-peer count, new-peer admission rate limit, block list |
|
||||||
|
| T10 | Spoof a display name ("Alice") with a fresh key | Works, invisible | Name is signed but unverified: TOFU pin + explicit UI conflict warning |
|
||||||
|
| T11 | Serve corrupt or hostile game content | Works | **Unchanged — out of scope** |
|
||||||
|
| T12 | Steal a peer's identity key off a running machine | Plaintext file | OS secret store where available; documented fallback otherwise |
|
||||||
|
|
||||||
|
T10 is worth stating precisely: cryptography can prove *the same key* as last
|
||||||
|
time. It cannot prove a human's name. The UI must present continuity ("this is
|
||||||
|
the Alice you played with yesterday") rather than authenticity of the name.
|
||||||
|
|
||||||
|
## 5. Design
|
||||||
|
|
||||||
|
### 5.1 Identity
|
||||||
|
|
||||||
|
- One **Ed25519** keypair per installation ("device identity"). A LAN party
|
||||||
|
machine is one player; there is no separate user identity.
|
||||||
|
- `PeerId` = lowercase RFC 4648 base32, no padding, of the first 20 bytes of
|
||||||
|
`SHA-256(public_key)` → 32 characters. DNS-label safe, case-insensitive-safe
|
||||||
|
(important: it appears in mDNS instance names and TLS SNI), short enough to
|
||||||
|
print, 160-bit second-preimage / 80-bit collision resistance, which is ample
|
||||||
|
for a LAN.
|
||||||
|
- Self-certifying: every message that claims an id carries the public key, and
|
||||||
|
receivers recompute `derive_peer_id(key) == claimed_id`. There is no key
|
||||||
|
distribution problem and no key-exchange step.
|
||||||
|
- Display: full id grouped in blocks of four (`a3f2-9k1m-…`); short form =
|
||||||
|
first eight characters, always shown next to a display name.
|
||||||
|
- The same key signs both the TLS certificate and application objects. TLS 1.3
|
||||||
|
signatures are already domain-separated by the TLS transcript prefix, and all
|
||||||
|
application signatures are domain-separated by an explicit context string
|
||||||
|
(5.4), so cross-protocol reuse is safe. Alternative if the TLS spike in
|
||||||
|
Phase 2 disappoints: ECDSA P-256 for the certificate, Ed25519 for objects,
|
||||||
|
bound by a signed statement in `Hello`. Extra moving part; only if needed.
|
||||||
|
|
||||||
|
### 5.2 Identity storage
|
||||||
|
|
||||||
|
New `lanspread-identity` crate owns an `IdentityStore` with a fixed resolution
|
||||||
|
order:
|
||||||
|
|
||||||
|
1. `LANSPREAD_IDENTITY_SEED` (env, base64url seed) — deterministic identities
|
||||||
|
for tests, containers, and scripted scenarios. Never persisted.
|
||||||
|
2. `--identity-file` / `LANSPREAD_IDENTITY_FILE` — explicit path, for the
|
||||||
|
peer-cli and CI.
|
||||||
|
3. **OS secret store** via `keyring`: Windows Credential Manager (DPAPI),
|
||||||
|
macOS Keychain, Linux Secret Service. Service `network.paul.lanspread`,
|
||||||
|
account `peer-identity`.
|
||||||
|
4. **File fallback** `<state_dir>/identity.key`, mode `0600` on Unix,
|
||||||
|
user-only ACL on Windows, written tmp + atomic rename.
|
||||||
|
|
||||||
|
Alongside it, a **non-secret** `<state_dir>/identity.json` always records
|
||||||
|
`{ version, peer_id, public_key, created_at, backend }`. That gives us: the UI
|
||||||
|
can show the identity and its protection level without touching the secret
|
||||||
|
store; a mismatch between the sidecar and the loaded key is detectable; and the
|
||||||
|
selected backend is auditable rather than guessed.
|
||||||
|
|
||||||
|
Hard rules:
|
||||||
|
|
||||||
|
- **Never silently generate a new identity when one exists but cannot be
|
||||||
|
read.** A locked keychain, a revoked ACL, or a corrupt file must surface a
|
||||||
|
loud, actionable error and a repair flow, because a new identity silently
|
||||||
|
invalidates every other peer's pin of us. This is the single most important
|
||||||
|
correctness rule in the storage layer.
|
||||||
|
- Seed material is `Zeroize`d, never logged, never sent to the frontend, and
|
||||||
|
never included in any event or error string.
|
||||||
|
- The backend actually used is reported to the UI. A file fallback says
|
||||||
|
"stored in a file on this machine" rather than pretending to be a keychain.
|
||||||
|
- Honest framing for the update-survival question: `<state_dir>` (`~/.lanspread`
|
||||||
|
by default, `state_paths.rs:10-24`) already survives binary updates, and so
|
||||||
|
does the current plaintext `peer_id`. The OS secret store buys protection at
|
||||||
|
rest and platform-consistent behavior — not update survival. Both are worth
|
||||||
|
having; only one of them is new.
|
||||||
|
- Export/import: passphrase-wrapped seed blob (PBKDF2-HMAC-SHA256, high
|
||||||
|
iteration count, XChaCha20-Poly1305 or AES-GCM from the crypto backend we
|
||||||
|
already link) so a user can move their identity to a new machine on purpose.
|
||||||
|
- Migration: the legacy `<state_dir>/peer_id` UUID file is deleted by the
|
||||||
|
existing pre-start migration phase (`migration.rs`). Nothing persistent is
|
||||||
|
keyed on it — the peer table is in-memory (`peer_db.rs`) and there are no
|
||||||
|
sqlite migrations — so the change is a clean break.
|
||||||
|
|
||||||
|
### 5.3 Transport: authenticated QUIC
|
||||||
|
|
||||||
|
Switch `s2n-quic` to the rustls provider on every platform and take control of
|
||||||
|
certificate verification. This is already available in the pinned versions:
|
||||||
|
`s2n-quic 1.83` exposes `provider-tls-rustls` →
|
||||||
|
`s2n_quic::provider::tls::rustls` (re-exported `s2n-quic-rustls 0.83`), whose
|
||||||
|
`Client`/`Server` are constructible from a full `rustls::ClientConfig` /
|
||||||
|
`ServerConfig` (rustls 0.23, aws-lc-rs backend). Dropping
|
||||||
|
`provider-tls-default` also drops the s2n-tls C build from the tree.
|
||||||
|
|
||||||
|
- At startup each peer generates (once, cached in memory) a self-signed
|
||||||
|
certificate whose SPKI is its Ed25519 identity key, CN = `peer_id`,
|
||||||
|
SAN DNS = `<peer_id>.lanspread`.
|
||||||
|
- **Server** presents that certificate. No client certificates in Phase 2 (see
|
||||||
|
the rationale below).
|
||||||
|
- **Client** uses a custom `ServerCertVerifier` that ignores CA chains and
|
||||||
|
hostname policy and instead: extracts the SPKI, requires Ed25519, derives the
|
||||||
|
peer id, and requires it to equal the **expected** id. The expected id comes
|
||||||
|
from the connection request and is also encoded in SNI
|
||||||
|
(`<expected_id>.lanspread`), so pinning works whether clients stay
|
||||||
|
per-connection (`network.rs:67-80` today) or get pooled later.
|
||||||
|
- `connect_to_peer` grows a required `expected: PeerId` argument. There is no
|
||||||
|
"connect to an address and see who answers" path any more; discovery always
|
||||||
|
supplies the id it is chasing.
|
||||||
|
- Result: T1/T2 die at the transport. Everything the responder sends on that
|
||||||
|
connection — `HelloAck`, snapshots, manifests, chunk bytes, stream-install
|
||||||
|
frames — is authenticated by TLS to the pinned key, with no per-frame work.
|
||||||
|
|
||||||
|
Why no client certificates: s2n-quic does not surface the peer certificate to
|
||||||
|
the application, so a client certificate would authenticate the channel to
|
||||||
|
nobody the app can name. Client authentication therefore happens one layer up
|
||||||
|
(5.4), where it is bound to the pinned responder identity. If s2n-quic later
|
||||||
|
exposes peer certificates, the two layers can collapse — worth a comment in the
|
||||||
|
code so the option is not forgotten.
|
||||||
|
|
||||||
|
### 5.4 Signed envelopes for control messages
|
||||||
|
|
||||||
|
Every `Request` and `Response` is wrapped:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Signed {
|
||||||
|
pub sender: PeerId, // must equal derive_peer_id(sender_key)
|
||||||
|
pub sender_key: PublicKey, // 32 bytes
|
||||||
|
pub recipient: PeerId, // who this is for; blocks cross-peer relay
|
||||||
|
pub sent_at_ms: i64,
|
||||||
|
pub nonce: [u8; 16],
|
||||||
|
pub context: SigContext, // Request | Response | Event
|
||||||
|
pub payload: Bytes, // exact serialized inner message
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Sign the transmitted bytes, do not canonicalize.** The wire format is
|
||||||
|
`serde_json`, which is not canonical, so the envelope carries the inner
|
||||||
|
message as opaque pre-serialized bytes (base64 in JSON) and the signature
|
||||||
|
covers exactly those bytes. Receivers verify first, deserialize second. This
|
||||||
|
removes an entire category of "re-serialization broke the signature" bugs and
|
||||||
|
is what makes forwarding (5.5) work at all.
|
||||||
|
- Signed input is domain-separated and length-prefixed:
|
||||||
|
`"lanspread-sig-v1" || context_tag || len(sender) || sender || … || len(payload) || payload`.
|
||||||
|
- Freshness: reject `|now - sent_at_ms| > 120 s`; keep a bounded per-sender
|
||||||
|
nonce LRU inside that window (e.g. 1024 nonces × 256 peers) to kill replay
|
||||||
|
(T7). Call to Play events are additionally idempotent by event id, and
|
||||||
|
library deltas are revision-guarded, so the nonce cache is defence in depth
|
||||||
|
for everything except `Goodbye`, where it is load-bearing.
|
||||||
|
- **Bulk data frames stay unsigned.** `StreamInstallFrame`s and raw chunk bytes
|
||||||
|
flow only responder → initiator, inside a channel whose responder key the
|
||||||
|
initiator pinned, so TLS already authenticates them. Signing 1 MiB chunks
|
||||||
|
would cost throughput and buy nothing. This asymmetry is deliberate and must
|
||||||
|
be documented next to the code.
|
||||||
|
- Verification happens once, at the stream boundary, in
|
||||||
|
`services/stream.rs::handle_peer_stream`. Handlers receive a
|
||||||
|
`VerifiedSender { peer_id, public_key }` and lose the `peer_id: String`
|
||||||
|
parameters they take today. That is the whole point: it becomes impossible to
|
||||||
|
write a handler that trusts a payload field, because there is no payload
|
||||||
|
field to trust.
|
||||||
|
|
||||||
|
### 5.5 Signed Call to Play events
|
||||||
|
|
||||||
|
Channel authentication is insufficient here: `Hello`/`HelloAck` carry *other
|
||||||
|
peers'* events (`ARCHITECTURE.md`, Call to Play replication), so B hands me A's
|
||||||
|
history. Those events must be verifiable independently of who relayed them.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SignedEvent {
|
||||||
|
pub author: PeerId,
|
||||||
|
pub author_key: PublicKey,
|
||||||
|
pub body: Bytes, // exact serialized CallToPlayEventBody
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `CallToPlayEventBody` is today's `CallToPlayEvent` **minus `actor_id`** — the
|
||||||
|
author is the key. `actor_name` stays, signed but unverified (T10).
|
||||||
|
`publish()`'s `event.actor_id.clone_from(...)` overwrite
|
||||||
|
(`call_to_play.rs:403`) disappears; the signature replaces it.
|
||||||
|
- `CallToPlayStore` stores `SignedEvent` and re-transmits `body` **verbatim**
|
||||||
|
in snapshots, so signatures survive relaying. Nothing in the store may
|
||||||
|
re-serialize a body.
|
||||||
|
- **Creator authority becomes cryptographic.** `HistoryIndex` compares signing
|
||||||
|
keys instead of `actor_id` strings for `Start` / `Cancel` / `AddTime`
|
||||||
|
(T5).
|
||||||
|
- **`call_id` becomes creator-bound**:
|
||||||
|
`call_id = base32(SHA-256(author_key || create_nonce))[..16]`. Receivers
|
||||||
|
verify the derivation for `Create` events and reject a `Create` for an
|
||||||
|
existing `call_id` from a different author. A hostile peer can no longer
|
||||||
|
plant a competing root for someone's call.
|
||||||
|
- **Per-author quotas** replace the single global cap: global 4096 unresolved
|
||||||
|
events *and* a per-author bound (e.g. 256), so one peer cannot starve the
|
||||||
|
shared history (T8). Terminal histories and tombstones keep their current
|
||||||
|
exemption.
|
||||||
|
- **Verified-signature cache** keyed by event id so each handshake merge only
|
||||||
|
verifies genuinely new events; handshakes carry full histories and must not
|
||||||
|
become O(history) in signature checks.
|
||||||
|
- Sanity-bound `at` against the local clock (±10 min) so ordering stays sane,
|
||||||
|
and keep documenting the skew assumption rather than pretending to fix it.
|
||||||
|
- The rest of the Call to Play design — immutable events, deterministic
|
||||||
|
reduction, atomic batch merges, tombstones, retention windows, ack-and-heal —
|
||||||
|
is untouched. This is an identity change, not a replication change.
|
||||||
|
|
||||||
|
### 5.6 Discovery becomes a hint
|
||||||
|
|
||||||
|
- mDNS TXT gains `pk` (base64url public key); `peer_id` stays and must match
|
||||||
|
the derived id. Optionally an `adv_sig` over
|
||||||
|
`peer_id|addr|library_rev|library_digest|timestamp`, which is cheap but of
|
||||||
|
limited value once the handshake verifies everything — decide during
|
||||||
|
implementation, not before.
|
||||||
|
- `services/discovery.rs::handle_discovered_peer` **stops mutating the peer
|
||||||
|
table**. It records a candidate `(peer_id, addr, key)` and triggers a
|
||||||
|
verified handshake; only `perform_handshake_with_peer` /
|
||||||
|
`accept_inbound_hello` create or rebind peer records (T4).
|
||||||
|
- The mismatch path in `handshake.rs:119-127` no longer removes the expected
|
||||||
|
peer (`remove_peer(expected)`), which is itself a forged-eviction primitive
|
||||||
|
today. A mismatch fails the attempt and logs.
|
||||||
|
- `peer_db.rs`'s "fall back to a unique peer with the same IP" heuristic
|
||||||
|
(`peer_id_for_transport_addr`, `peer_db.rs:140-162`) and address-based
|
||||||
|
liveness (`update_last_seen_by_addr`) are replaced by verified-identity
|
||||||
|
lookups.
|
||||||
|
Liveness refreshes only on a verified frame.
|
||||||
|
- Sybil bounds: cap known peers, rate-limit new-peer admission per minute, and
|
||||||
|
keep discovery cheap enough that a flood degrades gracefully (T9).
|
||||||
|
|
||||||
|
### 5.7 Trust store and user-visible trust
|
||||||
|
|
||||||
|
`<state_dir>/trust/peers.json`, versioned, atomic write, debounced:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "version": 1,
|
||||||
|
"peers": { "<peer_id>": {
|
||||||
|
"public_key": "…", "first_seen": 0, "last_seen": 0,
|
||||||
|
"names": ["Alice"], "pinned_name": "Alice",
|
||||||
|
"state": "known", // known | blocked
|
||||||
|
"rotated_from": null } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- First contact is TOFU: recorded, surfaced as **new**, never auto-labelled
|
||||||
|
trusted.
|
||||||
|
- **Name-conflict detection**: a known `pinned_name` arriving with an unknown
|
||||||
|
key produces a UI warning and never overwrites the pin (T10).
|
||||||
|
- **Blocking** is enforced at handshake (reject) and connection (drop, serve
|
||||||
|
nothing), and persists across restarts.
|
||||||
|
|
||||||
|
### 5.8 Optional hardening (later phases)
|
||||||
|
|
||||||
|
- **Key rotation with continuity**:
|
||||||
|
`RotationStatement { old_key, new_key, at, sig_by_old, sig_by_new }`
|
||||||
|
published in `Hello`; receivers move the pin and record `rotated_from`. A
|
||||||
|
lost key means no continuity — that is a new device, and the UI says so.
|
||||||
|
- **Party admission code** for public venues: `psk` derived from a short
|
||||||
|
human-shareable code; `Hello` carries
|
||||||
|
`HMAC(psk, sender||recipient||nonce)`; peers without a valid proof are
|
||||||
|
refused while a code is set. Off by default — a normal LAN party stays open.
|
||||||
|
- **Connection-level abuse control**: s2n-quic address-token / retry providers
|
||||||
|
plus per-address handshake rate limits, so an unauthenticated flood cannot
|
||||||
|
soak the accept loop.
|
||||||
|
|
||||||
|
## 6. Wire protocol changes
|
||||||
|
|
||||||
|
- `PROTOCOL_VERSION` bumps once per wire-affecting phase: 7 → **8** (transport
|
||||||
|
identity), → **9** (signed envelopes), → **10** (signed events), → **11** if
|
||||||
|
admission control lands. No compatibility paths, per project policy.
|
||||||
|
- `lanspread-proto` gains `PeerId`, `PublicKey`, `Signature`, `Signed`,
|
||||||
|
`SignedEvent`, `SigContext` as **dumb data types with no crypto dependency**;
|
||||||
|
`lanspread-identity` does all key handling. Proto must not depend on the
|
||||||
|
identity crate, and the identity crate must not depend on proto — shared
|
||||||
|
newtypes live in proto, algorithms live in identity.
|
||||||
|
- `Request`/`Response` are wrapped in `Signed`; the per-request `peer_id`
|
||||||
|
fields on `LibraryDelta`, `CallToPlayEvents`, and `Goodbye` are **removed**.
|
||||||
|
- `Hello`/`HelloAck` carry `public_key`, the display name, and (later) rotation
|
||||||
|
statements and admission proof. `call_to_play_events` becomes
|
||||||
|
`Vec<SignedEvent>`.
|
||||||
|
- `CallToPlayEvent` → `CallToPlayEventBody` without `actor_id`.
|
||||||
|
- mDNS TXT gains `pk`.
|
||||||
|
|
||||||
|
## 7. Code map
|
||||||
|
|
||||||
|
New crate `crates/lanspread-identity`:
|
||||||
|
|
||||||
|
| Module | Contents |
|
||||||
|
|---|---|
|
||||||
|
| `key` | Ed25519 keypair, `PeerId` derivation, `Zeroize`ing seed wrapper |
|
||||||
|
| `store` | `IdentityStore` backends (env, file, keyring, fallback), sidecar, repair errors |
|
||||||
|
| `sign` | Domain-separated signing/verification, `Signed`/`SignedEvent` construction and checks |
|
||||||
|
| `freshness` | Clock window + per-sender nonce LRU |
|
||||||
|
| `tls` | Self-signed cert generation, pinned `ServerCertVerifier`, rustls config builders |
|
||||||
|
| `trust` | Trust store, TOFU pinning, name conflicts, block list |
|
||||||
|
|
||||||
|
Changes in `lanspread-peer`:
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `identity.rs` | UUID generation → load identity, expose `peer_id`, public key, backend |
|
||||||
|
| `config.rs` | Delete `CERT_PEM` / `KEY_PEM`; delete `cert.pem` / `key.pem` from the repo |
|
||||||
|
| `network.rs` | rustls client config, `expected: PeerId` on connect, signed request/response helpers |
|
||||||
|
| `services/server.rs` | rustls server config with the per-peer certificate |
|
||||||
|
| `services/stream.rs` | Verify envelopes at the boundary; handlers take `VerifiedSender`; fix `handle_goodbye`, `handle_library_delta`, `handle_call_to_play_events`, `note_peer_activity` |
|
||||||
|
| `services/discovery.rs` | mDNS as hint only; no peer-table mutation; candidate + handshake |
|
||||||
|
| `services/handshake.rs` | Verify `Hello`/`HelloAck` signatures and key↔id binding; no `remove_peer` on mismatch; trust-store updates |
|
||||||
|
| `services/advertise.rs` | Advertise `pk`; instance name from short id |
|
||||||
|
| `services/liveness.rs` | Liveness on verified frames only |
|
||||||
|
| `peer_db.rs` | Records hold public keys; drop IP-based identity fallbacks |
|
||||||
|
| `call_to_play.rs` | `SignedEvent` store, key-based creator authority, derived `call_id`, per-author quotas, verification cache |
|
||||||
|
| `error.rs` | Typed `PeerAuthError` variants that reach the frontend as codes, not substrings |
|
||||||
|
|
||||||
|
Changes elsewhere: `lanspread-peer-cli` gets `--identity-file` /
|
||||||
|
`--identity-seed`, an `identity` JSONL command, and a hostile mode (§9);
|
||||||
|
`src-tauri` gets identity/trust commands and events; the frontend gets the
|
||||||
|
identity panel and peer chips (§8); `justfile` gets deterministic identities for
|
||||||
|
the alpha/bravo/charlie containers.
|
||||||
|
|
||||||
|
## 8. UI and UX
|
||||||
|
|
||||||
|
- **Settings → Identity**: display name, short id, full grouped fingerprint,
|
||||||
|
copy button, storage backend with an honest protection label, export/import,
|
||||||
|
rotate, and reset-with-consequences (resetting invalidates every other peer's
|
||||||
|
pin of you).
|
||||||
|
- **Peer chips everywhere** (peer list, game rows, Call to Play roster and
|
||||||
|
chat): display name + short id, a **new** badge on first sight, a **name
|
||||||
|
conflict** warning when a pinned name arrives with a different key, and
|
||||||
|
block/unblock.
|
||||||
|
- **Call to Play**: creator controls are unchanged but now cryptographically
|
||||||
|
enforced; the `ARCHITECTURE.md` disclaimer about accidental-only protection
|
||||||
|
gets rewritten rather than deleted (what is now enforced, what is still
|
||||||
|
self-asserted: names and timestamps).
|
||||||
|
- **Log window**: rejected messages surface as typed auth errors with the short
|
||||||
|
id and reason, so "why is that peer not showing up" is diagnosable.
|
||||||
|
- **Repair flow** when the identity cannot be read: explain, offer retry, offer
|
||||||
|
deliberate new identity, never do it silently.
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
Unit (`lanspread-identity`):
|
||||||
|
|
||||||
|
- Id derivation stability and key↔id binding; tampered public key rejected.
|
||||||
|
- Signature round trip; single-bit flips in payload, sender, recipient, nonce,
|
||||||
|
and context all rejected.
|
||||||
|
- Freshness window edges; replay rejected; nonce LRU bounded.
|
||||||
|
- Storage: each backend round-trips; unreadable-but-present identity yields a
|
||||||
|
repair error and **never** a new key; file permissions asserted on Unix;
|
||||||
|
sidecar mismatch detected; export/import round trip with a wrong-passphrase
|
||||||
|
failure case.
|
||||||
|
- Trust store: atomic write survives a truncated temp file, name conflict
|
||||||
|
detected, block persists, schema version migration.
|
||||||
|
|
||||||
|
Peer runtime:
|
||||||
|
|
||||||
|
- Pinned verifier: correct key accepted; wrong SPKI, non-Ed25519 SPKI, and
|
||||||
|
SNI/id mismatch rejected.
|
||||||
|
- Two in-process peers over loopback (`test_support.rs`) complete a verified
|
||||||
|
handshake, sync libraries, and exchange signed events.
|
||||||
|
- Forged `Goodbye`, forged `LibraryDelta`, and mDNS-only address rebinding all
|
||||||
|
leave the peer table unchanged.
|
||||||
|
- Call to Play: non-creator `Start`/`Cancel`/`AddTime` rejected; relayed
|
||||||
|
third-party events verify after a full serialize/deserialize cycle; duplicate
|
||||||
|
`call_id` from another author rejected; per-author quota does not block other
|
||||||
|
authors' publishes; verification cache keeps handshake merges linear in new
|
||||||
|
events.
|
||||||
|
|
||||||
|
Hostile-peer harness (`lanspread-peer-cli`): a mode that emits unsigned frames,
|
||||||
|
valid signatures with a mismatched id, replays, forged goodbyes, impersonated
|
||||||
|
creator actions, and history floods — driven from
|
||||||
|
`crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` with assertions
|
||||||
|
that each is refused. This is the regression net that keeps the trust model
|
||||||
|
from eroding; scenarios go into `PEER_CLI_SCENARIOS.md`.
|
||||||
|
|
||||||
|
Performance: LAN download throughput before/after (must be unchanged — bulk
|
||||||
|
frames are unsigned by design), handshake latency with full Call to Play
|
||||||
|
history, and certificate generation cost at startup (generate once, never per
|
||||||
|
connection).
|
||||||
|
|
||||||
|
Every phase ends with `just fmt`, `just clippy`, `just test`,
|
||||||
|
`just frontend-test`, and a manual three-container check
|
||||||
|
(`just peer-cli-alpha` / `-bravo` / `-charlie`).
|
||||||
|
|
||||||
|
## 10. Phases
|
||||||
|
|
||||||
|
Each phase is independently shippable and leaves the tree green.
|
||||||
|
|
||||||
|
**Phase 0 — decide and write it down.** `THREAT_MODEL.md` plus an ADR in
|
||||||
|
`IMPL_DECISIONS.md` recording: Ed25519, derived ids, opaque-bytes signing,
|
||||||
|
rustls provider, storage order, and the explicit supersession of the
|
||||||
|
"no cryptographic peer identities" line in `CALL_TO_PLAY_FIXES_PLAN.md`.
|
||||||
|
|
||||||
|
**Phase 1 — `lanspread-identity`, no wire change.**
|
||||||
|
`feat(identity): derive peer identity from an Ed25519 key`. Key, storage
|
||||||
|
backends, sidecar, trust-store skeleton, unit tests. `peer_id` becomes the
|
||||||
|
derived id; legacy `peer_id` file migrated away. Peers still talk plaintext-
|
||||||
|
trust over the shared cert, so the change is observable and low risk.
|
||||||
|
|
||||||
|
**Phase 2 — authenticated transport (proto 8).**
|
||||||
|
`feat(peer)!: authenticate QUIC connections with per-peer keys`. Spike the
|
||||||
|
Ed25519-certificate path against rustls **first**; then rustls provider, cert
|
||||||
|
generation, pinned verifier, `expected` on connect, delete `cert.pem` /
|
||||||
|
`key.pem` and their constants, drop `provider-tls-default`. Discovery stops
|
||||||
|
mutating the peer table.
|
||||||
|
|
||||||
|
**Phase 3 — signed envelopes (proto 9).**
|
||||||
|
`feat(peer)!: require signed request envelopes`. Envelope type, boundary
|
||||||
|
verification, `VerifiedSender` in handlers, freshness and replay defence, and
|
||||||
|
the four trust-site fixes (`Goodbye`, `LibraryDelta`, `CallToPlayEvents`,
|
||||||
|
liveness). Typed auth errors.
|
||||||
|
|
||||||
|
**Phase 4 — signed Call to Play events (proto 10).**
|
||||||
|
`feat(call-to-play)!: sign events with peer identity keys`. `SignedEvent`
|
||||||
|
store, verbatim forwarding, key-based creator authority, creator-bound
|
||||||
|
`call_id`, per-author quotas, verification cache.
|
||||||
|
|
||||||
|
**Phase 5 — trust UX.** `feat(ui): show verified peer identities`. Identity
|
||||||
|
panel, peer chips, new/conflict badges, block/unblock, repair flow,
|
||||||
|
export/import.
|
||||||
|
|
||||||
|
**Phase 6 — optional hardening.** Rotation with continuity, party admission
|
||||||
|
code (proto 11 if it lands), connection-level abuse control.
|
||||||
|
|
||||||
|
**Phase 7 — harness and docs.** Hostile-peer scenarios; rewrite the trust
|
||||||
|
paragraphs in `ARCHITECTURE.md`, `README.md`,
|
||||||
|
`crates/lanspread-peer/README.md`, and the peer-cli docs; final performance
|
||||||
|
pass.
|
||||||
|
|
||||||
|
Phases 1–4 are the plan's substance; 5 makes it usable; 6–7 make it durable.
|
||||||
|
|
||||||
|
## 11. Dependencies
|
||||||
|
|
||||||
|
Prefer what the tree already builds. rustls/s2n-quic already pull `aws-lc-rs`,
|
||||||
|
which provides Ed25519, SHA-256, HMAC, and PBKDF2 — no second crypto stack, no
|
||||||
|
new C toolchain.
|
||||||
|
|
||||||
|
| Crate | Why | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `rustls` 0.23 | Direct config/verifier construction | Version-locked to `s2n-quic-rustls` |
|
||||||
|
| `aws-lc-rs` | Ed25519, digests, HMAC, KDF | Already transitive |
|
||||||
|
| `rcgen` (aws-lc-rs backend) | Self-signed certificate generation | Alternative: hand-rolled DER, not worth it |
|
||||||
|
| `x509-parser` | SPKI extraction in the verifier | Pure Rust, no crypto |
|
||||||
|
| `keyring` 3 | OS secret stores | Platform features; Linux pulls zbus/secret-service |
|
||||||
|
| `zeroize` | Seed hygiene | |
|
||||||
|
| `data-encoding` | base32 / base64url | |
|
||||||
|
|
||||||
|
`s2n-quic` moves to `default-features = false` with
|
||||||
|
`provider-address-token-default`, `provider-event-tracing`,
|
||||||
|
`provider-tls-rustls`. `unsafe_code = "forbid"` stays on the new crate; all
|
||||||
|
unsafe lives in dependencies.
|
||||||
|
|
||||||
|
## 12. Risks
|
||||||
|
|
||||||
|
- **Ed25519 certificates through rustls/aws-lc-rs.** Highest-uncertainty item.
|
||||||
|
Spike it as the first task of Phase 2. Fallback: P-256 certificate + Ed25519
|
||||||
|
identity bound by a signed statement in `Hello`.
|
||||||
|
- **Keyring on Linux.** Headless, container, and no-Secret-Service setups must
|
||||||
|
land on the file fallback cleanly and say so. Never a hard failure, never a
|
||||||
|
silent new key.
|
||||||
|
- **macOS keychain prompts after updates.** Signing-identity changes can
|
||||||
|
re-prompt; document it and keep the app-specific service name stable.
|
||||||
|
- **Identity loss = broken pins.** Export/import and the repair flow are not
|
||||||
|
optional polish; they are how users recover.
|
||||||
|
- **Signature cost on handshakes.** Full histories are re-verified on every
|
||||||
|
handshake without the verification cache. Build the cache with the feature,
|
||||||
|
not after a report.
|
||||||
|
- **Scope creep into content trust.** Every doc touched by this work must keep
|
||||||
|
saying that authenticated peers can still serve bad bytes.
|
||||||
|
- **The plan touches the hottest files in the peer** (`stream.rs`,
|
||||||
|
`handshake.rs`, `discovery.rs`, `call_to_play.rs`). Phase boundaries are
|
||||||
|
chosen so each lands with its own tests rather than as one 3000-line commit.
|
||||||
|
|
||||||
|
## 13. Decisions to confirm before Phase 1
|
||||||
|
|
||||||
|
1. **`keyring` dependency acceptable?** It brings zbus/secret-service on Linux.
|
||||||
|
The alternative is file-only storage with `0600` and an honest UI label,
|
||||||
|
which is what the plan falls back to anyway.
|
||||||
|
2. **One Ed25519 key for TLS and objects, or split TLS/object keys?** Plan
|
||||||
|
recommends one, with the split as the documented fallback.
|
||||||
|
3. **Should downloads require a verified requester?** Signed envelopes make it
|
||||||
|
free to enforce; a LAN party may prefer "anyone at the party can pull
|
||||||
|
games". Plan keeps serving open by default and gates it behind the block
|
||||||
|
list plus the optional party code.
|
||||||
|
4. **Party admission code in scope now or later?** Plan says Phase 6.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
• ## Review outcome
|
||||||
|
|
||||||
|
The core direction is sound: per-device Ed25519 identity, pinned QUIC responders, signed forwarded events, explicit TOFU semantics, and keeping content trust out of scope.
|
||||||
|
However, I would not implement from this plan yet. Several security claims do not follow from the proposed mechanics, and the phases are not independently shippable as written.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
1. Blocker — a signed listener address still is not authenticated.
|
||||||
|
A signature proves who asserted listen_addr, not that they control it. The plan permits a verified handshake to rebind records (PEER_AUTH_PLAN.md:316), while the current
|
||||||
|
collision rule evicts the peer already occupying that address (crates/lanspread-peer/src/peer_db.rs:78). An authenticated attacker can therefore claim another peer’s listener
|
||||||
|
and still trigger T4. Outbound handshakes should retain the endpoint actually reached under the pinned key; inbound claims need a pinned connect-back or equivalent proof. An
|
||||||
|
unproven collision must never evict an established identity.
|
||||||
|
|
||||||
|
2. Blocker — Phase 2 authenticates only the responder.
|
||||||
|
Client certificates are omitted and application signatures arrive in Phase 3 (PEER_AUTH_PLAN.md:202), yet Phase 2 lets inbound handshakes mutate peer state. Today
|
||||||
|
accept_inbound_hello immediately trusts the supplied identity, history, and address (crates/lanspread-peer/src/services/handshake.rs:152). A copied public key in an unsigned
|
||||||
|
Hello proves no possession. Signed Hello must move into Phase 2, inbound mutation must wait, or Phases 2–3 must be combined.
|
||||||
|
|
||||||
|
Similarly, Phase 3 still lets B forge A’s relayed Call-to-Play history until signed events arrive in Phase 4. Those phases must ship together or temporarily reject third-
|
||||||
|
party snapshot authority.
|
||||||
|
|
||||||
|
3. Blocker — compacted Call-to-Play tombstones lack creator proof.
|
||||||
|
The proposed call_id needs create_nonce, but that nonce appears in neither the proposed body nor today’s schema (PEER_AUTH_PLAN.md:281, crates/lanspread-proto/src/lib.rs:43).
|
||||||
|
More fundamentally, current compaction discards Create and retains only Start/Cancel (crates/lanspread-peer/src/call_to_play.rs:340). A fresh peer can verify the terminal
|
||||||
|
signer but cannot prove that signer created the call. Retain the signed root or carry a verifiable root/creator commitment in the tombstone. The replication contract cannot
|
||||||
|
remain “exactly unchanged.”
|
||||||
|
|
||||||
|
4. Blocker — event IDs and the verification cache remain attacker-controlled.
|
||||||
|
Event IDs remain globally arbitrary, while the store rejects same-ID/different-body objects atomically (crates/lanspread-peer/src/call_to_play.rs:100). An attacker can reuse
|
||||||
|
an observed legitimate ID, pre-seed a fresh peer, and make later legitimate snapshots conflict. An ID-only signature cache (PEER_AUTH_PLAN.md:300) can also skip verification
|
||||||
|
for a different body after compaction. Event identity should be author/content-or-nonce bound; cache entries must bind the exact signed-object digest and have bounded
|
||||||
|
lifetime.
|
||||||
|
|
||||||
|
5. High — the ±10-minute event rule rejects valid history.
|
||||||
|
Full terminal history lasts 15 minutes and tombstones last for the session, while active or scheduled calls may be older still. The proposed local-clock check
|
||||||
|
(PEER_AUTH_PLAN.md:303) would make legitimate historical acceptance peer-dependent. Apply future-skew checks at direct publication if desired, but do not expire signatures
|
||||||
|
merely because an event is relayed later.
|
||||||
|
|
||||||
|
6. Blocker — T8/T9 and general resource exhaustion remain open.
|
||||||
|
Terminal histories and tombstones are exempt from the proposed bounds, so one key can issue unlimited Create+Cancel pairs. Sybil keys and relayed authors defeat per-author
|
||||||
|
quotas; sixteen suggested 256-event quotas still fill the global 4096 unresolved-event capacity. Add absolute object and byte limits covering unresolved events, terminal
|
||||||
|
history, tombstones, caches, and distinct authors, plus local-capacity reservation and defined eviction/rejection behavior.
|
||||||
|
|
||||||
|
The same threat model also requires connection, stream, signature-verification, transfer, disk-read, and expensive-operation limits. The server currently spawns work per
|
||||||
|
connection and stream (crates/lanspread-peer/src/services/server.rs:58); self-issued signatures do not make a requester trustworthy. These controls cannot remain optional
|
||||||
|
Phase 6 hardening.
|
||||||
|
|
||||||
|
7. High — replay protection does not establish T7.
|
||||||
|
A 1024-entry LRU can evict a still-fresh nonce, restart loses the cache, concurrent check-and-insert needs atomicity, and the ±120-second window introduces hard clock trust
|
||||||
|
despite the non-goal (PEER_AUTH_PLAN.md:249). A delayed legitimate Goodbye can also arrive after a newer handshake and remove the new incarnation. Use session/challenge or
|
||||||
|
incarnation binding—particularly for Goodbye—or persist all still-valid replay state and narrow the stated guarantee.
|
||||||
|
|
||||||
|
8. Blocker — identity storage needs a crash-safe backend state machine.
|
||||||
|
“Keyring unavailable” cannot mean the same thing as “no key exists”: falling through to a file can fork an established identity. The sidecar-selected backend must become
|
||||||
|
authoritative, with explicit handling for missing, locked, denied, corrupt, and mismatched states. The secret and identity.json also cannot be atomically committed together;
|
||||||
|
concurrent starts, crashes, import, reset, and rotation need a process lock, generation-based commit protocol, and reconciliation rules.
|
||||||
|
|
||||||
|
The persistence rationale is also inaccurate for the GUI: production passes Tauri’s app_data_dir, not the core ~/.lanspread fallback (crates/lanspread-tauri-deno-ts/src-
|
||||||
|
tauri/src/lib.rs:2437). Reinstall survival needs a platform/package matrix. The fixed keyring account additionally describes one identity per OS account, not necessarily one
|
||||||
|
per installation.
|
||||||
|
|
||||||
|
9. Blocker — mandatory expected identity is not threaded through the runtime.
|
||||||
|
The plan bans address-only connections (PEER_AUTH_PLAN.md:210), but direct connect is still ConnectPeer(SocketAddr) (crates/lanspread-peer/src/lib.rs:263), and downloads,
|
||||||
|
retries, streamed installs, healing, shutdown, and liveness frequently retain only addresses. Phase 2 needs a first-class PeerEndpoint { peer_id, addr } throughout. Direct
|
||||||
|
connect must require an expected fingerprint/ID or define an explicit user-confirmed TOFU bootstrap.
|
||||||
|
|
||||||
|
10. High — trust and identity lifecycle semantics are incomplete.
|
||||||
|
Blocking at direct handshake/connection does not block A’s events relayed by allowed B. The common event-merge boundary needs a block/admission policy, including existing
|
||||||
|
history and unblock recovery. The trust schema also has only known | blocked, so it cannot represent the promised persistent “new,” reviewed, or acknowledged state; security-
|
||||||
|
sensitive writes such as block/pin must not be merely debounced.
|
||||||
|
|
||||||
|
Phase 1 can already produce fatal identity errors, but usable repair is deferred to Phase 5; today startup failure is only logged (crates/lanspread-tauri-deno-ts/src-tauri/
|
||||||
|
src/lib.rs:2118). Minimum retry/import/reset/export must accompany storage. Seed export also creates two indistinguishable live devices unless backup versus transfer,
|
||||||
|
retirement, and stale-copy behavior are defined.
|
||||||
|
|
||||||
|
11. High — the cryptographic wire contract is not normative enough.
|
||||||
|
The signature transcript still contains an ellipsis (PEER_AUTH_PLAN.md:247); it must define every field, order, tag value, integer width/endian, encoding, and protocol-
|
||||||
|
version binding, plus require recipient-local, expected-context, and response-sender-equals-TLS-pinned-responder checks. Add golden vectors.
|
||||||
|
|
||||||
|
Additional concrete corrections:
|
||||||
|
- A custom rustls verifier must verify TLS 1.3 CertificateVerify, not only inspect SPKI (/home/pfs/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rustls-0.23.42/src/
|
||||||
|
verify.rs:69).
|
||||||
|
|
||||||
|
- Bytes becomes a JSON numeric array, not base64, without an explicit serializer.
|
||||||
|
- The proposed crate graph is contradictory: lanspread-identity cannot construct lanspread-proto types while both dependency directions are forbidden
|
||||||
|
(PEER_AUTH_PLAN.md:370).
|
||||||
|
|
||||||
|
- rcgen must disable its default ring feature to preserve the one-crypto-stack claim.
|
||||||
|
- The P-256 fallback needs a binding visible before accepting the TLS channel; an identity statement delivered only inside that channel is too late.
|
||||||
|
|
||||||
|
12. High — adversarial tests and documentation arrive too late.
|
||||||
|
The hostile harness is described as the trust model’s regression net but deferred to Phase 7 (PEER_AUTH_PLAN.md:523). Each trust boundary must land with its negative
|
||||||
|
scenarios and documentation. Per-phase gates should include targeted just peer-cli-tests, the honest-path matrix after major protocol phases, and just build for GUI changes—
|
||||||
|
not only manual containers, whose recipes can use an old image (justfile:46). Phase 7 should be a final audit, not the first security acceptance pass.
|
||||||
|
|
||||||
|
My recommended prerequisite is a revised Phase 0 that settles the endpoint model, exact wire transcripts, storage state machine, resource budgets, crate graph, and TLS spike.
|
||||||
|
Then Phase 1 can ship storage with recovery; Phase 2 can ship mutually authenticated handshakes and address proof; signed envelopes and independently verifiable relayed events
|
||||||
|
should ship atomically or behind a safe intermediate restriction.
|
||||||
Reference in New Issue
Block a user