Fix verified status to not depend on DTLS fingerprint hash matching

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 <noreply@anthropic.com>
This commit is contained in:
explewd
2026-07-12 22:44:11 +02:00
parent 932a4bd7bb
commit 2b77c3e102
3 changed files with 70 additions and 35 deletions

View File

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

View File

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

View File

@@ -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<string, string> = { 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') {