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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user