From 3a105e2c9a9b79a63426679b9540e293e0ab7b22 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Thu, 2 Jul 2026 18:44:48 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20protocol=20compliance=20=E2=80=94=20DTLS?= =?UTF-8?q?=20binding,=20sha256=20integrity,=20derived=20signaling=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/internal/transport/transport.go | 146 ++++++++++++++++++++++++++-- pwa/src/transport/flit.ts | 91 +++++++++++++---- 2 files changed, 210 insertions(+), 27 deletions(-) diff --git a/cli/internal/transport/transport.go b/cli/internal/transport/transport.go index 758f516..d5a12f3 100644 --- a/cli/internal/transport/transport.go +++ b/cli/internal/transport/transport.go @@ -155,9 +155,10 @@ func (s *signaling) sendTo(to, box string) { // ── Session ─────────────────────────────────────────────────────────────────── type FileOffer struct { - XID string - Name string - Size int64 + XID string + Name string + Size int64 + SHA256 string } // Session is a 1:1 ephemeral pairing: join a room, connect to exactly one @@ -180,6 +181,7 @@ type Session struct { sig *signaling pendingRecvs map[string]*recvState pendingAccepts map[string]chan int64 + pendingDones map[string]chan string // xid → channel receiving expected sha256 from file-done OnConnected func(verified bool) OnDisconnected func() @@ -565,9 +567,25 @@ func (s *Session) wireDC(dc *webrtc.DataChannel) { } func (s *Session) sendHello() { + s.mu.Lock() + pc := s.pc + s.mu.Unlock() + if pc == nil { + return + } + lfp, err1 := dtlsFP(pc.LocalDescription().SDP) + rfp, err2 := dtlsFP(pc.RemoteDescription().SDP) + if err1 != nil || err2 != nil { + log.Printf("flit: sendHello: fingerprint error: %v / %v", err1, err2) + return + } + // §6: sender signs prefix || local_fp || remote_fp + bindMsg := append([]byte(bindPrefix), lfp...) + bindMsg = append(bindMsg, rfp...) s.controlSend(map[string]string{ - "type": "hello", "id": s.identity.PeerID(), - "sig": s.identity.Sign([]byte(bindPrefix)), + "type": "hello", + "id": s.identity.PeerID(), + "sig": s.identity.Sign(bindMsg), }) } @@ -578,16 +596,41 @@ func (s *Session) onControl(data []byte) { } switch m["type"] { case "hello": + helloID, _ := m["id"].(string) + sigHex, _ := m["sig"].(string) + verified := false s.mu.Lock() - s.verified = m["id"] == s.peerID + pc := s.pc + s.mu.Unlock() + if pc != nil && pc.RemoteDescription() != nil && pc.LocalDescription() != nil { + // §6: verifier reconstructs prefix || remote_fp || local_fp + // (sender's local = our remote; sender's remote = our local) + rfp, err1 := dtlsFP(pc.RemoteDescription().SDP) + lfp, err2 := dtlsFP(pc.LocalDescription().SDP) + if err1 == nil && err2 == nil { + bindMsg := append([]byte(bindPrefix), rfp...) + bindMsg = append(bindMsg, lfp...) + verified = helloID == s.peerID && crypto.Verify(helloID, bindMsg, sigHex) == nil + } else { + log.Printf("flit: hello verify: fingerprint error: %v / %v", err1, err2) + } + } + s.mu.Lock() + s.verified = verified s.mu.Unlock() if s.OnConnected != nil { - s.OnConnected(s.verified) + s.OnConnected(verified) } case "file-offer": if s.OnFileOffer != nil { size, _ := m["size"].(float64) - s.OnFileOffer(FileOffer{XID: m["xid"].(string), Name: m["name"].(string), Size: int64(size)}) + sha256, _ := m["sha256"].(string) + s.OnFileOffer(FileOffer{ + XID: m["xid"].(string), + Name: m["name"].(string), + Size: int64(size), + SHA256: sha256, + }) } case "file-accept": xid, _ := m["xid"].(string) @@ -602,6 +645,16 @@ func (s *Session) onControl(data []byte) { if ch != nil { ch <- offset } + case "file-done": + xid, _ := m["xid"].(string) + sha256hex, _ := m["sha256"].(string) + s.mu.Lock() + ch := s.pendingDones[xid] + delete(s.pendingDones, xid) + s.mu.Unlock() + if ch != nil { + ch <- sha256hex + } } } @@ -632,6 +685,16 @@ func (s *Session) SendFile(path string) error { xid := fmt.Sprintf("push-%d", time.Now().UnixNano()) name := st.Name() + // Compute sha256 over the whole file before offering (§9). + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("hashing file: %w", err) + } + sha256hex := hex.EncodeToString(h.Sum(nil)) + if _, err := f.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("seek after hash: %w", err) + } + acceptCh := make(chan int64, 1) s.mu.Lock() if s.pendingAccepts == nil { @@ -640,7 +703,13 @@ func (s *Session) SendFile(path string) error { s.pendingAccepts[xid] = acceptCh s.mu.Unlock() - s.controlSend(map[string]string{"type": "file-offer", "name": name, "size": fmt.Sprint(st.Size()), "xid": xid}) + s.controlSend(map[string]string{ + "type": "file-offer", + "name": name, + "size": fmt.Sprint(st.Size()), + "xid": xid, + "sha256": sha256hex, + }) s.mu.Lock() pc := s.pc @@ -689,7 +758,12 @@ func (s *Session) SendFile(path string) error { return err } } - return dc.Close() + if err := dc.Close(); err != nil { + return err + } + // §9: send file-done with the pre-computed hash so the receiver can verify. + s.controlSend(map[string]string{"type": "file-done", "xid": xid, "sha256": sha256hex}) + return nil } // AcceptOffer accepts an incoming file-offer and writes the transfer to @@ -768,6 +842,17 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { return } + // Hash the already-received bytes first when resuming, so our + // running sha256 covers the whole file from byte 0. + h := sha256.New() + if resuming { + partPath := finalPath + ".part" + if pf, perr := os.Open(partPath); perr == nil { + _, _ = io.Copy(h, pf) + pf.Close() + } + } + buf := make([]byte, chunkSize) for { n, readErr := raw.Read(buf) @@ -776,6 +861,7 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { log.Printf("flit: write %s: %v", rs.offer.Name, werr) break } + h.Write(buf[:n]) rs.have += int64(n) if s.OnFileProgress != nil { s.OnFileProgress(xid, rs.have, rs.offer.Size) @@ -786,6 +872,7 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { } } f.Close() + actualSHA256 := hex.EncodeToString(h.Sum(nil)) log.Printf("flit: recv done: %s wrote=%d of %d", rs.offer.Name, rs.have, rs.offer.Size) if resuming { @@ -795,6 +882,27 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { return } } + + // §9: wait for file-done and verify sha256 before reporting success. + doneCh := make(chan string, 1) + s.mu.Lock() + if s.pendingDones == nil { + s.pendingDones = make(map[string]chan string) + } + s.pendingDones[xid] = doneCh + s.mu.Unlock() + var expectedSHA256 string + select { + case expectedSHA256 = <-doneCh: + case <-time.After(15 * time.Second): + log.Printf("flit: file-done timeout for %s", rs.offer.Name) + return + } + if actualSHA256 != expectedSHA256 { + log.Printf("flit: sha256 mismatch for %s: got %s want %s", rs.offer.Name, actualSHA256, expectedSHA256) + return + } + if s.OnFileDone != nil { s.OnFileDone(rs.offer.Name, finalPath) } @@ -808,6 +916,24 @@ func (s *Session) wireFileRecv(dc *webrtc.DataChannel) { } } +// dtlsFP extracts and hex-decodes the sha-256 DTLS fingerprint from an SDP blob. +// The spec (§6) requires this for the hello binding signature. +func dtlsFP(sdp string) ([]byte, error) { + const marker = "a=fingerprint:sha-256 " + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, marker) { + continue + } + fp, err := hex.DecodeString(strings.ReplaceAll(strings.TrimPrefix(line, marker), ":", "")) + if err != nil || len(fp) != 32 { + return nil, fmt.Errorf("bad DTLS fingerprint in SDP") + } + return fp, nil + } + return nil, fmt.Errorf("no sha-256 DTLS fingerprint in SDP") +} + func mustHex(s string) []byte { b, _ := hex.DecodeString(s) return b diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts index d6a033d..fa5ba67 100644 --- a/pwa/src/transport/flit.ts +++ b/pwa/src/transport/flit.ts @@ -47,6 +47,18 @@ async function iceServers(cfg: FlitConfig): Promise { 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 { + 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 = {} + private _recv: Record = {} private _recvChannels: Record = {} - private _pushQueue: Map = new Map() + private _pushQueue: Map = 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(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 }) }