diff --git a/Client/tauri-client/src/lib/e2eeCrypto.ts b/Client/tauri-client/src/lib/e2eeCrypto.ts index b912b6bf..b9a8627b 100644 --- a/Client/tauri-client/src/lib/e2eeCrypto.ts +++ b/Client/tauri-client/src/lib/e2eeCrypto.ts @@ -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 { 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 { + 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); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 2482ea10..621b2cd5 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -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 | 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 | null = null; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error("E2EE key exchange timeout")), 10_000); - }); + const makeTimeout = (ms: number) => + new Promise((_, 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 { + 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) { diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 0dde9dab..e48411cd 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -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 }