Fix multi-room anchor collision via per-room derived identities
The anchor keys its clients map by peer ID. With the daemon joining one room per trusted peer, both sessions authenticated with the same real Ed25519 ID — the second register() overwrote the first, orphaning the earlier room's WS connection. Fix mirrors waste-go/internal/netmgr: derive a deterministic per-room Ed25519 keypair from HKDF(master_private_key, room_hash). Same inputs always produce the same derived ID; different rooms produce different IDs. No anchor changes required. cli/internal/crypto: add DeriveForNetwork (HKDF-SHA256, same KDF as waste-go) so the daemon can derive stable per-room signaling identities. cli/internal/transport: Join derives a per-room signaling identity before calling dialSignaling. Real identity is still used for hello verification and seal/open crypto inside the DataChannel. pwa/src/transport/flit.ts: split PeerConn.peerId into signalingId (anchor routing) and cryptoId (seal/open, hello). In pair-room mode the daemon's signaling ID differs from its real ID, so connectTo and _onFrom now use trustedPeerId as the cryptoId regardless of what the anchor reports as the sender. offerByOrder comparison uses real IDs (trustedPeerId ?? signalingId) so both sides agree. hello verification now also checks the Ed25519 signature, not just the claimed ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
154
MULTI_ROOM_PROBLEM.md
Normal file
154
MULTI_ROOM_PROBLEM.md
Normal file
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<PeerEvents>
|
||||
|
||||
constructor(
|
||||
identity: Identity,
|
||||
sig: Signaling,
|
||||
peerId: string,
|
||||
signalingId: string,
|
||||
cryptoId: string,
|
||||
events: Partial<PeerEvents>,
|
||||
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<string, unknown>) {
|
||||
@@ -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<PeerEvents>, 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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user