diff --git a/.superpowers/FINDINGS.md b/.superpowers/FINDINGS.md index 937bd386..fdd1fb47 100644 --- a/.superpowers/FINDINGS.md +++ b/.superpowers/FINDINGS.md @@ -2,10 +2,22 @@ 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 +### 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 `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 +### 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 `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 -### 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 `Server/service/message_crud.go:346` · found 2026-08-12 · hunt `general-2026-08-12` · lens `hotspot-server-service` diff --git a/.superpowers/findings-ledger.json b/.superpowers/findings-ledger.json index f53d7a24..664e8335 100644 --- a/.superpowers/findings-ledger.json +++ b/.superpowers/findings-ledger.json @@ -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.", "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", - "status": "declined", + "status": "fixed", "found": "2026-08-09", "hunt": "voice-e2ee-2026-08-09", "lens": "crypto-primitives", - "fix": null, - "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." + "fix": { + "commit": "84033139", + "test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts", + "revertProof": "pass" + } }, { "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.", "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", - "status": "declined", + "status": "fixed", "found": "2026-08-09", "hunt": "voice-e2ee-2026-08-09", "lens": "tofu-trust-chain", - "fix": null, - "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." + "fix": { + "commit": "bf7612fb", + "test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts", + "revertProof": "pass" + } }, { "id": "OC-0004", diff --git a/Client/tauri-client/src/components/ChannelSidebar.ts b/Client/tauri-client/src/components/ChannelSidebar.ts index f2aefd31..7eeba884 100644 --- a/Client/tauri-client/src/components/ChannelSidebar.ts +++ b/Client/tauri-client/src/components/ChannelSidebar.ts @@ -71,10 +71,16 @@ function verifyPresentation(v: PeerVerification): { }; } // "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 { icon: "shield", 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); } + // 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 // announce resolves; the local user is never in peerVerifications. const verification = getPeerVerification(user.userId); @@ -484,7 +503,6 @@ function renderVoiceChannelItem( } // Right-click for per-user volume (skip for own user) - const currentUser = getCurrentUser(); if (currentUser === null || currentUser.id !== user.userId) { row.addEventListener( "contextmenu", @@ -915,7 +933,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC // kills hover) and never pays a per-user querySelector. const unsubVoiceStructure = voiceStore.subscribeSelector( (state) => { - let structSig = String(state.currentChannelId ?? ""); + let structSig = `${state.currentChannelId ?? ""}#${state.localSessionFingerprint ?? ""}`; for (const [chId, users] of state.voiceUsers) { structSig += `|${chId}`; for (const [uid, u] of users) { diff --git a/Client/tauri-client/src/lib/e2eeCrypto.ts b/Client/tauri-client/src/lib/e2eeCrypto.ts index 15a32dd0..cf274d67 100644 --- a/Client/tauri-client/src/lib/e2eeCrypto.ts +++ b/Client/tauri-client/src/lib/e2eeCrypto.ts @@ -77,7 +77,13 @@ export async function importPublicKey(base64: string): Promise { */ export async function computeKeyFingerprint(publicKey: CryptoKey): Promise { 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 { + const hash = await crypto.subtle.digest("SHA-256", raw as Uint8Array); const hex = Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) .join(""); @@ -204,51 +210,95 @@ export function roomKeyToBase64(key: Uint8Array): string { // ── 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 { + 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. * * 1. ECDH(myPrivate, peerPublic) → raw shared secret * 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( myPrivateKey: CryptoKey, peerPublicKey: CryptoKey, roomKey: Uint8Array, + epoch: number, ): Promise<{ encryptedKey: string; iv: string }> { + const header = encodeOfferEpoch(epoch); const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit GCM nonce const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv }, + { name: "AES-GCM", iv, additionalData: header }, wrapKey, roomKey as Uint8Array, ); - return { - encryptedKey: uint8ToBase64(new Uint8Array(ciphertext)), - iv: uint8ToBase64(iv), - }; + const blob = new Uint8Array(OFFER_HEADER_BYTES + ciphertext.byteLength); + blob.set(header, 0); + blob.set(new Uint8Array(ciphertext), OFFER_HEADER_BYTES); + return { encryptedKey: uint8ToBase64(blob), iv: uint8ToBase64(iv) }; } /** * Unwrap (decrypt) a room key received from a peer. * * 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( myPrivateKey: CryptoKey, peerPublicKey: CryptoKey, encryptedKeyBase64: string, ivBase64: string, -): Promise { +): Promise<{ roomKey: Uint8Array; epoch: number | null }> { const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey); 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 ──────────────────────────────────────────────────────── diff --git a/Client/tauri-client/src/lib/livekitE2EE.ts b/Client/tauri-client/src/lib/livekitE2EE.ts index 419daa9c..be66947d 100644 --- a/Client/tauri-client/src/lib/livekitE2EE.ts +++ b/Client/tauri-client/src/lib/livekitE2EE.ts @@ -16,6 +16,7 @@ import { verifyEphemeralKeySignature, importIdentityPublicKey, computeKeyFingerprint, + computeRawKeyFingerprint, } from "@lib/e2eeCrypto"; import { getOrCreateIdentityKeyPair, getIdentityPin, storeIdentityPin } from "@lib/identity"; import { authStore } from "@stores/auth.store"; @@ -25,6 +26,7 @@ import { setPeerVerification, clearPeerVerification, clearPeerVerifications, + setLocalSessionFingerprint, } from "@stores/voice.store"; 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 * current live key (OC-0011). */ private _retiredPeerKeys: Map> = 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 = new Map(); /** 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. */ private _identityKeyPair: CryptoKeyPair | null = null; @@ -165,8 +172,10 @@ export class E2EEManager { } this._peerPublicKeys.clear(); this._retiredPeerKeys.clear(); + this._peerOfferEpochs.clear(); clearPeerVerifications(); 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 // the keyring once, so the added identity round-trip does NOT stack on // 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 // processed with no offer sent) and gets its offer sent below. this._ecdhKeyPair = ecdhKeyPair; + setLocalSessionFingerprint(myFingerprint); if (this._isKeyHolder) { // Announce our (signed) key BEFORE draining queued announces. The @@ -348,6 +358,12 @@ export class E2EEManager { await this.keyProvider.setKey(roomKeyToBase64(this._roomKey)); } 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); // Re-check ownership right before the send too: buildAnnouncePayload can // itself await a keyring round trip, another window for clearState() (or @@ -506,6 +522,7 @@ export class E2EEManager { userId, status: "unknown", safetyNumber: null, + sessionFingerprint: null, }); log.error("E2EE: identity pin store unreadable — rejecting announce (fail closed)", { userId, @@ -522,6 +539,7 @@ export class E2EEManager { userId, status: "mismatch", safetyNumber: null, + sessionFingerprint: null, }); log.error("E2EE: pinned peer identity key missing/changed — blocking (identity-tofu)", { userId, @@ -529,6 +547,11 @@ export class E2EEManager { 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 // but mark unverified (pin-pending). This is the only case the compatibility // posture keeps open. @@ -537,6 +560,7 @@ export class E2EEManager { userId, status: "unverified", safetyNumber: null, + sessionFingerprint, }); log.warn("E2EE: peer has no identity key — accepting as unverified (legacy)", { userId }); return true; @@ -556,6 +580,7 @@ export class E2EEManager { userId, status: "mismatch", safetyNumber: null, + sessionFingerprint: null, }); log.error("E2EE: peer announce signature invalid — rejecting (MITM?)", { userId }); return false; @@ -585,11 +610,17 @@ export class E2EEManager { userId, status: "unverified", safetyNumber: null, + sessionFingerprint, }); return true; // still accept the announce — the write failure alone shouldn't block the call } const safetyNumber = await computeKeyFingerprint(identityKey); - this.setPeerVerificationIfCurrent(myGeneration, { userId, status: "verified", safetyNumber }); + this.setPeerVerificationIfCurrent(myGeneration, { + userId, + status: "verified", + safetyNumber, + sessionFingerprint, + }); return true; } @@ -775,6 +806,7 @@ export class E2EEManager { } if (!isDuplicate) { this._peerPublicKeys.set(userId, peerKey); + this._peerOfferEpochs.delete(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 // strictly-ordered _offerChain ends up on the dead key. 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 // wrap) OR the keypair no longer matches (a concurrent // 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. const epochBefore = this._e2eeEpoch; - const unwrapped = await unwrapRoomKey( + const { roomKey: unwrapped, epoch } = await unwrapRoomKey( keypair.privateKey, peerKey, encryptedKeyBase64, @@ -906,9 +943,34 @@ export class E2EEManager { 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; 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 // window up to unwrap, not this call. A teardown-and-rejoin-as-holder @@ -1059,7 +1121,12 @@ export class E2EEManager { }); 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) { log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", { peerId, @@ -1131,6 +1198,7 @@ export class E2EEManager { const departingKey = this._peerPublicKeys.get(userId); const hadPeerKey = departingKey !== undefined; this._peerPublicKeys.delete(userId); + this._peerOfferEpochs.delete(userId); clearPeerVerification(userId); // Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only // defense against replay of a validly-signed announce (the signed @@ -1347,7 +1415,9 @@ export class E2EEManager { this._roomKey = null; this._peerPublicKeys.clear(); this._retiredPeerKeys.clear(); + this._peerOfferEpochs.clear(); clearPeerVerifications(); + setLocalSessionFingerprint(null); this._isKeyHolder = false; this._rotatingKey = false; this._rotationPending = false; diff --git a/Client/tauri-client/src/stores/voice.store.ts b/Client/tauri-client/src/stores/voice.store.ts index 11db02b8..0bf3b93e 100644 --- a/Client/tauri-client/src/stores/voice.store.ts +++ b/Client/tauri-client/src/stores/voice.store.ts @@ -62,6 +62,11 @@ export interface PeerVerification { /** Safety number (identity-key fingerprint) for out-of-band verification; * null for legacy/unverified/mismatch/unknown peers. */ 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 { @@ -106,6 +111,10 @@ export interface VoiceState { * always sets it; optional only so the many inline VoiceState test fixtures * need not restate it. */ readonly peerVerifications?: ReadonlyMap; + /** 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 = { @@ -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). */ export function clearPeerVerifications(): void { voiceStore.setState((prev) => diff --git a/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts index fd8228f0..7b050a0f 100644 --- a/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts +++ b/Client/tauri-client/tests/e2e/voice-e2ee-verify.spec.ts @@ -231,9 +231,12 @@ test.describe("Voice E2EE identity verification (§7)", () => { const badge = peerBadge(page); await expect(badge).toBeVisible({ timeout: 10_000 }); 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( "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); }); diff --git a/Client/tauri-client/tests/unit/channel-sidebar.test.ts b/Client/tauri-client/tests/unit/channel-sidebar.test.ts index 9dee8d91..4811e8d4 100644 --- a/Client/tauri-client/tests/unit/channel-sidebar.test.ts +++ b/Client/tauri-client/tests/unit/channel-sidebar.test.ts @@ -113,10 +113,11 @@ function setPeerVerif( userId: number, status: PeerVerification["status"], safetyNumber: string | null = null, + sessionFingerprint: string | null = null, ): void { voiceStore.setState((prev) => { const peerVerifications = new Map(prev.peerVerifications ?? []); - peerVerifications.set(userId, { userId, status, safetyNumber }); + peerVerifications.set(userId, { userId, status, safetyNumber, sessionFingerprint }); return { ...prev, peerVerifications }; }); } @@ -1990,6 +1991,33 @@ describe("ChannelSidebar voice identity badge", () => { 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", () => { addVoiceUser(VOICE_CH, 10, "Alice"); setPeerVerif(10, "mismatch", null); diff --git a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts index 68e75804..5a7674bd 100644 --- a/Client/tauri-client/tests/unit/e2eeCrypto.test.ts +++ b/Client/tauri-client/tests/unit/e2eeCrypto.test.ts @@ -7,6 +7,7 @@ import { wrapRoomKey, unwrapRoomKey, computeKeyFingerprint, + computeRawKeyFingerprint, roomKeyToBase64, generateIdentityKeyPair, signEphemeralKey, @@ -29,10 +30,16 @@ describe("e2eeCrypto", () => { const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); - const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); - const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); + const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1); + const { roomKey: unwrapped, epoch } = await unwrapRoomKey( + bob.privateKey, + alice.publicKey, + encryptedKey, + iv, + ); expect(unwrapped).toEqual(roomKey); + expect(epoch).toBe(1); }); it("produces a different ciphertext each call (fresh IV)", async () => { @@ -40,8 +47,8 @@ describe("e2eeCrypto", () => { const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); - const first = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey); - const second = 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, 1); // The IVs should differ, making ciphertexts distinct expect(first.iv).not.toBe(second.iv); @@ -56,17 +63,124 @@ describe("e2eeCrypto", () => { const bob = await generateECDHKeyPair(); 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)); - bytes[0] = bytes[0]! ^ 0xff; + bytes[9] = bytes[9]! ^ 0xff; const tampered = btoa(String.fromCharCode(...bytes)); 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, + ); + + 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 ───────────────────────────────────────────────── describe("computeKeyFingerprint", () => { @@ -89,6 +203,13 @@ describe("e2eeCrypto", () => { 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 () => { const { publicKey } = await generateECDHKeyPair(); const fp = await computeKeyFingerprint(publicKey); @@ -117,8 +238,13 @@ describe("e2eeCrypto", () => { // The reimported key must be usable in a wrap/unwrap cycle const roomKey = generateRoomKey(); - const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, reimported, roomKey); - const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); + const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, reimported, roomKey, 1); + const { roomKey: unwrapped } = await unwrapRoomKey( + bob.privateKey, + alice.publicKey, + encryptedKey, + iv, + ); expect(unwrapped).toEqual(roomKey); }); }); diff --git a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts index c59d0882..17130c73 100644 --- a/Client/tauri-client/tests/unit/livekit-e2ee.test.ts +++ b/Client/tauri-client/tests/unit/livekit-e2ee.test.ts @@ -32,13 +32,14 @@ vi.mock("@lib/e2eeCrypto", () => ({ generateRoomKey: vi.fn(() => new Uint8Array(32)), roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), 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"), verifyEphemeralKeySignature: vi.fn(async () => true), importIdentityPublicKey: vi.fn( async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, ), computeKeyFingerprint: vi.fn(async () => "AB12 CD34 EF56 7890"), + computeRawKeyFingerprint: vi.fn(async () => "5E55 1234 5678 9ABC"), })); vi.mock("@lib/identity", () => ({ @@ -66,6 +67,7 @@ vi.mock("@stores/voice.store", () => ({ setPeerVerification: vi.fn(), clearPeerVerification: vi.fn(), clearPeerVerifications: vi.fn(), + setLocalSessionFingerprint: vi.fn(), })); vi.mock("@lib/logger", () => ({ @@ -79,7 +81,11 @@ vi.mock("@lib/logger", () => ({ // Now import import { E2EEManager } from "../../src/lib/livekitE2EE"; -import { setPeerVerification, clearPeerVerification } from "@stores/voice.store"; +import { + setPeerVerification, + clearPeerVerification, + setLocalSessionFingerprint, +} from "@stores/voice.store"; import { unwrapRoomKey, roomKeyToBase64, @@ -229,12 +235,12 @@ describe("E2EEManager", () => { // ordering guarantee); the second resolves immediately. Delivery order // must still win — otherwise the receiver ends on the first (dead) key. let releaseFirst!: () => void; - const firstUnwrap = new Promise((resolve) => { - releaseFirst = () => resolve(new Uint8Array(32).fill(1)); + const firstUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => { + releaseFirst = () => resolve({ roomKey: new Uint8Array(32).fill(1), epoch: 0 }); }); vi.mocked(unwrapRoomKey) .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]}`); try { const first = mgr.handleOffer(PEER_ID, "enc1", "iv1"); @@ -384,8 +390,8 @@ describe("E2EEManager", () => { await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig"); // This offer's unwrap stalls — still in flight when the user leaves voice. - let releaseUnwrap!: (v: Uint8Array) => void; - const stalledUnwrap = new Promise((resolve) => { + let releaseUnwrap!: (v: { roomKey: Uint8Array; epoch: number | null }) => void; + const stalledUnwrap = new Promise<{ roomKey: Uint8Array; epoch: number | null }>((resolve) => { releaseUnwrap = resolve; }); vi.mocked(unwrapRoomKey).mockReturnValueOnce(stalledUnwrap); @@ -408,7 +414,7 @@ describe("E2EEManager", () => { mockSetKey.mockClear(); // The stale session-1 offer now resolves. - releaseUnwrap(new Uint8Array(32).fill(9)); + releaseUnwrap({ roomKey: new Uint8Array(32).fill(9), epoch: 0 }); await offerPromise; // Must be discarded: epoch alone (0 === 0) would have let it through. @@ -1293,4 +1299,157 @@ describe("E2EEManager", () => { 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 }; + 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); + }); }); diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 396070ec..8119f54f 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -118,6 +118,7 @@ vi.mock("@stores/voice.store", () => ({ setPeerVerification: vi.fn(), clearPeerVerification: vi.fn(), clearPeerVerifications: vi.fn(), + setLocalSessionFingerprint: vi.fn(), setEncryptionDegraded: vi.fn(), })); @@ -173,7 +174,7 @@ vi.mock("@lib/e2eeCrypto", () => ({ generateRoomKey: vi.fn(() => new Uint8Array(32)), roomKeyToBase64: vi.fn(() => "mock-room-key-base64"), 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 signEphemeralKey: vi.fn(async () => "mock-signature"), verifyEphemeralKeySignature: vi.fn(async () => true), @@ -181,6 +182,7 @@ vi.mock("@lib/e2eeCrypto", () => ({ async () => ({ type: "id-public-imported" }) as unknown as CryptoKey, ), 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). diff --git a/docs/protocol.md b/docs/protocol.md index 897ef1b1..1d3be620 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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) Delivered only to `target_user_id`, with the sender attached: