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>
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import { test, expect, type Page } from "@playwright/test";
|
|
import {
|
|
buildTauriMockScript,
|
|
MOCK_LOGIN_RESPONSE,
|
|
MOCK_MESSAGES,
|
|
MOCK_CHANNELS_WITH_CATEGORIES,
|
|
MOCK_MEMBERS_MULTI_ROLE,
|
|
MOCK_VOICE_STATE,
|
|
navigateToMainPageReady,
|
|
joinVoiceChannelByName,
|
|
emitWsMessage,
|
|
} from "./helpers";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests: voice E2EE identity-verification surface (DC-04, spec:
|
|
// docs/architecture/ux/voice-and-e2ee.md §7)
|
|
//
|
|
// Covers the roster badge states (verified / unverified / mismatch / unknown)
|
|
// and the identity-mismatch modal journey (review → reject keeps the peer
|
|
// blocked; trust re-pins the displayed key). The peer's announce is REAL
|
|
// crypto — an ECDSA P-256 identity key signing an ECDH ephemeral key exactly
|
|
// as e2eeCrypto.signEphemeralKey does — so the badge states come out of the
|
|
// production verification path, not a shortcut.
|
|
//
|
|
// Harness notes:
|
|
// - Verification only runs once the local ECDH keypair exists, which requires
|
|
// a voice_token; the stock voiceWsHandlers deliberately withhold it. This
|
|
// spec's voice_join handler DOES send one with is_key_holder: true (the key
|
|
// holder path returns from setupKeyExchange immediately), and a WebSocket
|
|
// shim parks the resulting LiveKit room.connect forever so the session sits
|
|
// stably in "securing" instead of self-destructing mid-test.
|
|
// - Announces may be emitted before joining: handleAnnounce queues them until
|
|
// the keypair exists and setupKeyExchange drains the queue through the
|
|
// verifying path, so there is no race either way.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const subtle = globalThis.crypto.subtle;
|
|
|
|
interface PeerCrypto {
|
|
/** The peer's long-term identity public key (raw P-256, base64). */
|
|
identityPublicKeyB64: string;
|
|
/** The peer's ephemeral ECDH public key for this call (raw P-256, base64). */
|
|
ephemeralPublicKeyB64: string;
|
|
/** ECDSA signature over domain ‖ userId ‖ ephemeralRaw, base64. */
|
|
signatureB64: string;
|
|
}
|
|
|
|
/** Generate a peer's identity + ephemeral keys and the signed announce,
|
|
* mirroring e2eeCrypto.signEphemeralKey's message construction exactly. */
|
|
async function makePeerCrypto(userId: number): Promise<PeerCrypto> {
|
|
const identity = await subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
"sign",
|
|
"verify",
|
|
]);
|
|
const ephemeral = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
|
"deriveBits",
|
|
]);
|
|
const idPubRaw = new Uint8Array(await subtle.exportKey("raw", identity.publicKey));
|
|
const ephPubRaw = new Uint8Array(await subtle.exportKey("raw", ephemeral.publicKey));
|
|
|
|
const domain = new TextEncoder().encode("owncord-voice-e2ee-announce-v1");
|
|
const uid = new TextEncoder().encode(String(userId));
|
|
const message = new Uint8Array(domain.length + uid.length + ephPubRaw.length);
|
|
message.set(domain, 0);
|
|
message.set(uid, domain.length);
|
|
message.set(ephPubRaw, domain.length + uid.length);
|
|
const sig = new Uint8Array(
|
|
await subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identity.privateKey, message),
|
|
);
|
|
|
|
return {
|
|
identityPublicKeyB64: Buffer.from(idPubRaw).toString("base64"),
|
|
ephemeralPublicKeyB64: Buffer.from(ephPubRaw).toString("base64"),
|
|
signatureB64: Buffer.from(sig).toString("base64"),
|
|
};
|
|
}
|
|
|
|
/** voice_join reply that, unlike the stock voiceWsHandlers, also grants a
|
|
* voice_token — required to mint the local ECDH keypair that gates
|
|
* verification. is_key_holder: true avoids the non-key-holder's 15s stall. */
|
|
function voiceJoinWithTokenHandler(): { type: string; handler: string } {
|
|
return {
|
|
type: "voice_join",
|
|
handler: `
|
|
var p = parsed.payload;
|
|
setTimeout(function() {
|
|
__tauriEmitEvent("ws-message", JSON.stringify({
|
|
type: "voice_state",
|
|
payload: { user_id: 1, channel_id: p.channel_id, username: "testuser", muted: false, deafened: false, speaking: false, camera: false, screenshare: false }
|
|
}));
|
|
}, 50);
|
|
setTimeout(function() {
|
|
__tauriEmitEvent("ws-message", JSON.stringify({
|
|
type: "voice_token",
|
|
payload: { token: "mock-token", url: "ws://localhost:7880", channel_id: p.channel_id, direct_url: "", is_key_holder: true }
|
|
}));
|
|
}, 80);
|
|
`,
|
|
};
|
|
}
|
|
|
|
/** Mock session where remote user 2 ("moderator1") is in the voice channel and
|
|
* may carry a published identity key and/or a stored pin. */
|
|
async function mockE2EEVoiceSession(
|
|
page: Page,
|
|
opts: {
|
|
peerIdentityKeyB64?: string;
|
|
identityPins?: Record<string, string>;
|
|
identityPinError?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
const members = MOCK_MEMBERS_MULTI_ROLE.map((m) =>
|
|
m.id === 2 && opts.peerIdentityKeyB64 !== undefined
|
|
? { ...m, identity_public_key: opts.peerIdentityKeyB64 }
|
|
: m,
|
|
);
|
|
await page.addInitScript(
|
|
buildTauriMockScript({
|
|
httpRoutes: [
|
|
{ pattern: "/api/v1/health", status: 200, body: { status: "ok", version: "1.0.0" } },
|
|
{ pattern: "/api/v1/auth/login", status: 200, body: MOCK_LOGIN_RESPONSE },
|
|
{ pattern: "/messages", status: 200, body: MOCK_MESSAGES },
|
|
],
|
|
simulateWsFlow: true,
|
|
wsHandlers: [voiceJoinWithTokenHandler()],
|
|
readyOverrides: {
|
|
channels: MOCK_CHANNELS_WITH_CATEGORIES,
|
|
members,
|
|
voice_states: MOCK_VOICE_STATE,
|
|
},
|
|
identityPins: opts.identityPins,
|
|
identityPinError: opts.identityPinError,
|
|
}),
|
|
);
|
|
// Park the LiveKit signal WebSocket forever: room.connect neither succeeds
|
|
// nor fails during the test, so the voice session stays in "securing" and
|
|
// never tears down the verification state mid-assertion.
|
|
await page.addInitScript(() => {
|
|
const RealWS = window.WebSocket;
|
|
function ParkedOrReal(url: string | URL, protocols?: string | string[]): WebSocket {
|
|
const s = String(url);
|
|
if (s.includes("localhost:7880") || s.includes("127.0.0.1:7880")) {
|
|
const parked = new EventTarget() as unknown as Record<string, unknown>;
|
|
parked.url = s;
|
|
parked.readyState = 0; // CONNECTING, forever
|
|
parked.binaryType = "arraybuffer";
|
|
parked.send = () => {};
|
|
parked.close = () => {
|
|
parked.readyState = 3;
|
|
};
|
|
parked.onopen = null;
|
|
parked.onmessage = null;
|
|
parked.onerror = null;
|
|
parked.onclose = null;
|
|
return parked as unknown as WebSocket;
|
|
}
|
|
return new RealWS(url, protocols);
|
|
}
|
|
ParkedOrReal.prototype = RealWS.prototype;
|
|
Object.assign(ParkedOrReal, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
|
|
(window as unknown as { WebSocket: unknown }).WebSocket = ParkedOrReal;
|
|
});
|
|
}
|
|
|
|
async function emitPeerAnnounce(
|
|
page: Page,
|
|
userId: number,
|
|
crypto: PeerCrypto,
|
|
withSignature = true,
|
|
): Promise<void> {
|
|
await emitWsMessage(page, {
|
|
type: "voice_e2ee_announce",
|
|
payload: {
|
|
user_id: userId,
|
|
public_key: crypto.ephemeralPublicKeyB64,
|
|
...(withSignature ? { signature: crypto.signatureB64 } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** IPC calls of one command recorded by the mock's invoke log. */
|
|
async function invokesOf(page: Page, cmd: string): Promise<Array<Record<string, unknown>>> {
|
|
return page.evaluate(
|
|
(c) =>
|
|
(
|
|
window as unknown as { __invokeLog: Array<{ cmd: string; args: Record<string, unknown> }> }
|
|
).__invokeLog
|
|
.filter((e) => e.cmd === c)
|
|
.map((e) => e.args),
|
|
cmd,
|
|
);
|
|
}
|
|
|
|
const peerBadge = (page: Page): ReturnType<Page["locator"]> =>
|
|
page.locator('.voice-user-item[data-voice-uid="2"] .vu-verify');
|
|
|
|
test.describe("Voice E2EE identity verification (§7)", () => {
|
|
test("a valid signed announce verifies the peer: green badge with safety number, pinned on first sight", async ({
|
|
page,
|
|
}) => {
|
|
const peer = await makePeerCrypto(2);
|
|
await mockE2EEVoiceSession(page, { peerIdentityKeyB64: peer.identityPublicKeyB64 });
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
|
|
await emitPeerAnnounce(page, 2, peer);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toBeVisible({ timeout: 10_000 });
|
|
await expect(badge).toHaveClass(/verified/);
|
|
await expect(badge).toHaveAttribute("title", /^Identity verified · Safety number: /);
|
|
|
|
// TOFU: the first verified sighting pins the peer's identity key.
|
|
const pins = await invokesOf(page, "store_identity_pin");
|
|
expect(pins).toHaveLength(1);
|
|
expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 });
|
|
});
|
|
|
|
test("a legacy peer with no published key is accepted but shows the neutral unverified badge", async ({
|
|
page,
|
|
}) => {
|
|
const peer = await makePeerCrypto(2);
|
|
await mockE2EEVoiceSession(page, {}); // no identity_public_key on member 2
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
|
|
await emitPeerAnnounce(page, 2, peer, false);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toBeVisible({ timeout: 10_000 });
|
|
await expect(badge).toHaveClass(/unverified/);
|
|
// No identity key → no safety number, but the per-call session
|
|
// fingerprint is shown (labelled as not an identity) so there is still
|
|
// something to compare out of band (OC-0003).
|
|
await expect(badge).toHaveAttribute(
|
|
"title",
|
|
/^Identity not verified — this participant published no key\. Session fingerprint \(changes every call — not an identity\): ([0-9A-F]{4} ){7}[0-9A-F]{4}$/,
|
|
);
|
|
expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0);
|
|
});
|
|
|
|
test("a pinned peer whose delivered key changed is blocked with the red mismatch badge", async ({
|
|
page,
|
|
}) => {
|
|
const peer = await makePeerCrypto(2);
|
|
const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64; // a different, previously-pinned key
|
|
await mockE2EEVoiceSession(page, {
|
|
peerIdentityKeyB64: peer.identityPublicKeyB64,
|
|
identityPins: { "2": oldPin },
|
|
});
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
|
|
await emitPeerAnnounce(page, 2, peer);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toBeVisible({ timeout: 10_000 });
|
|
await expect(badge).toHaveClass(/mismatch/);
|
|
await expect(badge).toHaveAttribute(
|
|
"title",
|
|
"Identity key changed — click to review and re-pin",
|
|
);
|
|
// Blocked means blocked: nothing was re-pinned behind the user's back.
|
|
expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0);
|
|
});
|
|
|
|
test("mismatch modal journey — reject (Cancel) keeps the peer blocked", async ({ page }) => {
|
|
const peer = await makePeerCrypto(2);
|
|
const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64;
|
|
await mockE2EEVoiceSession(page, {
|
|
peerIdentityKeyB64: peer.identityPublicKeyB64,
|
|
identityPins: { "2": oldPin },
|
|
});
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
await emitPeerAnnounce(page, 2, peer);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toHaveClass(/mismatch/, { timeout: 10_000 });
|
|
await badge.click();
|
|
|
|
// The modal shows the participant and the NEW key's fingerprint so the
|
|
// user can verify it out-of-band before trusting.
|
|
await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeVisible();
|
|
await expect(page.locator(".cert-title")).toHaveText("Identity Key Changed");
|
|
await expect(page.locator(".cert-details")).toContainText("moderator1");
|
|
await expect(page.locator(".cert-details .cert-fingerprint")).toBeVisible();
|
|
|
|
await page.locator(".modal-footer button", { hasText: "Cancel" }).click();
|
|
|
|
await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden();
|
|
// Reject leaves the peer blocked for E2EE media: badge stays red, no pin write.
|
|
await expect(badge).toHaveClass(/mismatch/);
|
|
expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0);
|
|
});
|
|
|
|
test("mismatch modal journey — Trust New Key re-pins the displayed key and clears the block", async ({
|
|
page,
|
|
}) => {
|
|
const peer = await makePeerCrypto(2);
|
|
const oldPin = (await makePeerCrypto(2)).identityPublicKeyB64;
|
|
await mockE2EEVoiceSession(page, {
|
|
peerIdentityKeyB64: peer.identityPublicKeyB64,
|
|
identityPins: { "2": oldPin },
|
|
});
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
await emitPeerAnnounce(page, 2, peer);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toHaveClass(/mismatch/, { timeout: 10_000 });
|
|
await badge.click();
|
|
await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeVisible();
|
|
|
|
await page.locator(".modal-footer button", { hasText: "Trust New Key" }).click();
|
|
|
|
await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden();
|
|
// The EXACT displayed key was pinned (TOCTOU-safe re-pin), and the
|
|
// mismatch block cleared — the badge disappears until the next announce
|
|
// re-verifies against the new pin.
|
|
await expect(badge).toHaveCount(0);
|
|
const pins = await invokesOf(page, "store_identity_pin");
|
|
expect(pins).toHaveLength(1);
|
|
expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 });
|
|
});
|
|
|
|
test("an unreadable pin store fails closed: distinct 'could not check' badge, nothing pinned (DC-08)", async ({
|
|
page,
|
|
}) => {
|
|
const peer = await makePeerCrypto(2);
|
|
await mockE2EEVoiceSession(page, {
|
|
peerIdentityKeyB64: peer.identityPublicKeyB64,
|
|
identityPinError: true,
|
|
});
|
|
await page.goto("/");
|
|
await navigateToMainPageReady(page);
|
|
|
|
await emitPeerAnnounce(page, 2, peer);
|
|
await joinVoiceChannelByName(page);
|
|
|
|
const badge = peerBadge(page);
|
|
await expect(badge).toBeVisible({ timeout: 10_000 });
|
|
await expect(badge).toHaveClass(/unknown/);
|
|
await expect(badge).toHaveAttribute("title", /Could not check/);
|
|
// Fail closed: a storage fault must never read as "never pinned" — the
|
|
// valid-looking key is NOT verified and NOT pinned.
|
|
await expect(badge).not.toHaveClass(/verified/);
|
|
expect(await invokesOf(page, "store_identity_pin")).toHaveLength(0);
|
|
});
|
|
});
|