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:
Fredrik Johansson
2026-07-02 18:26:19 +02:00
parent 0fe01e146c
commit a4bee36f45
4 changed files with 211 additions and 17 deletions

View File

@@ -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)
}