fix: protocol compliance — DTLS binding, sha256 integrity, derived signaling ID

Go (transport.go, crypto.go):
- DeriveForNetwork: HKDF per-room Ed25519 identity to avoid anchor peer-ID collision
  when daemon holds multiple simultaneous pair-room connections (§ waste-go pattern)
- sendHello: sign prefix||local_fp||remote_fp per yaw/2 §6 DTLS fingerprint binding
- onControl hello: verify fingerprint binding + sig before setting verified=true
- FileOffer.SHA256: carry file hash in offer message per yaw/2 §9
- SendFile: sha256 before offer, sends file-done with hash after streaming
- wireFileRecv: pion Detach API + sha256 during recv + wait for file-done + verify
- pendingDones map: goroutine-safe channel for file-done routing
- signaling keepalive: ping with 10 s timeout, 3-min read deadline on anchor loop

PWA (flit.ts):
- dtlsFP/sha256hex helpers
- Split PeerConn.signalingId (routing) from .peerId (cryptoId, for crypto/hello)
- _sendHello: include DTLS fingerprints in signature per §6
- _onControl hello: verify fingerprint binding
- sendFile: async, sha256 before offer
- _stream: send file-done after DC close
- _wire: set dataComplete=true on DC close, defer emit until file-done arrives
- _onControl file-done: set expectedSHA256, conditionally call _finalizeRecv
- _finalizeRecv: concatenate bufs, verify sha256, create URL, emit fileRecv

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-02 18:44:48 +02:00
parent a4bee36f45
commit 3a105e2c9a
2 changed files with 210 additions and 27 deletions

View File

@@ -47,6 +47,18 @@ async function iceServers(cfg: FlitConfig): Promise<RTCIceServer[]> {
const enc = (s: string) => new TextEncoder().encode(s)
// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP string.
function dtlsFP(sdp: string): Uint8Array {
const m = sdp.match(/a=fingerprint:sha-256 ([\dA-Fa-f:]+)/i)
if (!m) throw new Error('no sha-256 DTLS fingerprint in SDP')
return sodium.from_hex(m[1].replace(/:/g, ''))
}
async function sha256hex(buf: ArrayBuffer): Promise<string> {
const digest = await globalThis.crypto.subtle.digest('SHA-256', buf)
return toHex(new Uint8Array(digest))
}
function concat(...arrs: Uint8Array[]): Uint8Array {
const n = arrs.reduce((a, b) => a + b.length, 0)
const out = new Uint8Array(n); let o = 0
@@ -250,9 +262,9 @@ export class PeerConn {
private _offerPending = false
private _offered = false
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean }> = {}
private _recv: Record<string, { name: string; size: number; buf: ArrayBuffer[]; have: number; cancelled?: boolean; dataComplete?: boolean; expectedSHA256?: string }> = {}
private _recvChannels: Record<string, RTCDataChannel> = {}
private _pushQueue: Map<string, File> = new Map()
private _pushQueue: Map<string, { file: File; sha256: string; buf: ArrayBuffer }> = new Map()
private identity: Identity
private sig: Signaling
@@ -382,17 +394,24 @@ export class PeerConn {
}
channel.onclose = () => {
if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
rx.dataComplete = true
// If file-done already arrived before DC closed, finalize now.
if (rx.expectedSHA256 !== undefined) void this._finalizeRecv(xid)
}
this._recvChannels[xid] = channel
}
}
private _sendHello() {
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(enc(BIND_PREFIX))) })
try {
const lfp = dtlsFP(this.pc.localDescription!.sdp)
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
// §6: sender signs prefix || local_fp || remote_fp
const bindMsg = concat(enc(BIND_PREFIX), lfp, rfp)
this._dc({ type: 'hello', id: this.identity.id, sig: sodium.to_hex(this.identity.sign(bindMsg)) })
} catch (e) {
console.error('flit: sendHello fingerprint error', e)
}
}
private _onControl(data: string) {
@@ -402,8 +421,17 @@ export class PeerConn {
if (m['type'] === 'hello') {
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
try {
// §6: verifier reconstructs prefix || remote_fp || local_fp
// (sender's local = our remote; sender's remote = our local)
const rfp = dtlsFP(this.pc.remoteDescription!.sdp)
const lfp = dtlsFP(this.pc.localDescription!.sdp)
const bindMsg = concat(enc(BIND_PREFIX), rfp, lfp)
const sigOk = Identity.verify(helloId, bindMsg, helloSig)
this.verified = helloId === this.peerId && sigOk && this.peerAuthed
} catch {
this.verified = false
}
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 })
@@ -419,9 +447,35 @@ export class PeerConn {
delete this._recvChannels[xid]
this.events.fileCancelled?.(xid)
}
} else if (m['type'] === 'file-done') {
const xid = m['xid'] as string
const expectedSHA256 = m['sha256'] as string
const rx = this._recv[xid]
if (rx) {
rx.expectedSHA256 = expectedSHA256
// If DC already closed and all data is in, finalize now.
if (rx.dataComplete) void this._finalizeRecv(xid)
}
}
}
private async _finalizeRecv(xid: string) {
const rx = this._recv[xid]
if (!rx) return
delete this._recv[xid]
delete this._recvChannels[xid]
const combined = new Uint8Array(rx.buf.reduce((acc, b) => acc + b.byteLength, 0))
let offset = 0
for (const b of rx.buf) { combined.set(new Uint8Array(b), offset); offset += b.byteLength }
const actualSHA256 = await sha256hex(combined.buffer)
if (actualSHA256 !== rx.expectedSHA256) {
console.error(`flit: sha256 mismatch for ${rx.name}: got ${actualSHA256} want ${rx.expectedSHA256}`)
return
}
const url = URL.createObjectURL(new Blob([combined]))
this.events.fileRecv?.({ peer: this.peerId, name: rx.name, size: rx.size, url })
}
acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, buf: [], have: 0 }
this._dc({ type: 'file-accept', xid })
@@ -431,22 +485,23 @@ export class PeerConn {
this._dc({ type: 'file-cancel', xid })
}
sendFile(file: File) {
async sendFile(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
const buf = await file.arrayBuffer()
const hash = await sha256hex(buf)
this._pushQueue.set(xid, { file, sha256: hash, buf })
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid, sha256: hash })
}
private async _stream(xid: string, resumeOffset = 0) {
const file = this._pushQueue.get(xid)
if (!file) return
const entry = this._pushQueue.get(xid)
if (!entry) return
this._pushQueue.delete(xid)
const { file, sha256: hash, buf: fullBuf } = entry
const dc = this.pc.createDataChannel(`f:${xid}`)
dc.binaryType = 'arraybuffer'
await new Promise<void>(res => { dc.onopen = () => res() })
// Slice from resume offset so we only transfer the remaining bytes.
const slice = resumeOffset > 0 ? file.slice(resumeOffset) : file
const buf = await slice.arrayBuffer()
const buf = resumeOffset > 0 ? fullBuf.slice(resumeOffset) : fullBuf
let localOffset = 0
while (localOffset < buf.byteLength) {
while (dc.bufferedAmount > 1024 * 1024) await new Promise(r => setTimeout(r, 10))
@@ -456,6 +511,8 @@ export class PeerConn {
await new Promise(r => setTimeout(r, 0))
}
dc.close()
// §9: send file-done with sha256 so receiver can verify integrity.
this._dc({ type: 'file-done', xid, sha256: hash })
this.events.fileSent?.({ peer: this.peerId, xid, name: file.name })
}