mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
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:
Generated
+25
-25
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -77,7 +77,13 @@ export async function importPublicKey(base64: string): Promise<CryptoKey> {
|
||||
*/
|
||||
export async function computeKeyFingerprint(publicKey: CryptoKey): Promise<string> {
|
||||
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))
|
||||
.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<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.
|
||||
*
|
||||
* 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<ArrayBuffer>,
|
||||
);
|
||||
|
||||
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<Uint8Array> {
|
||||
): 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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<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
|
||||
* 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;
|
||||
|
||||
@@ -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<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 = {
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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 ─────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Uint8Array>((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<Uint8Array>((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<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(),
|
||||
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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user