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:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user