mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import {
|
|
generateECDHKeyPair,
|
|
exportPublicKey,
|
|
importPublicKey,
|
|
generateRoomKey,
|
|
wrapRoomKey,
|
|
unwrapRoomKey,
|
|
computeKeyFingerprint,
|
|
computeRawKeyFingerprint,
|
|
roomKeyToBase64,
|
|
generateIdentityKeyPair,
|
|
signEphemeralKey,
|
|
verifyEphemeralKeySignature,
|
|
importIdentityPublicKey,
|
|
exportIdentityKeyPair,
|
|
importIdentityKeyPair,
|
|
} from "@lib/e2eeCrypto";
|
|
|
|
vi.mock("@lib/logger", () => ({
|
|
createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
}));
|
|
|
|
describe("e2eeCrypto", () => {
|
|
// ── wrap / unwrap round-trip ───────────────────────────────────────────────
|
|
|
|
describe("wrapRoomKey / unwrapRoomKey", () => {
|
|
it("round-trips a room key between two keypairs", async () => {
|
|
const alice = await generateECDHKeyPair();
|
|
const bob = await generateECDHKeyPair();
|
|
const roomKey = generateRoomKey();
|
|
|
|
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 () => {
|
|
const alice = await generateECDHKeyPair();
|
|
const bob = await generateECDHKeyPair();
|
|
const roomKey = generateRoomKey();
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── tampered ciphertext rejected ──────────────────────────────────────────
|
|
|
|
describe("unwrapRoomKey", () => {
|
|
it("throws when the ciphertext has been tampered", async () => {
|
|
const alice = await generateECDHKeyPair();
|
|
const bob = await generateECDHKeyPair();
|
|
const roomKey = generateRoomKey();
|
|
|
|
const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1);
|
|
|
|
// Decode, flip the first ciphertext byte (after the 9-byte header), re-encode
|
|
const bytes = Uint8Array.from(atob(encryptedKey), (c) => c.charCodeAt(0));
|
|
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", () => {
|
|
it("returns the same fingerprint for the same public key", async () => {
|
|
const { publicKey } = await generateECDHKeyPair();
|
|
|
|
const fp1 = await computeKeyFingerprint(publicKey);
|
|
const fp2 = await computeKeyFingerprint(publicKey);
|
|
|
|
expect(fp1).toBe(fp2);
|
|
});
|
|
|
|
it("returns different fingerprints for different keys", async () => {
|
|
const { publicKey: keyA } = await generateECDHKeyPair();
|
|
const { publicKey: keyB } = await generateECDHKeyPair();
|
|
|
|
const fpA = await computeKeyFingerprint(keyA);
|
|
const fpB = await computeKeyFingerprint(keyB);
|
|
|
|
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);
|
|
|
|
expect(fp).toMatch(/^([0-9A-F]{4} ){7}[0-9A-F]{4}$/);
|
|
});
|
|
});
|
|
|
|
// ── exportPublicKey / importPublicKey round-trip ──────────────────────────
|
|
|
|
describe("exportPublicKey / importPublicKey", () => {
|
|
it("round-trips a public key through base64", async () => {
|
|
const alice = await generateECDHKeyPair();
|
|
const bob = await generateECDHKeyPair();
|
|
|
|
// Export Bob's public key and re-import it
|
|
const exported = await exportPublicKey(bob.publicKey);
|
|
expect(() => atob(exported)).not.toThrow();
|
|
|
|
const reimported = await importPublicKey(exported);
|
|
|
|
// The reimported key must produce the same fingerprint as the original
|
|
const fpOriginal = await computeKeyFingerprint(bob.publicKey);
|
|
const fpReimported = await computeKeyFingerprint(reimported);
|
|
expect(fpOriginal).toBe(fpReimported);
|
|
|
|
// The reimported key must be usable in a wrap/unwrap cycle
|
|
const roomKey = generateRoomKey();
|
|
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);
|
|
});
|
|
});
|
|
|
|
// ── generateRoomKey entropy ───────────────────────────────────────────────
|
|
|
|
describe("generateRoomKey", () => {
|
|
it("returns a 32-byte Uint8Array", () => {
|
|
const key = generateRoomKey();
|
|
expect(key).toBeInstanceOf(Uint8Array);
|
|
expect(key.byteLength).toBe(32);
|
|
});
|
|
|
|
it("two calls return different keys", () => {
|
|
const a = generateRoomKey();
|
|
const b = generateRoomKey();
|
|
// Compare as base64 strings for a simple deep-inequality check
|
|
expect(roomKeyToBase64(a)).not.toBe(roomKeyToBase64(b));
|
|
});
|
|
});
|
|
|
|
// ── roomKeyToBase64 ───────────────────────────────────────────────────────
|
|
|
|
describe("roomKeyToBase64", () => {
|
|
it("encodes a room key to a non-empty base64 string", () => {
|
|
const key = generateRoomKey();
|
|
const encoded = roomKeyToBase64(key);
|
|
expect(typeof encoded).toBe("string");
|
|
expect(encoded.length).toBeGreaterThan(0);
|
|
expect(() => atob(encoded)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── Identity sign / verify (F3 TOFU) ───────────────────────────────────────
|
|
|
|
describe("signEphemeralKey / verifyEphemeralKeySignature", () => {
|
|
const userId = 42;
|
|
|
|
async function fixture() {
|
|
const identity = await generateIdentityKeyPair();
|
|
const ephemeral = await generateECDHKeyPair();
|
|
const ephemeralRaw = new Uint8Array(
|
|
await crypto.subtle.exportKey("raw", ephemeral.publicKey),
|
|
);
|
|
const signature = await signEphemeralKey(identity.privateKey, userId, ephemeralRaw);
|
|
return { identity, ephemeralRaw, signature };
|
|
}
|
|
|
|
it("round-trips: a valid signature verifies against the identity public key", async () => {
|
|
const { identity, ephemeralRaw, signature } = await fixture();
|
|
const ok = await verifyEphemeralKeySignature(
|
|
identity.publicKey,
|
|
userId,
|
|
ephemeralRaw,
|
|
signature,
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("verifies against a public key re-imported from its base64 raw form", async () => {
|
|
const { identity, ephemeralRaw, signature } = await fixture();
|
|
const pubBase64 = await exportPublicKey(identity.publicKey);
|
|
const reimported = await importIdentityPublicKey(pubBase64);
|
|
const ok = await verifyEphemeralKeySignature(reimported, userId, ephemeralRaw, signature);
|
|
expect(ok).toBe(true);
|
|
});
|
|
|
|
it("fails when the userId is tampered (server re-attribution)", async () => {
|
|
const { identity, ephemeralRaw, signature } = await fixture();
|
|
const ok = await verifyEphemeralKeySignature(
|
|
identity.publicKey,
|
|
userId + 1,
|
|
ephemeralRaw,
|
|
signature,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("fails when the ephemeral key is substituted (server MITM)", async () => {
|
|
const { identity, signature } = await fixture();
|
|
const other = await generateECDHKeyPair();
|
|
const otherRaw = new Uint8Array(await crypto.subtle.exportKey("raw", other.publicKey));
|
|
const ok = await verifyEphemeralKeySignature(identity.publicKey, userId, otherRaw, signature);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("fails when the signature bytes are tampered", async () => {
|
|
const { identity, ephemeralRaw, signature } = await fixture();
|
|
const bytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
|
|
bytes[0] = bytes[0]! ^ 0xff;
|
|
const tampered = btoa(String.fromCharCode(...bytes));
|
|
const ok = await verifyEphemeralKeySignature(
|
|
identity.publicKey,
|
|
userId,
|
|
ephemeralRaw,
|
|
tampered,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("returns false (not throw) on malformed base64 signature", async () => {
|
|
const { identity, ephemeralRaw } = await fixture();
|
|
const ok = await verifyEphemeralKeySignature(
|
|
identity.publicKey,
|
|
userId,
|
|
ephemeralRaw,
|
|
"not valid base64 !!!",
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
|
|
it("fails against a different identity key (wrong signer)", async () => {
|
|
const { ephemeralRaw, signature } = await fixture();
|
|
const attacker = await generateIdentityKeyPair();
|
|
const ok = await verifyEphemeralKeySignature(
|
|
attacker.publicKey,
|
|
userId,
|
|
ephemeralRaw,
|
|
signature,
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Identity keypair persistence (keyring blob round-trip) ──────────────────
|
|
|
|
describe("exportIdentityKeyPair / importIdentityKeyPair", () => {
|
|
it("round-trips a keypair through the JWK blob and can still sign+verify", async () => {
|
|
const original = await generateIdentityKeyPair();
|
|
const blob = await exportIdentityKeyPair(original.privateKey);
|
|
const restored = await importIdentityKeyPair(blob);
|
|
|
|
const ephemeral = await generateECDHKeyPair();
|
|
const ephemeralRaw = new Uint8Array(
|
|
await crypto.subtle.exportKey("raw", ephemeral.publicKey),
|
|
);
|
|
|
|
// Sign with the restored private key, verify with the restored public key.
|
|
const sig = await signEphemeralKey(restored.privateKey, 7, ephemeralRaw);
|
|
expect(await verifyEphemeralKeySignature(restored.publicKey, 7, ephemeralRaw, sig)).toBe(
|
|
true,
|
|
);
|
|
|
|
// Public key survives the round-trip identically (safety-number stability).
|
|
const fpOriginal = await computeKeyFingerprint(original.publicKey);
|
|
const fpRestored = await computeKeyFingerprint(restored.publicKey);
|
|
expect(fpRestored).toBe(fpOriginal);
|
|
});
|
|
});
|
|
|
|
// ── Identity fingerprint stability (safety number repoint) ──────────────────
|
|
|
|
describe("computeKeyFingerprint on identity keys", () => {
|
|
it("is stable across export/import of the identity public key", async () => {
|
|
const identity = await generateIdentityKeyPair();
|
|
const base64 = await exportPublicKey(identity.publicKey);
|
|
const reimported = await importIdentityPublicKey(base64);
|
|
expect(await computeKeyFingerprint(reimported)).toBe(
|
|
await computeKeyFingerprint(identity.publicKey),
|
|
);
|
|
});
|
|
});
|
|
});
|