fix(e2ee): bind a key epoch into room-key offers and show a per-call session fingerprint (#1394)

* fix(e2ee): bind the key epoch into wrapped room-key offers

The holder's rotation counter now rides inside encrypted_key as a
versioned header and is bound as AES-GCM additional data, so a receiver
can tell a current room key from a superseded one. Receivers keep a
per-sender high-water mark and apply an offer only at or above it; the
mark resets when that sender announces a fresh ephemeral key. Blobs in
the pre-epoch layout are still accepted for holders on the older build
(compat path, scheduled for removal next release). No server or schema
change: the relay treats encrypted_key as opaque.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(e2ee): show a per-call session fingerprint for every voice peer

A peer with no published identity key has no safety number, so the TOFU
badge gave the user nothing to compare out of band. Every accepted
announce now also carries a fingerprint of the peer's ephemeral session
key, shown on the unverified badge and labelled as changing every call
and not an identity; the local user's own session fingerprint is shown
on their row so it can be read back. safetyNumber is unchanged and stays
null for unverified peers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(ledger): mark OC-0001 and OC-0003 fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-19 19:04:51 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c86d803a18
commit eacba10cff
12 changed files with 566 additions and 70 deletions
+25 -25
View File
@@ -2,10 +2,22 @@
Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`. Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.
**0 open** · 0 blocked · 186 fixed · 4 declined · 0 refuted · 1 duplicate **0 open** · 0 blocked · 188 fixed · 2 declined · 0 refuted · 1 duplicate
## Fixed ## Fixed
### OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever
`Client/tauri-client/src/lib/livekitE2EE.ts:772` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `crypto-primitives`
The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.
**Repro:** Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.
**Evidence:** e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install
**Fixed:** `84033139` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass
### OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it ### OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it
`Client/tauri-client/src/components/VoiceWidget.ts:196` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability` `Client/tauri-client/src/components/VoiceWidget.ts:196` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `degradation-observability`
@@ -18,6 +30,18 @@ The badge is derived purely from voiceStatus === 'connected', never from the SDK
**Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/voice-widget.test.ts` · revert-proof pass **Fixed:** `8579cb5d` · test `Client/tauri-client/tests/unit/voice-widget.test.ts` · revert-proof pass
### OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch
`Client/tauri-client/src/lib/livekitE2EE.ts:477` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain`
The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.
**Repro:** A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.
**Evidence:** livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers
**Fixed:** `bf7612fb` · test `Client/tauri-client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass
### OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived ### OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived
`Client/tauri-client/src/lib/livekitE2EE.ts:864` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election` `Client/tauri-client/src/lib/livekitE2EE.ts:864` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `keyholder-election`
@@ -4572,30 +4596,6 @@ CreateChannelModal.ts:163 createBtn.setAttribute("disabled", "true");
## Declined ## Declined
### OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever
`Client/tauri-client/src/lib/livekitE2EE.ts:772` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `crypto-primitives`
The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.
**Repro:** Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.
**Evidence:** e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install
**Declined:** Requires a protocol-level freshness field (epoch in voice_e2ee_offer via protocol-change across schema/server/client) plus a design decision on the scheme; a client-only patch cannot detect replay without a wire epoch. Architectural, not a single-file fix.
### OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch
`Client/tauri-client/src/lib/livekitE2EE.ts:477` · found 2026-08-09 · hunt `voice-e2ee-2026-08-09` · lens `tofu-trust-chain`
The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.
**Repro:** A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.
**Evidence:** livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers
**Declined:** A fallback safety number from the ephemeral key conflicts with the test-pinned contract that safetyNumber is null for legacy/unverified peers (livekit-session.test.ts), and substituting a weaker per-session fingerprint for the identity-based safety number is a product/security design decision, not a file-scoped fix.
### OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message ### OC-0039 — medium — DeleteMessage treats a GetChannel read error as "not a DM", letting a moderator hard-delete another user's private DM message
`Server/service/message_crud.go:346` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service` `Server/service/message_crud.go:346` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service`
+12 -6
View File
@@ -10,12 +10,15 @@
"why": "The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.", "why": "The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.",
"repro": "Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.", "repro": "Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.",
"evidence": "e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install", "evidence": "e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install",
"status": "declined", "status": "fixed",
"found": "2026-08-09", "found": "2026-08-09",
"hunt": "voice-e2ee-2026-08-09", "hunt": "voice-e2ee-2026-08-09",
"lens": "crypto-primitives", "lens": "crypto-primitives",
"fix": null, "fix": {
"rationale": "Requires a protocol-level freshness field (epoch in voice_e2ee_offer via protocol-change across schema/server/client) plus a design decision on the scheme; a client-only patch cannot detect replay without a wire epoch. Architectural, not a single-file fix." "commit": "84033139",
"test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts",
"revertProof": "pass"
}
}, },
{ {
"id": "OC-0002", "id": "OC-0002",
@@ -47,12 +50,15 @@
"why": "The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.", "why": "The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.",
"repro": "A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.", "repro": "A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.",
"evidence": "livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers", "evidence": "livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers",
"status": "declined", "status": "fixed",
"found": "2026-08-09", "found": "2026-08-09",
"hunt": "voice-e2ee-2026-08-09", "hunt": "voice-e2ee-2026-08-09",
"lens": "tofu-trust-chain", "lens": "tofu-trust-chain",
"fix": null, "fix": {
"rationale": "A fallback safety number from the ephemeral key conflicts with the test-pinned contract that safetyNumber is null for legacy/unverified peers (livekit-session.test.ts), and substituting a weaker per-session fingerprint for the identity-based safety number is a product/security design decision, not a file-scoped fix." "commit": "bf7612fb",
"test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts",
"revertProof": "pass"
}
}, },
{ {
"id": "OC-0004", "id": "OC-0004",
@@ -71,10 +71,16 @@ function verifyPresentation(v: PeerVerification): {
}; };
} }
// "unverified" — the remaining status: peer published no identity key (legacy). // "unverified" — the remaining status: peer published no identity key (legacy).
// No identity key means no safety number; the per-call session fingerprint
// is the only value that can be compared out of band (OC-0003).
return { return {
icon: "shield", icon: "shield",
color: "var(--text-muted, #949ba4)", color: "var(--text-muted, #949ba4)",
title: "Identity not verified — this participant published no key", title:
"Identity not verified — this participant published no key." +
(v.sessionFingerprint !== null
? ` Session fingerprint (changes every call — not an identity): ${v.sessionFingerprint}`
: ""),
}; };
} }
@@ -456,6 +462,19 @@ function renderVoiceChannelItem(
row.appendChild(muteIcon); row.appendChild(muteIcon);
} }
// The local user's own session fingerprint (OC-0003): what a peer who
// sees us as unverified compares against, so show it where it can be
// read out. The local user is never in peerVerifications.
const currentUser = getCurrentUser();
const ownFingerprint = voiceStore.select((st) => st.localSessionFingerprint ?? null);
if (currentUser !== null && currentUser.id === user.userId && ownFingerprint !== null) {
const own = createElement("span", { class: "vu-verify vu-session-fp" });
own.style.color = "var(--text-muted, #949ba4)";
own.title = `Your session fingerprint (changes every call — not an identity): ${ownFingerprint}`;
own.appendChild(createIcon("shield", 14));
row.appendChild(own);
}
// E2EE identity verification badge (F3 TOFU). Absent until the peer's // E2EE identity verification badge (F3 TOFU). Absent until the peer's
// announce resolves; the local user is never in peerVerifications. // announce resolves; the local user is never in peerVerifications.
const verification = getPeerVerification(user.userId); const verification = getPeerVerification(user.userId);
@@ -484,7 +503,6 @@ function renderVoiceChannelItem(
} }
// Right-click for per-user volume (skip for own user) // Right-click for per-user volume (skip for own user)
const currentUser = getCurrentUser();
if (currentUser === null || currentUser.id !== user.userId) { if (currentUser === null || currentUser.id !== user.userId) {
row.addEventListener( row.addEventListener(
"contextmenu", "contextmenu",
@@ -915,7 +933,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// kills hover) and never pays a per-user querySelector. // kills hover) and never pays a per-user querySelector.
const unsubVoiceStructure = voiceStore.subscribeSelector( const unsubVoiceStructure = voiceStore.subscribeSelector(
(state) => { (state) => {
let structSig = String(state.currentChannelId ?? ""); let structSig = `${state.currentChannelId ?? ""}#${state.localSessionFingerprint ?? ""}`;
for (const [chId, users] of state.voiceUsers) { for (const [chId, users] of state.voiceUsers) {
structSig += `|${chId}`; structSig += `|${chId}`;
for (const [uid, u] of users) { for (const [uid, u] of users) {
+61 -11
View File
@@ -77,7 +77,13 @@ export async function importPublicKey(base64: string): Promise<CryptoKey> {
*/ */
export async function computeKeyFingerprint(publicKey: CryptoKey): Promise<string> { export async function computeKeyFingerprint(publicKey: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey("raw", publicKey); const raw = await crypto.subtle.exportKey("raw", publicKey);
const hash = await crypto.subtle.digest("SHA-256", raw); return computeRawKeyFingerprint(new Uint8Array(raw));
}
/** Same fingerprint as computeKeyFingerprint, over the raw public-key bytes
* (the form an announce carries) — no import needed. */
export async function computeRawKeyFingerprint(raw: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", raw as Uint8Array<ArrayBuffer>);
const hex = Array.from(new Uint8Array(hash)) const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase()) .map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(""); .join("");
@@ -204,51 +210,95 @@ export function roomKeyToBase64(key: Uint8Array): string {
// ── Room key wrapping (ECDH + HKDF + AES-GCM) ────────────────────────────── // ── Room key wrapping (ECDH + HKDF + AES-GCM) ──────────────────────────────
/** Wire format version byte for `encrypted_key`. v1 prepends a header of
* `0x01 ‖ epoch (u64 big-endian)` to the GCM ciphertext and binds that same
* epoch as additional data, so a peer can tell a fresh room key from a
* superseded one and the relay cannot alter the epoch unnoticed (OC-0001).
* Blobs with no header are the pre-epoch format: unwrap still accepts them
* for holders on the old build, reporting `epoch: null`. */
const OFFER_FORMAT_V1 = 0x01;
const OFFER_HEADER_BYTES = 9;
function encodeOfferEpoch(epoch: number): Uint8Array<ArrayBuffer> {
if (!Number.isSafeInteger(epoch) || epoch < 0) {
throw new Error("E2EE: offer epoch must be a non-negative safe integer");
}
const header = new Uint8Array(OFFER_HEADER_BYTES);
header[0] = OFFER_FORMAT_V1;
new DataView(header.buffer).setBigUint64(1, BigInt(epoch));
return header;
}
/** /**
* Wrap (encrypt) a room key for a specific peer. * Wrap (encrypt) a room key for a specific peer.
* *
* 1. ECDH(myPrivate, peerPublic) → raw shared secret * 1. ECDH(myPrivate, peerPublic) → raw shared secret
* 2. HKDF-SHA256(shared, salt, info) → 256-bit AES wrapping key * 2. HKDF-SHA256(shared, salt, info) → 256-bit AES wrapping key
* 3. AES-GCM(wrappingKey, randomIV, roomKey) → ciphertext * 3. AES-GCM(wrappingKey, randomIV, roomKey, aad=header) → ciphertext
* 4. encrypted_key = header ‖ ciphertext
*/ */
export async function wrapRoomKey( export async function wrapRoomKey(
myPrivateKey: CryptoKey, myPrivateKey: CryptoKey,
peerPublicKey: CryptoKey, peerPublicKey: CryptoKey,
roomKey: Uint8Array, roomKey: Uint8Array,
epoch: number,
): Promise<{ encryptedKey: string; iv: string }> { ): Promise<{ encryptedKey: string; iv: string }> {
const header = encodeOfferEpoch(epoch);
const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit GCM nonce const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit GCM nonce
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, { name: "AES-GCM", iv, additionalData: header },
wrapKey, wrapKey,
roomKey as Uint8Array<ArrayBuffer>, roomKey as Uint8Array<ArrayBuffer>,
); );
return { const blob = new Uint8Array(OFFER_HEADER_BYTES + ciphertext.byteLength);
encryptedKey: uint8ToBase64(new Uint8Array(ciphertext)), blob.set(header, 0);
iv: uint8ToBase64(iv), blob.set(new Uint8Array(ciphertext), OFFER_HEADER_BYTES);
}; return { encryptedKey: uint8ToBase64(blob), iv: uint8ToBase64(iv) };
} }
/** /**
* Unwrap (decrypt) a room key received from a peer. * Unwrap (decrypt) a room key received from a peer.
* *
* Same ECDH + HKDF derivation as wrapRoomKey, but on the receiver's side. * Same ECDH + HKDF derivation as wrapRoomKey, but on the receiver's side.
* Returns the epoch the holder bound into the blob, or null for a legacy
* (pre-epoch) blob.
*/ */
export async function unwrapRoomKey( export async function unwrapRoomKey(
myPrivateKey: CryptoKey, myPrivateKey: CryptoKey,
peerPublicKey: CryptoKey, peerPublicKey: CryptoKey,
encryptedKeyBase64: string, encryptedKeyBase64: string,
ivBase64: string, ivBase64: string,
): Promise<Uint8Array> { ): Promise<{ roomKey: Uint8Array; epoch: number | null }> {
const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey);
const iv = base64ToUint8(ivBase64); const iv = base64ToUint8(ivBase64);
const ciphertext = base64ToUint8(encryptedKeyBase64); const blob = base64ToUint8(encryptedKeyBase64);
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, wrapKey, ciphertext); // Legacy blob: exactly key + GCM tag, no header. A v1 blob is 9 bytes
// longer, so the two formats never collide on length.
// ponytail: compat for holders on the pre-epoch build — remove after the
// next release, then any non-v1 blob is rejected.
if (blob.byteLength === ROOM_KEY_BYTES + 16) {
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, wrapKey, blob);
return { roomKey: new Uint8Array(plaintext), epoch: null };
}
return new Uint8Array(plaintext); if (blob.byteLength < OFFER_HEADER_BYTES || blob[0] !== OFFER_FORMAT_V1) {
throw new Error("E2EE: unknown wrapped-key format");
}
const header = blob.subarray(0, OFFER_HEADER_BYTES);
const epochBig = new DataView(blob.buffer, blob.byteOffset, OFFER_HEADER_BYTES).getBigUint64(1);
if (epochBig > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("E2EE: offer epoch out of range");
}
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, additionalData: header },
wrapKey,
blob.subarray(OFFER_HEADER_BYTES),
);
return { roomKey: new Uint8Array(plaintext), epoch: Number(epochBig) };
} }
// ── Internal helpers ──────────────────────────────────────────────────────── // ── Internal helpers ────────────────────────────────────────────────────────
+75 -5
View File
@@ -16,6 +16,7 @@ import {
verifyEphemeralKeySignature, verifyEphemeralKeySignature,
importIdentityPublicKey, importIdentityPublicKey,
computeKeyFingerprint, computeKeyFingerprint,
computeRawKeyFingerprint,
} from "@lib/e2eeCrypto"; } from "@lib/e2eeCrypto";
import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity";
import { authStore } from "@stores/auth.store"; import { authStore } from "@stores/auth.store";
@@ -25,6 +26,7 @@ import {
setPeerVerification, setPeerVerification,
clearPeerVerification, clearPeerVerification,
clearPeerVerifications, clearPeerVerifications,
setLocalSessionFingerprint,
} from "@stores/voice.store"; } from "@stores/voice.store";
import { createLogger } from "@lib/logger"; import { createLogger } from "@lib/logger";
@@ -59,6 +61,11 @@ export class E2EEManager {
* replay of a key we already moved a peer off of from overwriting their * replay of a key we already moved a peer off of from overwriting their
* current live key (OC-0011). */ * current live key (OC-0011). */
private _retiredPeerKeys: Map<number, Set<string>> = new Map(); private _retiredPeerKeys: Map<number, Set<string>> = new Map();
/** Highest offer epoch applied per sender (OC-0001). The holder binds its
* epoch into every wrapped room key; an offer below this mark is a
* superseded key and is discarded. Per sender because each client's epoch
* counter is local; reset when that sender's ephemeral key is replaced. */
private _peerOfferEpochs: Map<number, number> = new Map();
/** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our /** This client's long-term ECDSA identity keypair (F3 TOFU), used to sign our
* ephemeral announces. Loaded lazily from the OS keyring, cached per session. */ * ephemeral announces. Loaded lazily from the OS keyring, cached per session. */
private _identityKeyPair: CryptoKeyPair | null = null; private _identityKeyPair: CryptoKeyPair | null = null;
@@ -165,8 +172,10 @@ export class E2EEManager {
} }
this._peerPublicKeys.clear(); this._peerPublicKeys.clear();
this._retiredPeerKeys.clear(); this._retiredPeerKeys.clear();
this._peerOfferEpochs.clear();
clearPeerVerifications(); clearPeerVerifications();
const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey); const myPubKeyBase64 = await exportPublicKey(ecdhKeyPair.publicKey);
const myFingerprint = await computeRawKeyFingerprint(this.rawFromBase64(myPubKeyBase64));
// Build the signed announce up front — this loads the identity key from // Build the signed announce up front — this loads the identity key from
// the keyring once, so the added identity round-trip does NOT stack on // the keyring once, so the added identity round-trip does NOT stack on
// the non-key-holder's 10s key-exchange stall below (F3). // the non-key-holder's 10s key-exchange stall below (F3).
@@ -222,6 +231,7 @@ export class E2EEManager {
// that arrived during the awaits above was queued (not silently // that arrived during the awaits above was queued (not silently
// processed with no offer sent) and gets its offer sent below. // processed with no offer sent) and gets its offer sent below.
this._ecdhKeyPair = ecdhKeyPair; this._ecdhKeyPair = ecdhKeyPair;
setLocalSessionFingerprint(myFingerprint);
if (this._isKeyHolder) { if (this._isKeyHolder) {
// Announce our (signed) key BEFORE draining queued announces. The // Announce our (signed) key BEFORE draining queued announces. The
@@ -348,6 +358,12 @@ export class E2EEManager {
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
} }
const reconnectPubKey = await exportPublicKey(pair.publicKey); const reconnectPubKey = await exportPublicKey(pair.publicKey);
const reconnectFingerprint = await computeRawKeyFingerprint(
this.rawFromBase64(reconnectPubKey),
);
if (this._ecdhKeyPair === pair) {
setLocalSessionFingerprint(reconnectFingerprint);
}
const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey); const reconnectAnnounce = await this.buildAnnouncePayload(reconnectPubKey);
// Re-check ownership right before the send too: buildAnnouncePayload can // Re-check ownership right before the send too: buildAnnouncePayload can
// itself await a keyring round trip, another window for clearState() (or // itself await a keyring round trip, another window for clearState() (or
@@ -506,6 +522,7 @@ export class E2EEManager {
userId, userId,
status: "unknown", status: "unknown",
safetyNumber: null, safetyNumber: null,
sessionFingerprint: null,
}); });
log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", { log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", {
userId, userId,
@@ -522,6 +539,7 @@ export class E2EEManager {
userId, userId,
status: "mismatch", status: "mismatch",
safetyNumber: null, safetyNumber: null,
sessionFingerprint: null,
}); });
log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", {
userId, userId,
@@ -529,6 +547,11 @@ export class E2EEManager {
return false; return false;
} }
// Fingerprint of the ephemeral key this announce carries (OC-0003). Every
// accepted peer gets one — for an unverified peer it is the only value
// that can be compared out of band, since there is no identity key.
const sessionFingerprint = await computeRawKeyFingerprint(this.rawFromBase64(publicKeyBase64));
// Genuine legacy peer: never pinned AND no published identity key — accept // Genuine legacy peer: never pinned AND no published identity key — accept
// but mark unverified (pin-pending). This is the only case the compatibility // but mark unverified (pin-pending). This is the only case the compatibility
// posture keeps open. // posture keeps open.
@@ -537,6 +560,7 @@ export class E2EEManager {
userId, userId,
status: "unverified", status: "unverified",
safetyNumber: null, safetyNumber: null,
sessionFingerprint,
}); });
log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId });
return true; return true;
@@ -556,6 +580,7 @@ export class E2EEManager {
userId, userId,
status: "mismatch", status: "mismatch",
safetyNumber: null, safetyNumber: null,
sessionFingerprint: null,
}); });
log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId });
return false; return false;
@@ -585,11 +610,17 @@ export class E2EEManager {
userId, userId,
status: "unverified", status: "unverified",
safetyNumber: null, safetyNumber: null,
sessionFingerprint,
}); });
return true; // still accept the announce — the write failure alone shouldn't block the call return true; // still accept the announce — the write failure alone shouldn't block the call
} }
const safetyNumber = await computeKeyFingerprint(identityKey); const safetyNumber = await computeKeyFingerprint(identityKey);
this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber }); this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "verified",
safetyNumber,
sessionFingerprint,
});
return true; return true;
} }
@@ -775,6 +806,7 @@ export class E2EEManager {
} }
if (!isDuplicate) { if (!isDuplicate) {
this._peerPublicKeys.set(userId, peerKey); this._peerPublicKeys.set(userId, peerKey);
this._peerOfferEpochs.delete(userId);
log.info("E2EE: received peer public key", { userId }); log.info("E2EE: received peer public key", { userId });
} }
@@ -790,7 +822,12 @@ export class E2EEManager {
// happened, ship this pre-rotation wrap and the receiver's // happened, ship this pre-rotation wrap and the receiver's
// strictly-ordered _offerChain ends up on the dead key. // strictly-ordered _offerChain ends up on the dead key.
const epochBefore = this._e2eeEpoch; const epochBefore = this._e2eeEpoch;
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, currentRoomKey); const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
currentRoomKey,
epochBefore,
);
// Discard if either the epoch advanced (a rotation landed during the // Discard if either the epoch advanced (a rotation landed during the
// wrap) OR the keypair no longer matches (a concurrent // wrap) OR the keypair no longer matches (a concurrent
// reannounceForReconnect() swapped it without bumping the epoch) — // reannounceForReconnect() swapped it without bumping the epoch) —
@@ -886,7 +923,7 @@ export class E2EEManager {
// unwrap, the epoch will have advanced and we discard this stale result. // unwrap, the epoch will have advanced and we discard this stale result.
const epochBefore = this._e2eeEpoch; const epochBefore = this._e2eeEpoch;
const unwrapped = await unwrapRoomKey( const { roomKey: unwrapped, epoch } = await unwrapRoomKey(
keypair.privateKey, keypair.privateKey,
peerKey, peerKey,
encryptedKeyBase64, encryptedKeyBase64,
@@ -906,9 +943,34 @@ export class E2EEManager {
return; return;
} }
// Freshness (OC-0001): the epoch is GCM-authenticated, so it is the
// holder's own value. Equal is fine — the holder re-sends the current
// key at the current epoch when a peer re-announces.
if (epoch === null) {
// ponytail: compat with holders on the pre-epoch build — remove with
// the legacy branch in unwrapRoomKey.
log.warn(
"E2EE: offer carries no epoch (legacy holder) — applying without freshness check",
{
fromUserId,
},
);
} else {
const highWater = this._peerOfferEpochs.get(fromUserId);
if (highWater !== undefined && epoch < highWater) {
log.warn("E2EE: discarding superseded offer (epoch below high-water mark)", {
fromUserId,
epoch,
highWater,
});
return;
}
this._peerOfferEpochs.set(fromUserId, epoch);
}
this._roomKey = unwrapped; this._roomKey = unwrapped;
await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); await this.keyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: room key received and applied", { fromUserId }); log.info("E2EE: room key received and applied", { fromUserId, epoch });
// Re-check after the setKey await too: the guard above only covers the // Re-check after the setKey await too: the guard above only covers the
// window up to unwrap, not this call. A teardown-and-rejoin-as-holder // window up to unwrap, not this call. A teardown-and-rejoin-as-holder
@@ -1059,7 +1121,12 @@ export class E2EEManager {
}); });
return; return;
} }
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey); const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
roomKey,
this._e2eeEpoch,
);
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) { if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", { log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", {
peerId, peerId,
@@ -1131,6 +1198,7 @@ export class E2EEManager {
const departingKey = this._peerPublicKeys.get(userId); const departingKey = this._peerPublicKeys.get(userId);
const hadPeerKey = departingKey !== undefined; const hadPeerKey = departingKey !== undefined;
this._peerPublicKeys.delete(userId); this._peerPublicKeys.delete(userId);
this._peerOfferEpochs.delete(userId);
clearPeerVerification(userId); clearPeerVerification(userId);
// Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only // Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only
// defense against replay of a validly-signed announce (the signed // defense against replay of a validly-signed announce (the signed
@@ -1347,7 +1415,9 @@ export class E2EEManager {
this._roomKey = null; this._roomKey = null;
this._peerPublicKeys.clear(); this._peerPublicKeys.clear();
this._retiredPeerKeys.clear(); this._retiredPeerKeys.clear();
this._peerOfferEpochs.clear();
clearPeerVerifications(); clearPeerVerifications();
setLocalSessionFingerprint(null);
this._isKeyHolder = false; this._isKeyHolder = false;
this._rotatingKey = false; this._rotatingKey = false;
this._rotationPending = false; this._rotationPending = false;
@@ -62,6 +62,11 @@ export interface PeerVerification {
/** Safety number (identity-key fingerprint) for out-of-band verification; /** Safety number (identity-key fingerprint) for out-of-band verification;
* null for legacy/unverified/mismatch/unknown peers. */ * null for legacy/unverified/mismatch/unknown peers. */
readonly safetyNumber: string | null; readonly safetyNumber: string | null;
/** Fingerprint of the peer's ephemeral session key (OC-0003). Set whenever
* the announce was accepted (verified or unverified), null when it was
* rejected. Changes every call, so it is NOT an identity — it is the only
* thing an unverified peer has that can be compared out of band. */
readonly sessionFingerprint: string | null;
} }
export interface VoiceState { export interface VoiceState {
@@ -106,6 +111,10 @@ export interface VoiceState {
* always sets it; optional only so the many inline VoiceState test fixtures * always sets it; optional only so the many inline VoiceState test fixtures
* need not restate it. */ * need not restate it. */
readonly peerVerifications?: ReadonlyMap<number, PeerVerification>; readonly peerVerifications?: ReadonlyMap<number, PeerVerification>;
/** Fingerprint of the local user's own ephemeral session key (OC-0003), so
* it can be read out to a peer who sees us as unverified. Null outside a
* voice session. Optional for the same fixture reason as peerVerifications. */
readonly localSessionFingerprint?: string | null;
} }
const INITIAL_STATE: VoiceState = { const INITIAL_STATE: VoiceState = {
@@ -514,6 +523,15 @@ export function clearPeerVerification(userId: number): void {
}); });
} }
/** Publish (or clear, with null) the local user's own session fingerprint. */
export function setLocalSessionFingerprint(fingerprint: string | null): void {
voiceStore.setState((prev) =>
(prev.localSessionFingerprint ?? null) === fingerprint
? prev
: { ...prev, localSessionFingerprint: fingerprint },
);
}
/** Drop all peer verifications (on voice leave). */ /** Drop all peer verifications (on voice leave). */
export function clearPeerVerifications(): void { export function clearPeerVerifications(): void {
voiceStore.setState((prev) => voiceStore.setState((prev) =>
@@ -231,9 +231,12 @@ test.describe("Voice E2EE identity verification (§7)", () => {
const badge = peerBadge(page); const badge = peerBadge(page);
await expect(badge).toBeVisible({ timeout: 10_000 }); await expect(badge).toBeVisible({ timeout: 10_000 });
await expect(badge).toHaveClass(/unverified/); await expect(badge).toHaveClass(/unverified/);
// No identity key → no safety number, but the per-call session
// fingerprint is shown (labelled as not an identity) so there is still
// something to compare out of band (OC-0003).
await expect(badge).toHaveAttribute( await expect(badge).toHaveAttribute(
"title", "title",
"Identity not verified — this participant published no key", /^Identity not verified — this participant published no key\. Session fingerprint \(changes every call — not an identity\): ([0-9A-F]{4} ){7}[0-9A-F]{4}$/,
); );
expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0); expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0);
}); });
@@ -113,10 +113,11 @@ function setPeerVerif(
userId: number, userId: number,
status: PeerVerification["status"], status: PeerVerification["status"],
safetyNumber: string | null = null, safetyNumber: string | null = null,
sessionFingerprint: string | null = null,
): void { ): void {
voiceStore.setState((prev) => { voiceStore.setState((prev) => {
const peerVerifications = new Map(prev.peerVerifications ?? []); const peerVerifications = new Map(prev.peerVerifications ?? []);
peerVerifications.set(userId, { userId, status, safetyNumber }); peerVerifications.set(userId, { userId, status, safetyNumber, sessionFingerprint });
return { ...prev, peerVerifications }; return { ...prev, peerVerifications };
}); });
} }
@@ -1990,6 +1991,33 @@ describe("ChannelSidebar voice identity badge", () => {
expect(badge!.classList.contains("unverified")).toBe(true); expect(badge!.classList.contains("unverified")).toBe(true);
}); });
it("shows the session fingerprint, labelled as not an identity, on an unverified badge", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unverified", null, "5E55 1234 5678 9ABC");
sidebar.mount(container);
const title = badgeFor(10)!.getAttribute("title") ?? "";
expect(title).toContain("5E55 1234 5678 9ABC");
expect(title).toContain("not an identity");
});
it("shows the local user's own session fingerprint on their voice row", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 7, username: "Me", avatar: null, role: "member", status: "online" } as never,
isAuthenticated: true,
}));
addVoiceUser(VOICE_CH, 7, "Me");
voiceStore.setState((prev) => ({ ...prev, localSessionFingerprint: "0123 4567 89AB CDEF" }));
sidebar.mount(container);
const own = container.querySelector(
`.voice-user-item[data-voice-uid="7"] .vu-session-fp`,
) as HTMLElement | null;
expect(own).not.toBeNull();
expect(own!.getAttribute("title")).toContain("0123 4567 89AB CDEF");
});
it("shows a mismatch badge for a peer whose identity key changed", () => { it("shows a mismatch badge for a peer whose identity key changed", () => {
addVoiceUser(VOICE_CH, 10, "Alice"); addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "mismatch", null); setPeerVerif(10, "mismatch", null);
@@ -7,6 +7,7 @@ import {
wrapRoomKey, wrapRoomKey,
unwrapRoomKey, unwrapRoomKey,
computeKeyFingerprint, computeKeyFingerprint,
computeRawKeyFingerprint,
roomKeyToBase64, roomKeyToBase64,
generateIdentityKeyPair, generateIdentityKeyPair,
signEphemeralKey, signEphemeralKey,
@@ -29,10 +30,16 @@ describe("e2eeCrypto", () => {
const bob = await generateECDHKeyPair(); const bob = await generateECDHKeyPair();
const roomKey = generateRoomKey(); const roomKey = generateRoomKey();
const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1);
const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); const { roomKey: unwrapped, epoch } = await unwrapRoomKey(
bob.privateKey,
alice.publicKey,
encryptedKey,
iv,
);
expect(unwrapped).toEqual(roomKey); expect(unwrapped).toEqual(roomKey);
expect(epoch).toBe(1);
}); });
it("produces a different ciphertext each call (fresh IV)", async () => { it("produces a different ciphertext each call (fresh IV)", async () => {
@@ -40,8 +47,8 @@ describe("e2eeCrypto", () => {
const bob = await generateECDHKeyPair(); const bob = await generateECDHKeyPair();
const roomKey = generateRoomKey(); const roomKey = generateRoomKey();
const first = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); const first = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1);
const second = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); const second = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1);
// The IVs should differ, making ciphertexts distinct // The IVs should differ, making ciphertexts distinct
expect(first.iv).not.toBe(second.iv); expect(first.iv).not.toBe(second.iv);
@@ -56,17 +63,124 @@ describe("e2eeCrypto", () => {
const bob = await generateECDHKeyPair(); const bob = await generateECDHKeyPair();
const roomKey = generateRoomKey(); const roomKey = generateRoomKey();
const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1);
// Decode, flip the first byte, re-encode // Decode, flip the first ciphertext byte (after the 9-byte header), re-encode
const bytes = Uint8Array.from(atob(encryptedKey), (c) => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(encryptedKey), (c) => c.charCodeAt(0));
bytes[0] = bytes[0]! ^ 0xff; bytes[9] = bytes[9]! ^ 0xff;
const tampered = btoa(String.fromCharCode(...bytes)); const tampered = btoa(String.fromCharCode(...bytes));
await expect(unwrapRoomKey(bob.privateKey, alice.publicKey, tampered, iv)).rejects.toThrow(); await expect(unwrapRoomKey(bob.privateKey, alice.publicKey, tampered, iv)).rejects.toThrow();
}); });
}); });
// ── epoch binding (OC-0001) ───────────────────────────────────────────────
describe("offer epoch binding", () => {
const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
const fromB64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
it("carries the epoch as a 0x01 version byte + u64 big-endian header", async () => {
const alice = await generateECDHKeyPair();
const bob = await generateECDHKeyPair();
const { encryptedKey } = await wrapRoomKey(
alice.privateKey,
bob.publicKey,
generateRoomKey(),
0x0102030405,
);
const bytes = fromB64(encryptedKey);
expect(Array.from(bytes.subarray(0, 9))).toEqual([1, 0, 0, 0, 1, 2, 3, 4, 5]);
// 32-byte key + 16-byte GCM tag after the header
expect(bytes.byteLength).toBe(9 + 48);
});
it("rejects a blob whose epoch header was edited", async () => {
const alice = await generateECDHKeyPair();
const bob = await generateECDHKeyPair();
const { encryptedKey, iv } = await wrapRoomKey(
alice.privateKey,
bob.publicKey,
generateRoomKey(),
3,
);
const bytes = fromB64(encryptedKey);
bytes[8] = 9; // epoch 3 -> 9, ciphertext untouched
await expect(
unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv),
).rejects.toThrow();
});
it("rejects an unknown format version byte", async () => {
const alice = await generateECDHKeyPair();
const bob = await generateECDHKeyPair();
const { encryptedKey, iv } = await wrapRoomKey(
alice.privateKey,
bob.publicKey,
generateRoomKey(),
3,
);
const bytes = fromB64(encryptedKey);
bytes[0] = 2;
await expect(
unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv),
).rejects.toThrow();
});
it("rejects an epoch that is negative or not a safe integer", async () => {
const alice = await generateECDHKeyPair();
const bob = await generateECDHKeyPair();
await expect(
wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), -1),
).rejects.toThrow();
await expect(
wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 2 ** 53),
).rejects.toThrow();
});
it("still unwraps a legacy blob (no header, no additional data) and reports epoch null", async () => {
const alice = await generateECDHKeyPair();
const bob = await generateECDHKeyPair();
const roomKey = generateRoomKey();
// Build the pre-epoch wire format by hand: ECDH -> HKDF(salt, info) ->
// AES-GCM with no additional data, raw ciphertext in encrypted_key.
const shared = await crypto.subtle.deriveBits(
{ name: "ECDH", public: bob.publicKey },
alice.privateKey,
256,
);
const hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
const wrapKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: new TextEncoder().encode("owncord-voice-e2ee-v1"),
info: new TextEncoder().encode("room-key-wrap"),
},
hkdf,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"],
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
wrapKey,
roomKey as Uint8Array<ArrayBuffer>,
);
const result = await unwrapRoomKey(
bob.privateKey,
alice.publicKey,
b64(new Uint8Array(ct)),
b64(iv),
);
expect(result.roomKey).toEqual(roomKey);
expect(result.epoch).toBeNull();
});
});
// ── fingerprint stability ───────────────────────────────────────────────── // ── fingerprint stability ─────────────────────────────────────────────────
describe("computeKeyFingerprint", () => { describe("computeKeyFingerprint", () => {
@@ -89,6 +203,13 @@ describe("e2eeCrypto", () => {
expect(fpA).not.toBe(fpB); expect(fpA).not.toBe(fpB);
}); });
it("computeRawKeyFingerprint over the exported raw bytes matches computeKeyFingerprint", async () => {
const { publicKey } = await generateECDHKeyPair();
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
expect(await computeRawKeyFingerprint(raw)).toBe(await computeKeyFingerprint(publicKey));
});
it("formats the fingerprint as 8 space-separated 4-char hex groups", async () => { it("formats the fingerprint as 8 space-separated 4-char hex groups", async () => {
const { publicKey } = await generateECDHKeyPair(); const { publicKey } = await generateECDHKeyPair();
const fp = await computeKeyFingerprint(publicKey); const fp = await computeKeyFingerprint(publicKey);
@@ -117,8 +238,13 @@ describe("e2eeCrypto", () => {
// The reimported key must be usable in a wrap/unwrap cycle // The reimported key must be usable in a wrap/unwrap cycle
const roomKey = generateRoomKey(); const roomKey = generateRoomKey();
const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, reimported, roomKey); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, reimported, roomKey, 1);
const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); const { roomKey: unwrapped } = await unwrapRoomKey(
bob.privateKey,
alice.publicKey,
encryptedKey,
iv,
);
expect(unwrapped).toEqual(roomKey); expect(unwrapped).toEqual(roomKey);
}); });
}); });
@@ -32,13 +32,14 @@ vi.mock("@lib/e2eeCrypto", () => ({
generateRoomKey: vi.fn(() => new Uint8Array(32)), generateRoomKey: vi.fn(() => new Uint8Array(32)),
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })), wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
unwrapRoomKey: vi.fn(async () => new Uint8Array(32)), unwrapRoomKey: vi.fn(async () => ({ roomKey: new Uint8Array(32), epoch: 0 })),
signEphemeralKey: vi.fn(async () => "mock-signature"), signEphemeralKey: vi.fn(async () => "mock-signature"),
verifyEphemeralKeySignature: vi.fn(async () => true), verifyEphemeralKeySignature: vi.fn(async () => true),
importIdentityPublicKey: vi.fn( importIdentityPublicKey: vi.fn(
async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, async () => ({ type: "id-public-imported" }) as unknown as CryptoKey,
), ),
computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"), computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"),
computeRawKeyFingerprint: vi.fn(async () => "5E55 1234 5678 9ABC"),
})); }));
vi.mock("@lib/identity", () => ({ vi.mock("@lib/identity", () => ({
@@ -66,6 +67,7 @@ vi.mock("@stores/voice.store", () => ({
setPeerVerification: vi.fn(), setPeerVerification: vi.fn(),
clearPeerVerification: vi.fn(), clearPeerVerification: vi.fn(),
clearPeerVerifications: vi.fn(), clearPeerVerifications: vi.fn(),
setLocalSessionFingerprint: vi.fn(),
})); }));
vi.mock("@lib/logger", () => ({ vi.mock("@lib/logger", () => ({
@@ -79,7 +81,11 @@ vi.mock("@lib/logger", () => ({
// Now import // Now import
import { E2EEManager } from "../../src/lib/livekitE2EE"; import { E2EEManager } from "../../src/lib/livekitE2EE";
import { setPeerVerification, clearPeerVerification } from "@stores/voice.store"; import {
setPeerVerification,
clearPeerVerification,
setLocalSessionFingerprint,
} from "@stores/voice.store";
import { import {
unwrapRoomKey, unwrapRoomKey,
roomKeyToBase64, roomKeyToBase64,
@@ -229,12 +235,12 @@ describe("E2EEManager", () => {
// ordering guarantee); the second resolves immediately. Delivery order // ordering guarantee); the second resolves immediately. Delivery order
// must still win — otherwise the receiver ends on the first (dead) key. // must still win — otherwise the receiver ends on the first (dead) key.
let releaseFirst!: () => void; let releaseFirst!: () => void;
const firstUnwrap = new Promise<Uint8Array>((resolve) => { const firstUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => {
releaseFirst = () => resolve(new Uint8Array(32).fill(1)); releaseFirst = () => resolve({ roomKey: new Uint8Array(32).fill(1), epoch: 0 });
}); });
vi.mocked(unwrapRoomKey) vi.mocked(unwrapRoomKey)
.mockReturnValueOnce(firstUnwrap) .mockReturnValueOnce(firstUnwrap)
.mockResolvedValueOnce(new Uint8Array(32).fill(2)); .mockResolvedValueOnce({ roomKey: new Uint8Array(32).fill(2), epoch: 0 });
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`); vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
try { try {
const first = mgr.handleOffer(PEER_ID, "enc1", "iv1"); const first = mgr.handleOffer(PEER_ID, "enc1", "iv1");
@@ -384,8 +390,8 @@ describe("E2EEManager", () => {
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
// This offer's unwrap stalls — still in flight when the user leaves voice. // This offer's unwrap stalls — still in flight when the user leaves voice.
let releaseUnwrap!: (v: Uint8Array) => void; let releaseUnwrap!: (v: { roomKey: Uint8Array; epoch: number | null }) => void;
const stalledUnwrap = new Promise<Uint8Array>((resolve) => { const stalledUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => {
releaseUnwrap = resolve; releaseUnwrap = resolve;
}); });
vi.mocked(unwrapRoomKey).mockReturnValueOnce(stalledUnwrap); vi.mocked(unwrapRoomKey).mockReturnValueOnce(stalledUnwrap);
@@ -408,7 +414,7 @@ describe("E2EEManager", () => {
mockSetKey.mockClear(); mockSetKey.mockClear();
// The stale session-1 offer now resolves. // The stale session-1 offer now resolves.
releaseUnwrap(new Uint8Array(32).fill(9)); releaseUnwrap({ roomKey: new Uint8Array(32).fill(9), epoch: 0 });
await offerPromise; await offerPromise;
// Must be discarded: epoch alone (0 === 0) would have let it through. // Must be discarded: epoch alone (0 === 0) would have let it through.
@@ -1293,4 +1299,157 @@ describe("E2EEManager", () => {
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA=="); vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
} }
}); });
// ── OC-0001: per-sender offer epoch high-water mark ───────────────────────
const unwrapAt = (epoch: number | null, fill = 1) =>
vi.mocked(unwrapRoomKey).mockResolvedValueOnce({
roomKey: new Uint8Array(32).fill(fill),
epoch,
});
async function holderWithPeer(): Promise<{
ws: { send: ReturnType<typeof vi.fn> };
mgr: E2EEManager;
}> {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
mockSetKey.mockClear();
vi.mocked(roomKeyToBase64).mockImplementation((k: Uint8Array) => `key-${k[0]}`);
return { ws, mgr };
}
it("[OC-0001] wraps each offer with the sender's current epoch", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(wrapRoomKey).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
mgr.epoch,
);
await mgr.rotateKeyPeriodically();
expect(wrapRoomKey).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
mgr.epoch,
);
});
it("[OC-0001] discards an offer carrying an older epoch than one already applied from that sender", async () => {
const { mgr } = await holderWithPeer();
try {
unwrapAt(3, 3);
await mgr.handleOffer(PEER_ID, "enc3", "iv3");
expect(mockSetKey).toHaveBeenLastCalledWith("key-3");
unwrapAt(2, 2);
await mgr.handleOffer(PEER_ID, "enc2", "iv2");
expect(mockSetKey).toHaveBeenLastCalledWith("key-3");
expect(mockSetKey).toHaveBeenCalledTimes(1);
} finally {
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
}
});
it("[OC-0001] applies an offer at the same epoch as the last applied one (holder re-sends the current key)", async () => {
const { mgr } = await holderWithPeer();
try {
unwrapAt(3, 3);
await mgr.handleOffer(PEER_ID, "enc3", "iv3");
unwrapAt(3, 4);
await mgr.handleOffer(PEER_ID, "enc3b", "iv3b");
expect(mockSetKey).toHaveBeenLastCalledWith("key-4");
} finally {
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
}
});
it("[OC-0001] resets the sender's high-water mark when they announce a fresh ephemeral key", async () => {
const { mgr } = await holderWithPeer();
vi.mocked(importPublicKey).mockImplementation(
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
(key as unknown as { type: string }).type.replace("peer-key-", ""),
);
try {
unwrapAt(5, 5);
await mgr.handleOffer(PEER_ID, "enc5", "iv5");
expect(mockSetKey).toHaveBeenLastCalledWith("key-5");
// Peer rejoined: new ephemeral key, new (local) epoch counter from 1.
await mgr.handleAnnounce(PEER_ID, "bmV3", "sigB");
unwrapAt(1, 1);
await mgr.handleOffer(PEER_ID, "enc1", "iv1");
expect(mockSetKey).toHaveBeenLastCalledWith("key-1");
} finally {
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
vi.mocked(importPublicKey).mockImplementation(
async () => ({ type: "public" }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
}
});
it("[OC-0001] still applies a legacy offer (no epoch) from a holder on the old build", async () => {
const { mgr } = await holderWithPeer();
try {
unwrapAt(null, 7);
await mgr.handleOffer(PEER_ID, "legacy", "iv");
expect(mockSetKey).toHaveBeenLastCalledWith("key-7");
} finally {
vi.mocked(roomKeyToBase64).mockImplementation(() => "mock-room-key-base64");
}
});
// ── OC-0003: per-session fingerprint for every peer ───────────────────────
it("[OC-0003] publishes a session fingerprint of the ephemeral key for a legacy (unverified) peer while safetyNumber stays null", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
mockMembers.set(PEER_ID, { identityPublicKey: null });
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", undefined);
expect(setPeerVerification).toHaveBeenCalledWith({
userId: PEER_ID,
status: "unverified",
safetyNumber: null,
sessionFingerprint: "5E55 1234 5678 9ABC",
});
});
it("[OC-0003] publishes the session fingerprint alongside the safety number for a verified peer", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(setPeerVerification).toHaveBeenCalledWith({
userId: PEER_ID,
status: "verified",
safetyNumber: "AB12 CD34 EF56 7890",
sessionFingerprint: "5E55 1234 5678 9ABC",
});
});
it("[OC-0003] publishes the local session fingerprint on setup and clears it on teardown", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
expect(setLocalSessionFingerprint).toHaveBeenLastCalledWith("5E55 1234 5678 9ABC");
mgr.clearState();
expect(setLocalSessionFingerprint).toHaveBeenLastCalledWith(null);
});
}); });
@@ -118,6 +118,7 @@ vi.mock("@stores/voice.store", () => ({
setPeerVerification: vi.fn(), setPeerVerification: vi.fn(),
clearPeerVerification: vi.fn(), clearPeerVerification: vi.fn(),
clearPeerVerifications: vi.fn(), clearPeerVerifications: vi.fn(),
setLocalSessionFingerprint: vi.fn(),
setEncryptionDegraded: vi.fn(), setEncryptionDegraded: vi.fn(),
})); }));
@@ -173,7 +174,7 @@ vi.mock("@lib/e2eeCrypto", () => ({
generateRoomKey: vi.fn(() => new Uint8Array(32)), generateRoomKey: vi.fn(() => new Uint8Array(32)),
roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), roomKeyToBase64: vi.fn(() => "mock-room-key-base64"),
wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })), wrapRoomKey: vi.fn(async () => ({ encryptedKey: "enc", iv: "iv" })),
unwrapRoomKey: vi.fn(async () => new Uint8Array(32)), unwrapRoomKey: vi.fn(async () => ({ roomKey: new Uint8Array(32), epoch: 0 })),
// F3 TOFU identity signing/verification // F3 TOFU identity signing/verification
signEphemeralKey: vi.fn(async () => "mock-signature"), signEphemeralKey: vi.fn(async () => "mock-signature"),
verifyEphemeralKeySignature: vi.fn(async () => true), verifyEphemeralKeySignature: vi.fn(async () => true),
@@ -181,6 +182,7 @@ vi.mock("@lib/e2eeCrypto", () => ({
async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, async () => ({ type: "id-public-imported" }) as unknown as CryptoKey,
), ),
computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"), computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"),
computeRawKeyFingerprint: vi.fn(async () => "5E55 1234 5678 9ABC"),
})); }));
// F3 TOFU: identity keyring + peer pin store (Tauri-backed; mocked here). // F3 TOFU: identity keyring + peer pin store (Tauri-backed; mocked here).
+16
View File
@@ -1175,6 +1175,22 @@ The key holder wraps the room key for a specific participant:
} }
``` ```
`encrypted_key` is opaque to the server (base64 and length checks only). Its
layout is:
```
0x01 ‖ epoch (u64 big-endian) ‖ AES-GCM ciphertext of the 32-byte room key
```
`epoch` is the holder's key-rotation counter, bound as GCM additional data so
the relay cannot change it without failing authentication. A receiver applies
an offer only if its epoch is at least the highest it has already applied from
that sender (equal is allowed: the holder re-sends the current key when a peer
re-announces); the mark resets when the sender announces a new ephemeral key.
A blob with no header (exactly 48 bytes: key + GCM tag) is the pre-epoch
format and is still accepted from holders on an older build; that
compatibility path is scheduled for removal in the next release.
### voice_e2ee_offer (Server -> Client, relay to target) ### voice_e2ee_offer (Server -> Client, relay to target)
Delivered only to `target_user_id`, with the sender attached: Delivered only to `target_user_id`, with the sender attached: