diff --git a/MULTI_ROOM_PROBLEM.md b/MULTI_ROOM_PROBLEM.md new file mode 100644 index 0000000..b6cb213 --- /dev/null +++ b/MULTI_ROOM_PROBLEM.md @@ -0,0 +1,154 @@ +# 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. diff --git a/cli/internal/crypto/crypto.go b/cli/internal/crypto/crypto.go index 75a4b75..69ba2e6 100644 --- a/cli/internal/crypto/crypto.go +++ b/cli/internal/crypto/crypto.go @@ -8,17 +8,20 @@ package crypto import ( "crypto/ed25519" "crypto/rand" + "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "filippo.io/edwards25519" "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" "golang.org/x/crypto/nacl/box" ) @@ -94,6 +97,21 @@ func Verify(publicKeyHex string, data []byte, sigHex string) error { return nil } +// DeriveForNetwork returns a deterministic in-memory Identity derived from the +// master key and the given network hash (hex). Same inputs always produce the +// same keypair, so the peer ID is stable across restarts. Different network +// hashes produce different peer IDs, preventing the anchor's peer-ID map from +// colliding when the same device joins multiple rooms simultaneously. +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 + if _, err := io.ReadFull(r, seed[:]); err != nil { + return nil, fmt.Errorf("deriving network identity: %w", err) + } + priv := ed25519.NewKeyFromSeed(seed[:]) + return &Identity{privateKey: priv, PublicKey: priv.Public().(ed25519.PublicKey)}, nil +} + // CurvePublicKey returns the X25519 public key derived from this Ed25519 identity. func (id *Identity) CurvePublicKey() *[32]byte { edPoint, _ := new(edwards25519.Point).SetBytes(id.PublicKey) diff --git a/cli/internal/transport/transport.go b/cli/internal/transport/transport.go index b65ef4c..758f516 100644 --- a/cli/internal/transport/transport.go +++ b/cli/internal/transport/transport.go @@ -202,7 +202,13 @@ func (s *Session) PeerID() string { return s.identity.PeerID() } // one peer (or the pinned trustedPeerID) to complete the handshake. func (s *Session) Join(ctx context.Context, roomName string, trustedPeerID string) error { hash := NetHash(roomName) - sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, s.identity, hash) + // Use a per-room derived identity for signaling so that joining multiple + // rooms simultaneously doesn't collide in the anchor's peer-ID map. + signingID, err := crypto.DeriveForNetwork(s.identity, hash) + if err != nil { + return fmt.Errorf("derive signaling identity: %w", err) + } + sig, present, err := dialSignaling(ctx, s.cfg.SignalURL, signingID, hash) if err != nil { return err } diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts index 4f4de12..d6a033d 100644 --- a/pwa/src/transport/flit.ts +++ b/pwa/src/transport/flit.ts @@ -256,19 +256,25 @@ export class PeerConn { private identity: Identity private sig: Signaling + // signalingId: the peer's anchor routing ID (may be a per-room derived key) + // cryptoId / peerId: the peer's real Ed25519 identity (used for seal/open and hello) + private _signalingId: string peerId: string + private events: Partial constructor( identity: Identity, sig: Signaling, - peerId: string, + signalingId: string, + cryptoId: string, events: Partial, ice: RTCIceServer[], ) { this.identity = identity this.sig = sig - this.peerId = peerId + this._signalingId = signalingId + this.peerId = cryptoId this.events = events this.pc = new RTCPeerConnection({ iceServers: ice }) const kp = sodium.crypto_box_keypair() @@ -303,7 +309,7 @@ export class PeerConn { this._ekeySent = true const signed = concat(enc(EKEY_PREFIX), sodium.from_hex(this.identity.id), sodium.from_hex(this.peerId), this._epk) const msg = { kind: 'ekey', v: 'yaw/2.1', epk: sodium.to_hex(this._epk), sig: sodium.to_hex(this.identity.sign(signed)) } - this.sig.sendTo(this.peerId, this._seal(msg, false)) + this.sig.sendTo(this._signalingId, this._seal(msg, false)) } private async _onEkey(obj: Record) { @@ -333,7 +339,7 @@ export class PeerConn { this._wire(this.dc) await this.pc.setLocalDescription(await this.pc.createOffer()) await gatherComplete(this.pc) - this.sig.sendTo(this.peerId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)) + this.sig.sendTo(this._signalingId, this._seal({ kind: 'offer', sdp: this.pc.localDescription!.sdp }, true)) } async onBox(box: string) { @@ -349,7 +355,7 @@ export class PeerConn { await this.pc.setRemoteDescription({ type: 'offer', sdp: obj['sdp'] as string }) await this.pc.setLocalDescription(await this.pc.createAnswer()) await gatherComplete(this.pc) - this.sig.sendTo(this.peerId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)) + this.sig.sendTo(this._signalingId, this._seal({ kind: 'answer', sdp: this.pc.localDescription!.sdp }, usedEph)) } else if (obj['kind'] === 'answer') { await this.pc.setRemoteDescription({ type: 'answer', sdp: obj['sdp'] as string }) } @@ -394,7 +400,10 @@ export class PeerConn { try { m = JSON.parse(data) } catch { return } if (m['type'] === 'hello') { - this.verified = m['id'] === this.peerId && this.peerAuthed + const helloId = m['id'] as string + const helloSig = sodium.from_hex(m['sig'] as string) + const sigOk = Identity.verify(helloId, enc(BIND_PREFIX), helloSig) + this.verified = helloId === this.peerId && sigOk && this.peerAuthed this.events.connected?.(this.verified, this.peerId) } else if (m['type'] === 'file-offer') { this.events.fileOffer?.({ peer: this.peerId, xid: m['xid'] as string, name: m['name'] as string, size: m['size'] as number }) @@ -488,27 +497,34 @@ export class FlitSession { this.sig = sig const ice = await iceServers(this.cfg) - const connectTo = async (pid: string) => { - if (pid === this.identity.id) return - if (trustedPeerId && pid !== trustedPeerId) return + const connectTo = async (signalingId: string) => { + if (signalingId === this.identity.id) return if (this.peer) return - this.peer = new PeerConn(this.identity, sig, pid, events, ice) - if (this.identity.id < pid) await this.peer.startOffer() + // cryptoId is the real Ed25519 identity used for seal/open and hello. + // In ephemeral rooms trustedPeerId is unset so signalingId == cryptoId. + // In pair rooms the daemon uses a derived signaling ID; trustedPeerId is + // the real ID we verify hello against. + const cryptoId = trustedPeerId ?? signalingId + this.peer = new PeerConn(this.identity, sig, signalingId, cryptoId, events, ice) + // Compare real IDs so both sides agree on who offers first. + if (this.identity.id < cryptoId) await this.peer.startOffer() } const present = await sig.connect( (from, box) => { void this._onFrom(from, box, events, ice, trustedPeerId) }, - (pid) => { void connectTo(pid) }, + (signalingId) => { void connectTo(signalingId) }, () => { /* peer-leave: nothing to clean up for a single 1:1 session */ }, - (peers) => { peers.forEach(pid => void connectTo(pid)) }, + (peers) => { peers.forEach(signalingId => void connectTo(signalingId)) }, ) - for (const pid of present) await connectTo(pid) + for (const signalingId of present) await connectTo(signalingId) } private async _onFrom(from: string, box: string, events: Partial, ice: RTCIceServer[], trustedPeerId?: string) { if (from === this.identity.id) return - if (trustedPeerId && from !== trustedPeerId) return - if (!this.peer) this.peer = new PeerConn(this.identity, this.sig!, from, events, ice) + if (!this.peer) { + const cryptoId = trustedPeerId ?? from + this.peer = new PeerConn(this.identity, this.sig!, from, cryptoId, events, ice) + } await this.peer.onBox(box) }