155 lines
6.3 KiB
Markdown
155 lines
6.3 KiB
Markdown
|
|
# The Multi-Room Peer ID Collision Problem
|
||
|
|
|
||
|
|
## What's happening
|
||
|
|
|
||
|
|
The flit daemon runs one goroutine per trusted peer, and each goroutine opens its own
|
||
|
|
WebSocket connection to the anchor and joins a separate pair room. Both connections
|
||
|
|
authenticate with the daemon's real Ed25519 identity — the same peer ID.
|
||
|
|
|
||
|
|
The anchor stores connected clients in a Go map keyed by peer ID:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// waste-go/cmd/anchor/main.go
|
||
|
|
type anchor struct {
|
||
|
|
clients map[string]*client // keyed by hex peer id
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *anchor) register(c *client) {
|
||
|
|
a.mu.Lock()
|
||
|
|
a.clients[c.id] = c // ← overwrites any existing entry for this id
|
||
|
|
a.mu.Unlock()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
When the Samsung goroutine connects first, `clients["c639a691..."]` points to the Samsung
|
||
|
|
room WebSocket. When the Laptop goroutine connects a millisecond later, `clients["c639a691..."]`
|
||
|
|
is **overwritten** to point to the Laptop room WebSocket. The Samsung entry is gone.
|
||
|
|
|
||
|
|
The Samsung room WebSocket is still open. The daemon's read goroutine is still blocked on it.
|
||
|
|
But the anchor no longer routes any messages to it — it's orphaned. When Samsung joins,
|
||
|
|
`networkPeerIDs("e1d686b9...", samsung_id)` finds no entry for the daemon in that net hash,
|
||
|
|
returns an empty list, logs `peers=0`, and never sends the daemon a `peer-join`.
|
||
|
|
|
||
|
|
## Why it's not a bug in the anchor
|
||
|
|
|
||
|
|
The anchor was designed for human-scale use: one device, one room at a time. A single peer
|
||
|
|
ID being in two rooms simultaneously isn't a use case it was built for. The `map[string]*client`
|
||
|
|
design is correct for that model.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Options
|
||
|
|
|
||
|
|
### Option A — Ephemeral signaling identity per session (recommended)
|
||
|
|
|
||
|
|
Generate a fresh Ed25519 keypair for each `dialSignaling` call. The anchor sees a unique,
|
||
|
|
throwaway ID per room. The real identity is only used inside the WebRTC DataChannel, in
|
||
|
|
the `hello` message where it matters for security.
|
||
|
|
|
||
|
|
**Anchor changes:** none.
|
||
|
|
|
||
|
|
**Protocol changes:** none at the DataChannel level (`hello` is unchanged).
|
||
|
|
|
||
|
|
**PWA change required:** the pair-room `trustedPeerID` filter currently rejects any
|
||
|
|
peer whose *signaling* ID doesn't match the expected peer ID. Since the daemon now
|
||
|
|
uses an ephemeral signaling ID, the PWA must not filter by signaling ID in pair-room
|
||
|
|
mode — instead, connect to the first peer in the room and rely on `hello` for identity
|
||
|
|
verification. This is safe:
|
||
|
|
|
||
|
|
- The pair room name is `hash("flit-pair:" + sorted(idA, idB))` — private to the two
|
||
|
|
parties; not publicly guessable unless you know both real peer IDs.
|
||
|
|
- `hello` binds the peer's real Ed25519 identity to the DTLS session, so an impostor
|
||
|
|
can't pass verification even if they discover the room name.
|
||
|
|
|
||
|
|
The trade-off: a peer in the wrong room (or a race-condition join) triggers a failed
|
||
|
|
hello instead of being silently ignored at the signaling level.
|
||
|
|
|
||
|
|
**Summary of code changes:**
|
||
|
|
- `dialSignaling`: accept a `*crypto.Identity` parameter (currently does); callers
|
||
|
|
pass a freshly-generated ephemeral identity instead of the real one.
|
||
|
|
- `Session.Join`: generate ephemeral identity before calling `dialSignaling`.
|
||
|
|
- `Session.PeerID()`: unchanged — returns the real identity's ID.
|
||
|
|
- `offerByOrder` comparison: compare real peer IDs (unchanged).
|
||
|
|
- PWA `transport/flit.ts`: in pair-room mode, remove signaling-level ID filter;
|
||
|
|
connect to any peer present in the room and let `hello` sort out identity.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Option B — Daemon-only room (single WS)
|
||
|
|
|
||
|
|
The daemon joins one room derived solely from its own real ID:
|
||
|
|
|
||
|
|
```
|
||
|
|
room = hash("flit-daemon:" + daemon_real_id)
|
||
|
|
```
|
||
|
|
|
||
|
|
All trusted peers know this room (it's computable from the daemon's public ID). The
|
||
|
|
daemon filters incoming `peer-join` events against its trusted list. One WebSocket
|
||
|
|
connection, no collision.
|
||
|
|
|
||
|
|
**Anchor changes:** none.
|
||
|
|
|
||
|
|
**Protocol changes:** yes — pair-room name derivation changes for daemon mode. The PWA
|
||
|
|
needs a "connect to daemon" flow that uses `hash("flit-daemon:" + daemon_id)` instead
|
||
|
|
of `pairRoomName(myId, daemonId)`. Existing QR/pair-room flow is unaffected for
|
||
|
|
non-daemon peers.
|
||
|
|
|
||
|
|
**Trade-off:** all trusted peers share one room — each peer can see the others' peer IDs
|
||
|
|
(via `peer-join` events from the anchor). For most home setups this is fine; for higher
|
||
|
|
privacy requirements it's undesirable.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Option C — Per-peer sub-identity stored in config
|
||
|
|
|
||
|
|
For each trusted peer, the daemon config stores a unique Ed25519 keypair used only for
|
||
|
|
signaling into that peer's pair room. The real identity is still used for `hello`.
|
||
|
|
|
||
|
|
```toml
|
||
|
|
[[peers]]
|
||
|
|
id = "ba3e38bf..." # peer's real ID
|
||
|
|
label = "Samsung"
|
||
|
|
signal_key = "aabb1122..." # daemon's per-peer signing key (hex Ed25519 private key)
|
||
|
|
```
|
||
|
|
|
||
|
|
The PWA must be told the daemon's per-peer signing key (its public half) to compute
|
||
|
|
the pair room and recognise the peer-join. This means re-pairing every time a new
|
||
|
|
`signal_key` is generated.
|
||
|
|
|
||
|
|
**Anchor changes:** none. **Protocol changes:** minor. **Pairing complexity:** high —
|
||
|
|
effectively adds a second identity per pair.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Option D — Per-network derived identity (how waste-go solves this)
|
||
|
|
|
||
|
|
`waste-go/internal/netmgr` already has this problem and solves it cleanly:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// waste-go/internal/crypto/crypto.go
|
||
|
|
func DeriveForNetwork(master *Identity, networkHash string) (*Identity, error) {
|
||
|
|
r := hkdf.New(sha256.New, master.privateKey[:32], []byte(networkHash), []byte("yaw2-net-identity"))
|
||
|
|
var seed [32]byte
|
||
|
|
io.ReadFull(r, seed[:])
|
||
|
|
priv := ed25519.NewKeyFromSeed(seed[:])
|
||
|
|
return &Identity{privateKey: priv, ...}, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`HKDF(master_private_key, network_hash)` produces a **deterministic, per-room keypair**.
|
||
|
|
Same master key + same room hash → same derived ID, always. Different rooms → different IDs.
|
||
|
|
No collision in the anchor's `clients` map.
|
||
|
|
|
||
|
|
**Anchor changes:** none.
|
||
|
|
**Protocol changes:** none — `hello` still uses the real identity.
|
||
|
|
**PWA changes:** none — the daemon's derived ID is stable per room, so the pair room
|
||
|
|
derivation and `trustedPeerID` filter are unaffected (they operate on the *other* peer's ID).
|
||
|
|
**Security:** same as before. The derived key signs the anchor challenge legitimately.
|
||
|
|
`hello` inside the DataChannel still uses and verifies the real Ed25519 identity.
|
||
|
|
|
||
|
|
This is the recommended fix. Flit's `crypto` package needs `DeriveForNetwork` added
|
||
|
|
(HKDF, same as waste-go), then `Session.Join` passes the derived identity to
|
||
|
|
`dialSignaling` instead of the master identity.
|