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:
@@ -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