Files
OwnCord/Client/tauri-client/src/lib/e2eeCrypto.ts
T
J3vbandClaude Fable 5 eacba10cff 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>
2026-08-19 19:04:51 +02:00

359 lines
14 KiB
TypeScript

/**
* e2eeCrypto — Client-side ECDH key exchange and room key wrapping for true
* end-to-end encrypted voice/video.
*
* The server NEVER sees the room key. It only relays:
* 1. ECDH P-256 public keys (useless without the private key)
* 2. AES-GCM encrypted room key blobs (can't decrypt without ECDH shared secret)
*
* Flow:
* - Each participant generates an ephemeral ECDH P-256 keypair on voice join.
* - The key holder (longest-present participant) generates a random 256-bit
* room key and wraps it for each peer using ECDH + HKDF + AES-GCM.
* - Peers unwrap the room key and feed it to LiveKit's ExternalE2EEKeyProvider.
* - When a participant leaves, the key holder rotates the room key.
*/
import { createLogger } from "@lib/logger";
const log = createLogger("e2eeCrypto");
// ── 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";
// UTF-8 bytes of "owncord-voice-e2ee-v1"
const HKDF_SALT = new Uint8Array([
111, 119, 110, 99, 111, 114, 100, 45, 118, 111, 105, 99, 101, 45, 101, 50, 101, 101, 45, 118, 49,
]);
// UTF-8 bytes of "room-key-wrap"
const HKDF_INFO = new Uint8Array([114, 111, 111, 109, 45, 107, 101, 121, 45, 119, 114, 97, 112]);
const ROOM_KEY_BYTES = 32; // 256-bit AES key for LiveKit SFrame
// ── Long-term identity keys (F3: voice E2EE TOFU) ──────────────────────────
// ECDSA P-256 (same curve family as the ECDH exchange; works in all three
// webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps).
const ECDSA_CURVE = "P-256";
// Domain-separation prefix signed with the identity key when announcing an
// ephemeral key: UTF-8 bytes of "owncord-voice-e2ee-announce-v1". Binding the
// prefix + userId stops the server re-attributing a valid announce to a
// different user or reusing the signature in another context.
const ANNOUNCE_DOMAIN = new TextEncoder().encode("owncord-voice-e2ee-announce-v1");
// ── Key pair generation ─────────────────────────────────────────────────────
/** Generate an ephemeral ECDH P-256 keypair. */
export async function generateECDHKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: "ECDH", namedCurve: ECDH_CURVE }, true, ["deriveBits"]);
}
/** Export a CryptoKey (public) to base64 for transmission. */
export async function exportPublicKey(key: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey("raw", key);
return uint8ToBase64(new Uint8Array(raw));
}
/** Import a base64-encoded P-256 public key. */
export async function importPublicKey(base64: string): Promise<CryptoKey> {
const raw = base64ToUint8(base64);
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.
*
* For the F3 safety number, feed the *stable* identity public key (the ECDSA
* key that persists across calls), NOT the per-call ephemeral ECDH key — the
* fingerprint only makes sense out-of-band if it stays constant for a peer.
* The raw-byte hash is algorithm-agnostic, so it works on either key type.
*/
export async function computeKeyFingerprint(publicKey: CryptoKey): Promise<string> {
const raw = await crypto.subtle.exportKey("raw", publicKey);
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("");
// Format as 8 groups of 4 hex chars: "AB12 CD34 EF56 ..."
const groups = hex.match(/.{1,4}/g) ?? [];
return groups.slice(0, 8).join(" ");
}
// ── Identity keypair (sign/verify ephemeral announces) ─────────────────────
/** Generate a long-term ECDSA P-256 identity keypair. */
export async function generateIdentityKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: ECDSA_CURVE }, true, [
"sign",
"verify",
]);
}
/**
* Sign an ephemeral-key announce with the long-term identity private key.
* The signed message is ANNOUNCE_DOMAIN ‖ myUserId ‖ ephemeralPubRaw, so a
* receiver knows this exact ephemeral key was announced by this exact user.
* Returns the base64 signature to carry in the `voice_e2ee_announce` payload.
*/
export async function signEphemeralKey(
identityPrivateKey: CryptoKey,
myUserId: string | number,
ephemeralPubRaw: Uint8Array,
): Promise<string> {
const message = buildAnnounceMessage(myUserId, ephemeralPubRaw);
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
identityPrivateKey,
message,
);
return uint8ToBase64(new Uint8Array(sig));
}
/**
* Verify an ephemeral-key announce against a peer's pinned identity public key.
* Returns false (never throws) on any tamper — bad base64, wrong userId, wrong
* ephemeral key, or wrong/forged signature — so callers can reject a MITM.
*/
export async function verifyEphemeralKeySignature(
identityPublicKey: CryptoKey,
userId: string | number,
ephemeralPubRaw: Uint8Array,
signatureBase64: string,
): Promise<boolean> {
let signature: Uint8Array<ArrayBuffer>;
try {
signature = base64ToUint8(signatureBase64);
} catch {
return false;
}
const message = buildAnnounceMessage(userId, ephemeralPubRaw);
try {
return await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
identityPublicKey,
signature,
message,
);
} catch {
return false;
}
}
/** Import a base64 raw P-256 identity public key for signature verification. */
export async function importIdentityPublicKey(base64: string): Promise<CryptoKey> {
const raw = base64ToUint8(base64);
return crypto.subtle.importKey("raw", raw, { name: "ECDSA", namedCurve: ECDSA_CURVE }, true, [
"verify",
]);
}
/**
* Serialize an identity keypair for OS-keyring storage. Exports the private
* key as JWK (base64-encoded JSON) — the JWK carries both the private scalar
* `d` and the public point `x`/`y`, so both keys are recoverable on load.
*/
export async function exportIdentityKeyPair(privateKey: CryptoKey): Promise<string> {
const jwk = await crypto.subtle.exportKey("jwk", privateKey);
return btoa(JSON.stringify(jwk));
}
/** Inverse of exportIdentityKeyPair: recover both keys from the keyring blob. */
export async function importIdentityKeyPair(blobBase64: string): Promise<CryptoKeyPair> {
const jwk = JSON.parse(atob(blobBase64)) as JsonWebKey;
const alg = { name: "ECDSA", namedCurve: ECDSA_CURVE };
const privateKey = await crypto.subtle.importKey("jwk", jwk, alg, true, ["sign"]);
// Strip the private scalar to import the matching public key.
const pubJwk: JsonWebKey = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
const publicKey = await crypto.subtle.importKey("jwk", pubJwk, alg, true, ["verify"]);
return { privateKey, publicKey };
}
/** Build the byte string signed/verified for an ephemeral-key announce. */
function buildAnnounceMessage(
userId: string | number,
ephemeralPubRaw: Uint8Array,
): Uint8Array<ArrayBuffer> {
const userIdBytes = new TextEncoder().encode(String(userId));
const message = new Uint8Array(
ANNOUNCE_DOMAIN.length + userIdBytes.length + ephemeralPubRaw.length,
);
message.set(ANNOUNCE_DOMAIN, 0);
message.set(userIdBytes, ANNOUNCE_DOMAIN.length);
message.set(ephemeralPubRaw, ANNOUNCE_DOMAIN.length + userIdBytes.length);
return message;
}
// ── Room key generation ─────────────────────────────────────────────────────
/** Generate a random 256-bit room key. */
export function generateRoomKey(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(ROOM_KEY_BYTES));
}
/** Encode a room key as base64 for ExternalE2EEKeyProvider.setKey(). */
export function roomKeyToBase64(key: Uint8Array): string {
return uint8ToBase64(key);
}
// ── 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, 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, additionalData: header },
wrapKey,
roomKey as Uint8Array<ArrayBuffer>,
);
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<{ roomKey: Uint8Array; epoch: number | null }> {
const wrapKey = await deriveWrappingKey(myPrivateKey, peerPublicKey);
const iv = base64ToUint8(ivBase64);
const blob = base64ToUint8(encryptedKeyBase64);
// 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 };
}
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 ────────────────────────────────────────────────────────
/**
* Derive a 256-bit AES-GCM wrapping key from an ECDH shared secret via HKDF.
*/
async function deriveWrappingKey(
myPrivateKey: CryptoKey,
peerPublicKey: CryptoKey,
): Promise<CryptoKey> {
// Step 1: ECDH → raw shared secret bits
const sharedBits = await crypto.subtle.deriveBits(
{ name: "ECDH", public: peerPublicKey },
myPrivateKey,
256,
);
// Step 2: Import shared secret as HKDF key material
const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
// Step 3: HKDF → AES-GCM key
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: HKDF_SALT,
info: HKDF_INFO,
},
hkdfKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// ── Base64 utilities ────────────────────────────────────────────────────────
function uint8ToBase64(bytes: Uint8Array): string {
return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));
}
function base64ToUint8(base64: string): Uint8Array<ArrayBuffer> {
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);
}
return bytes;
}
log.debug("e2eeCrypto module loaded");