fix: remaining E2EE hardening — rotation, retry, fingerprint, validation

Periodic key rotation:
- Key holder rotates room key every 5 minutes for forward secrecy,
  independent of participant changes. Timer managed by key holder only.

Offer retry mechanism:
- Non-key-holders re-announce their public key after 10s timeout and
  wait 5s more before giving up. Covers lost offers from target
  disconnect during async key wrapping.
- Key holder now re-sends room key offer on duplicate announces (peer
  may be re-requesting after a missed offer), instead of ignoring them.

Key fingerprint verification:
- New computeKeyFingerprint() in e2eeCrypto.ts — SHA-256 hash of raw
  public key formatted as "AB12 CD34 ..." for out-of-band verification.
  Can be displayed in UI for MITM detection.

Server hardening:
- Public key size limit tightened from 256 to 128 bytes (P-256
  uncompressed = 65 bytes = ~88 base64 chars).

Client hardening:
- WebCrypto availability check at module load — throws descriptive
  error if crypto.subtle is unavailable (non-HTTPS context).
- base64ToUint8() now wraps atob() in try-catch with clear error message.

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
This commit is contained in:
Claude
2026-04-04 19:43:17 +00:00
parent 277c2d3e76
commit 774a7bcce9
3 changed files with 148 additions and 20 deletions
+30 -1
View File
@@ -16,6 +16,13 @@
import { log } from "@lib/logger";
// ── WebCrypto availability check ───────────────────────────────────────────
if (typeof crypto === "undefined" || !crypto.subtle) {
throw new Error(
"E2EE requires WebCrypto (crypto.subtle). Ensure the app is served over HTTPS or a secure context.",
);
}
const ECDH_CURVE = "P-256";
const HKDF_SALT = new TextEncoder().encode("owncord-voice-e2ee-v1");
const HKDF_INFO = new TextEncoder().encode("room-key-wrap");
@@ -42,6 +49,23 @@ export async function importPublicKey(base64: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", raw, { name: "ECDH", namedCurve: ECDH_CURVE }, true, []);
}
// ── Key fingerprint (for out-of-band verification) ─────────────────────────
/**
* Compute a human-readable fingerprint of a public key for out-of-band
* verification (safety numbers). Returns a hex string of the SHA-256 hash
* of the raw key bytes, formatted as "AB12 CD34 …" groups.
*/
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);
const hex = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
// Format as 8 groups of 4 hex chars: "AB12 CD34 EF56 ..."
return hex.match(/.{1,4}/g)!.slice(0, 8).join(" ");
}
// ── Room key generation ─────────────────────────────────────────────────────
/** Generate a random 256-bit room key. */
@@ -154,7 +178,12 @@ function uint8ToBase64(bytes: Uint8Array): string {
}
function base64ToUint8(base64: string): Uint8Array {
const binary = atob(base64);
let binary: string;
try {
binary = atob(base64);
} catch {
throw new Error("E2EE: invalid base64 input");
}
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
+115 -16
View File
@@ -156,6 +156,10 @@ export class LiveKitSession {
private _e2eeEpoch = 0;
/** Announces that arrived before our ECDH keypair was ready. Drained after keypair init. */
private _pendingAnnounces: Array<{ userId: number; publicKeyBase64: string }> = [];
/** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
/** Interval between periodic key rotations (5 minutes). */
private static readonly KEY_ROTATION_INTERVAL_MS = 5 * 60 * 1000;
// --- State transition (single writer) ---
@@ -856,6 +860,7 @@ export class LiveKitSession {
this._roomKey = generateRoomKey();
await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: key holder — generated room key", { channelId });
this.startKeyRotationTimer();
} else {
// Wait for the key holder to send us the room key via voice_e2ee_offer.
// This promise resolves when handleE2EEOffer() sets _roomKey.
@@ -864,19 +869,41 @@ export class LiveKitSession {
this._roomKeyResolver = resolve;
this._roomKeyRejector = reject;
});
// Don't block forever — timeout after 10 seconds.
// Wait up to 10s for the key holder to send an offer. If the first
// attempt times out, re-announce our public key (the offer may have been
// lost if the key holder disconnected mid-send) and wait 5s more.
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), 10_000);
});
const makeTimeout = (ms: number) =>
new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), ms);
});
let keyReceived = false;
try {
await Promise.race([roomKeyPromise, timeout]);
await Promise.race([roomKeyPromise, makeTimeout(10_000)]);
keyReceived = true;
} catch {
log.warn("E2EE: key exchange timed out, proceeding without E2EE", { channelId });
this.onErrorCallback?.("End-to-end encryption unavailable — key exchange timed out");
// First attempt timed out — re-announce and retry once.
if (timeoutId !== null) clearTimeout(timeoutId);
log.warn("E2EE: first key exchange attempt timed out, re-announcing", { channelId });
this.ws?.send({
type: "voice_e2ee_announce",
payload: { public_key: myPubKeyBase64 },
});
try {
await Promise.race([roomKeyPromise, makeTimeout(5_000)]);
keyReceived = true;
} catch {
log.warn("E2EE: key exchange timed out after retry, proceeding without E2EE", {
channelId,
});
this.onErrorCallback?.("End-to-end encryption unavailable — key exchange timed out");
}
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
if (!keyReceived) {
log.error("E2EE: failed to receive room key", { channelId });
}
this._roomKeyResolver = null;
this._roomKeyRejector = null;
}
@@ -1133,21 +1160,29 @@ export class LiveKitSession {
return;
}
try {
// Deduplicate: if we already have a key for this user with the same base64,
// skip the import. If the key changed, log a warning (could be a reconnect
// or a protocol violation).
// Deduplicate: if the key is identical, skip the import but still
// re-send the room key offer (the peer may be re-requesting after a
// missed offer or reconnect).
const existingKey = this._peerPublicKeys.get(userId);
const peerKey = await importPublicKey(publicKeyBase64);
let peerKey: CryptoKey;
let isDuplicate = false;
if (existingKey) {
const existingB64 = await exportPublicKey(existingKey);
if (existingB64 === publicKeyBase64) {
log.debug("E2EE: duplicate announce from same key, ignoring", { userId });
return;
peerKey = existingKey;
isDuplicate = true;
log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
} else {
peerKey = await importPublicKey(publicKeyBase64);
log.warn("E2EE: peer public key changed (reconnect?)", { userId });
}
log.warn("E2EE: peer public key changed (reconnect or key rotation?)", { userId });
} else {
peerKey = await importPublicKey(publicKeyBase64);
}
if (!isDuplicate) {
this._peerPublicKeys.set(userId, peerKey);
log.info("E2EE: received peer public key", { userId });
}
this._peerPublicKeys.set(userId, peerKey);
log.info("E2EE: received peer public key", { userId });
// If we're the key holder and have a room key, wrap it for the new peer.
// Capture keypair + roomKey before async work to avoid null dereference if
@@ -1321,10 +1356,73 @@ export class LiveKitSession {
log.error("E2EE: failed to rotate room key", err);
} finally {
this._rotatingKey = false;
this.startKeyRotationTimer();
}
}
}
// ── Periodic key rotation ──────────────────────────────────────────────────
/** Start the periodic key rotation timer (only meaningful for key holders). */
private startKeyRotationTimer(): void {
this.clearKeyRotationTimer();
if (!this._isKeyHolder) return;
this._keyRotationTimer = setTimeout(() => {
this._keyRotationTimer = null;
this.rotateKeyPeriodically();
}, LiveKitSession.KEY_ROTATION_INTERVAL_MS);
log.debug("E2EE: key rotation timer started", {
intervalMs: LiveKitSession.KEY_ROTATION_INTERVAL_MS,
});
}
private clearKeyRotationTimer(): void {
if (this._keyRotationTimer !== null) {
clearTimeout(this._keyRotationTimer);
this._keyRotationTimer = null;
}
}
/** Rotate the room key on a timer tick (forward secrecy improvement). */
private async rotateKeyPeriodically(): Promise<void> {
if (!this._isKeyHolder || this._rotatingKey) return;
const channelId = this._currentChannelId;
if (!channelId) return;
this._rotatingKey = true;
try {
this._e2eeEpoch++;
this._roomKey = generateRoomKey();
await this._e2eeKeyProvider.setKey(roomKeyToBase64(this._roomKey));
log.info("E2EE: periodic key rotation", { channelId, epoch: this._e2eeEpoch });
const keypair = this._ecdhKeyPair;
if (keypair && this._roomKey) {
for (const [peerId, peerKey] of this._peerPublicKeys) {
const { encryptedKey, iv } = await wrapRoomKey(
keypair.privateKey,
peerKey,
this._roomKey,
);
this.ws?.send({
type: "voice_e2ee_offer",
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
});
}
log.info("E2EE: distributed periodically rotated key", {
peerCount: this._peerPublicKeys.size,
});
}
} catch (err) {
log.error("E2EE: periodic key rotation failed", err);
} finally {
this._rotatingKey = false;
}
// Re-arm the timer for the next rotation.
this.startKeyRotationTimer();
}
/** Clear all E2EE state (called on voice leave). */
private clearE2EEState(): void {
this._ecdhKeyPair = null;
@@ -1334,6 +1432,7 @@ export class LiveKitSession {
this._rotatingKey = false;
this._e2eeEpoch = 0;
this._pendingAnnounces.length = 0;
this.clearKeyRotationTimer();
// Reject (not resolve) so waiting connectAndSetup sees a failure, not a
// silent success with no room key.
if (this._roomKeyRejector) {
+3 -3
View File
@@ -27,9 +27,9 @@ func (h *Hub) handleVoiceE2EEAnnounce(_ context.Context, c *Client, payload json
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key is required"))
return
}
// Sanity check: base64-encoded P-256 public key is ~88 chars (uncompressed)
// or ~44 chars (compressed). Allow up to 256 chars to be safe.
if len(p.PublicKey) > 256 {
// P-256 uncompressed public key = 65 bytes → 88 base64 chars.
// Allow up to 128 chars for padding tolerance.
if len(p.PublicKey) > 128 {
c.sendMsg(buildErrorMsg(ErrCodeBadPayload, "public_key too large"))
return
}