From 2b77c3e1020a92687d5176e6d42a88cdb7ac3f90 Mon Sep 17 00:00:00 2001 From: explewd Date: Sun, 12 Jul 2026 22:44:11 +0200 Subject: [PATCH] Fix verified status to not depend on DTLS fingerprint hash matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hello's fingerprint-bound signature was the actual gate for `verified` on both the CLI and PWA, and dtlsFP() only ever parses sha-256 fingerprints. Different WebRTC stacks report the DTLS cert hash under different algorithms (WebKit: sha-512, pion/Chromium: sha-256), so a legitimate cross-stack peer could never produce a verifiable hello — degrading to permanently "unverified", or on the Go side, never sending hello at all. Matches an interop gotcha waste-go's yaw2 docs recently called out explicitly. Real authentication already happens over the sealed signaling channel during the ekey exchange (crypto.Verify), same as waste-go. Added peerAuthed to the Go session (mirroring the PWA's existing field), set once a sealed box from the peer opens successfully, and gate `verified` on peerAuthed + hello.id matching instead. The fingerprint signature is still sent/checked when both sides have a parseable fingerprint, but only logged on mismatch — never blocking. No behavior change for today's pion<->Chromium pairings (both sha-256). Co-Authored-By: Claude Sonnet 5 --- README.md | 6 ++-- cli/internal/transport/transport.go | 50 +++++++++++++++++++---------- pwa/src/transport/flit.ts | 49 +++++++++++++++++++--------- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 8fb0968..8d1535d 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,9 @@ Each device generates an **Ed25519 keypair** on first run. The hex-encoded publi Pairing uses [yaw/2.1](PROTOCOL.md) forward-secret signaling, trimmed for flit's 1:1 ephemeral use case: -1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys — providing forward secrecy for the signaling channel. -2. Once the WebRTC data channel opens, each side sends a **`hello`** message containing their identity and a signature over a known prefix. This binds the WebRTC DTLS fingerprint to the Ed25519 identity, confirming "the device on the other end is who the QR said it was." -3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity-confirmed binding from step 2. +1. Connecting peers exchange **ephemeral X25519 keys** (`ekey`) signed with their Ed25519 identity keys over the sealed signaling channel — this is what actually authenticates "the device on the other end is who the QR said it was." +2. Once the WebRTC data channel opens, each side sends a **`hello`** message confirming its identity, plus a best-effort signature binding it to the session's DTLS fingerprint when both sides report that fingerprint under a hash algorithm flit can parse. `verified` status rests on step 1 (the authenticated sealed exchange) and `hello.id` matching it — not on the fingerprint signature, which different WebRTC stacks may report under different hash algorithms (WebKit: sha-512, Chromium/pion: sha-256) and so can't be relied on to match even between two legitimate peers. +3. File data flows over the WebRTC data channel — encrypted by DTLS, with the identity already confirmed by step 1. The anchor never sees plaintext message content — it only routes sealed (encrypted) blobs by peer id and hashed room name. diff --git a/cli/internal/transport/transport.go b/cli/internal/transport/transport.go index aaff774..98b54fa 100644 --- a/cli/internal/transport/transport.go +++ b/cli/internal/transport/transport.go @@ -172,6 +172,7 @@ type Session struct { dc *webrtc.DataChannel peerID string verified bool + peerAuthed bool // set once a sealed box from peerID has been opened successfully esk, epk *[32]byte peerEPK *[32]byte ekeySent bool @@ -423,6 +424,9 @@ func (s *Session) onBox(from, box string) { if plain == nil { return } + s.mu.Lock() + s.peerAuthed = true + s.mu.Unlock() var obj map[string]any if err := json.Unmarshal(plain, &obj); err != nil { return @@ -573,20 +577,25 @@ func (s *Session) sendHello() { 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{ + msg := map[string]string{ "type": "hello", "id": s.identity.PeerID(), - "sig": s.identity.Sign(bindMsg), - }) + } + // §6: sign prefix || local_fp || remote_fp when both are extractable. + // This is best-effort, not required — different WebRTC stacks report the + // DTLS certificate hash under different algorithms (WebKit: sha-512, + // pion/Chromium: sha-256), so a legitimate cross-stack peer may have no + // fingerprint we know how to parse. Trust rests on the sealed signaling + // exchange that already authenticated s.peerID (see onBox/peerAuthed) + // plus hello.id matching it, not on this signature — see onControl. + if lfp, err1 := dtlsFP(pc.LocalDescription().SDP); err1 == nil { + if rfp, err2 := dtlsFP(pc.RemoteDescription().SDP); err2 == nil { + bindMsg := append([]byte(bindPrefix), lfp...) + bindMsg = append(bindMsg, rfp...) + msg["sig"] = s.identity.Sign(bindMsg) + } + } + s.controlSend(msg) } func (s *Session) onControl(data []byte) { @@ -598,11 +607,18 @@ func (s *Session) onControl(data []byte) { case "hello": helloID, _ := m["id"].(string) sigHex, _ := m["sig"].(string) - verified := false s.mu.Lock() pc := s.pc + peerAuthed := s.peerAuthed s.mu.Unlock() - if pc != nil && pc.RemoteDescription() != nil && pc.LocalDescription() != nil { + // Trust rests on the sealed signaling exchange that already + // authenticated s.peerID (peerAuthed, set in onBox) plus hello.id + // matching it — not on the fingerprint-bound signature below, + // which is only checkable when both sides report their DTLS cert + // hash under an algorithm we can parse. See sendHello for why + // that can't be a hard requirement. + verified := helloID == s.peerID && peerAuthed + if sigHex != "" && 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) @@ -610,9 +626,9 @@ func (s *Session) onControl(data []byte) { 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) + if err := crypto.Verify(helloID, bindMsg, sigHex); err != nil { + log.Printf("flit: hello: fingerprint-bind signature did not verify for %s (informational only): %v", helloID, err) + } } } s.mu.Lock() diff --git a/pwa/src/transport/flit.ts b/pwa/src/transport/flit.ts index b91d59f..b7c3cb8 100644 --- a/pwa/src/transport/flit.ts +++ b/pwa/src/transport/flit.ts @@ -435,15 +435,23 @@ export class PeerConn { } private _sendHello() { + // §6: sign prefix || local_fp || remote_fp when both are extractable. + // Best-effort, not required — different WebRTC stacks report the DTLS + // certificate hash under different algorithms (WebKit: sha-512, + // pion/Chromium: sha-256), so a legitimate cross-stack peer may have no + // fingerprint we know how to parse. Trust rests on the sealed signaling + // exchange that already authenticated peerId (peerAuthed, set in onBox) + // plus hello.id matching it, not on this signature — see _onControl. + const msg: Record = { type: 'hello', id: this.identity.id } 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) + msg['sig'] = sodium.to_hex(this.identity.sign(bindMsg)) + } catch { + // no parseable fingerprint on one or both sides — hello still goes out } + this._dc(msg) } private _onControl(data: string) { @@ -452,17 +460,28 @@ export class PeerConn { if (m['type'] === 'hello') { const helloId = m['id'] as string - const helloSig = sodium.from_hex(m['sig'] as string) - 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 + // Trust rests on the sealed signaling exchange that already + // authenticated peerId (peerAuthed, set in onBox) plus hello.id + // matching it — not on the fingerprint-bound signature below, which + // is only checkable when both sides report their DTLS cert hash + // under an algorithm we can parse. See _sendHello for why that + // can't be a hard requirement. + this.verified = helloId === this.peerId && this.peerAuthed + const sigHex = m['sig'] as string | undefined + if (sigHex) { + 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 helloSig = sodium.from_hex(sigHex) + if (!Identity.verify(helloId, bindMsg, helloSig)) { + console.warn(`flit: hello: fingerprint-bind signature did not verify for ${helloId} (informational only)`) + } + } catch { + // no parseable fingerprint on one or both sides — nothing to check + } } this.events.connected?.(this.verified, this.peerId) } else if (m['type'] === 'file-offer') {