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

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

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