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

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