mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user