From e2fb16745ced6455901d885da85fb65ead2fa1e5 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sun, 5 Jul 2026 18:00:35 +0000 Subject: [PATCH] test(messaging): #182 deterministic cross-member group encryption E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group-message E2E (group-chat-multiuser.spec.ts) drove group CREATION through the UI, which runs createGroup() in-browser (Argon2 + in-browser ECDH key distribution, routinely >90s). It test.skip'd on slow runners and gave no reliable send/decrypt proof — on the #182 merge run it SKIPPED, so the actual proof came from an earlier CI log rather than a green assertion. Add a NON-skipping test that proves an encrypted group message round-trips between two DIFFERENT members (A sends, B decrypts) by distributing the group key SERVER-SIDE in the fixture, off the browser critical path: - test-user-factory.ts: `seedIsolatedGroup(n, { withKeys: true })` now also distributes the group key. `distributeGroupKeysForFixture` re-derives the creator's keypair in Node from their stored base64 salt (deterministic Argon2id), generates one AES-GCM group key, and wraps it per member via the REAL production `GroupKeyService.encryptGroupKeyForMember` (reused, not duplicated — zero wire-format drift). All rows share created_by = creator so each member unwraps with ECDH(memberPriv, creatorPub) at runtime. - group-chat-multiuser.spec.ts: new deterministic test in its own describe; the 6 existing tests (incl. the UI create-group flow) are untouched. - group-key-roundtrip.node.test.ts: real-crypto Node unit guard proving the exact wrap→unwrap→message-decrypt contract (catches a base64-salt decode drift in ~1s, before the browser). Each browser still pays one Argon2 key-unlock (same as every 1:1 iso test); the eliminated cost is the compounding in-browser group creation. Runs in chromium-msg-iso (already in MSG_ISO_GLOBS — no config change). Verified locally against served build + cloud Supabase: 2 passed, 0 skipped (~29s); unit guard 1 passed. Follows the #115 backlog "deterministic group send/decrypt E2E" nice-to-have. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../group-key-roundtrip.node.test.ts | 99 ++++++++++ .../messaging/group-chat-multiuser.spec.ts | 114 ++++++++++++ tests/e2e/utils/test-user-factory.ts | 169 +++++++++++++++++- 3 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/services/messaging/__tests__/group-key-roundtrip.node.test.ts diff --git a/src/services/messaging/__tests__/group-key-roundtrip.node.test.ts b/src/services/messaging/__tests__/group-key-roundtrip.node.test.ts new file mode 100644 index 00000000..83c5341f --- /dev/null +++ b/src/services/messaging/__tests__/group-key-roundtrip.node.test.ts @@ -0,0 +1,99 @@ +/** + * Real-crypto round-trip guard for the deterministic group-E2E fixture. + * + * The E2E fixture `seedIsolatedGroup(N, { withKeys: true })` distributes a group + * key server-side (Node) by re-deriving the CREATOR's private key from their + * stored *base64* `encryption_salt` and wrapping one AES-GCM group key for each + * member via the real `GroupKeyService.encryptGroupKeyForMember`. At runtime a + * member unwraps it with `ECDH(memberPriv, creatorPub)` inside their browser. + * + * This test exercises that EXACT contract with REAL crypto (no mocks) — the same + * `deriveKeyPair`/`encryptGroupKeyForMember`/`decryptGroupKey` code the fixture + * and the app use. Its whole job is to fail fast (~seconds, no browser) if the + * fixture's wrap would ever drift from what the app can unwrap — most likely + * culprit being the base64-salt → Uint8Array decode. If this is green, a red + * E2E is an environment/UI problem, not a crypto-contract problem. + * + * NOTE: unlike `group-key-service.test.ts` (which mocks crypto), this file uses + * real Web Crypto + real Argon2id, so it derives only 2 keypairs to stay fast. + */ + +import { describe, it, expect } from 'vitest'; +import { KeyDerivationService } from '@/lib/messaging/key-derivation'; +import { GroupKeyService } from '@/services/messaging/group-key-service'; +import { encryptionService } from '@/lib/messaging/encryption'; + +/** base64 → Uint8Array, matching the fixture's stored-salt decode. */ +function b64ToBytes(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, 'base64')); +} + +describe('group-key fixture round-trip (real crypto)', () => { + it('creator-wrapped group key unwraps for a member and decrypts a message', async () => { + const kds = new KeyDerivationService(); + const gks = new GroupKeyService(); + const password = 'TestPassword123!'; + + // Two members: creator C (= group_keys.created_by) and member B. The fixture + // stores each user's salt as base64; re-derive from the base64 form to + // exercise the exact decode the fixture uses. + const creatorSaltB64 = Buffer.from(kds.generateSalt()).toString('base64'); + const memberSaltB64 = Buffer.from(kds.generateSalt()).toString('base64'); + + const creator = await kds.deriveKeyPair({ + password, + salt: b64ToBytes(creatorSaltB64), + }); + const member = await kds.deriveKeyPair({ + password, + salt: b64ToBytes(memberSaltB64), + }); + + // Re-deriving from the same base64 salt must reproduce the stored public key + // (the fixture asserts this too — a mismatch means the runtime ECDH + // counterparty wouldn't line up). + const creatorReDerived = await kds.deriveKeyPair({ + password, + salt: b64ToBytes(creatorSaltB64), + }); + expect( + kds.verifyPublicKey(creator.publicKeyJwk, creatorReDerived.publicKeyJwk) + ).toBe(true); + + // One group key, wrapped FOR the member using the CREATOR's private key — + // exactly what distributeGroupKeysForFixture writes into group_keys. + const groupKey = await gks.generateGroupKey(); + const encryptedKey = await gks.encryptGroupKeyForMember( + groupKey, + member.publicKeyJwk, + creator.privateKey + ); + + // Member unwraps with the CREATOR's public key (the created_by lookup path + // in getGroupKeyForConversation) + their own private key. + const unwrapped = await gks.decryptGroupKey( + encryptedKey, + creator.publicKeyJwk, + member.privateKey + ); + + // The unwrapped key must be byte-identical to the original group key. + const originalBytes = new Uint8Array(await gks.exportKeyBytes(groupKey)); + const unwrappedBytes = new Uint8Array(await gks.exportKeyBytes(unwrapped)); + expect(unwrappedBytes).toEqual(originalBytes); + + // End-to-end: a message encrypted with the group key decrypts with the + // member's unwrapped copy — the full send→decrypt path the E2E proves. + const plaintext = `deterministic group hello ${originalBytes.length}`; + const { ciphertext, iv } = await encryptionService.encryptMessage( + plaintext, + groupKey + ); + const decrypted = await encryptionService.decryptMessage( + ciphertext, + iv, + unwrapped + ); + expect(decrypted).toBe(plaintext); + }); +}); diff --git a/tests/e2e/messaging/group-chat-multiuser.spec.ts b/tests/e2e/messaging/group-chat-multiuser.spec.ts index bfe43e4b..00355e3f 100644 --- a/tests/e2e/messaging/group-chat-multiuser.spec.ts +++ b/tests/e2e/messaging/group-chat-multiuser.spec.ts @@ -15,12 +15,17 @@ import { test, expect } from '@playwright/test'; import { seedIsolatedConnection, deleteIsolatedConnection, + seedIsolatedGroup, + deleteIsolatedGroup, openAuthedPage, + openConversationAs, handleReAuthModal, dismissCookieBanner, fillMessageInput, + scrollThreadToBottom, DEFAULT_TEST_PASSWORD, type IsolatedConnection, + type IsolatedGroup, } from '../utils/test-user-factory'; test.describe.configure({ mode: 'parallel' }); @@ -315,3 +320,112 @@ test('contract - isolated connection helper is usable', async () => { expect(fixture!.requesterDisplayName).toMatch(/.+/); await deleteIsolatedConnection(fixture); }); + +/** + * DETERMINISTIC group send/decrypt (#182 follow-up). + * + * Unlike the UI-created-group test above — which drives createGroup() in-browser + * (Argon2 + in-browser ECDH key distribution, routinely >90s, so it skips on + * slow runners and never reliably proves send/decrypt) — this seeds the group + * AND distributes the group key SERVER-SIDE via seedIsolatedGroup(withKeys), so + * the group is send/decrypt-ready with NO UI creation. Each browser still pays + * one Argon2 key-unlock (via the ReAuth modal, same as every 1:1 iso test), but + * the compounding UI-creation cost is gone → the test is deterministic and does + * NOT skip. It's the authoritative cross-member group-encryption round-trip: + * member A sends, member B (a second browser context) decrypts. + */ +test.describe('Group Chat E2E — deterministic encrypted round-trip (#182)', () => { + let group: IsolatedGroup | null = null; + + test.beforeEach(async () => { + // 2 keyed members + a group conversation WITH the group key distributed. + group = await seedIsolatedGroup(2, { withKeys: true }); + test.skip(!group, 'group seed/keying failed (no admin client / anon key?)'); + }); + + test.afterEach(async () => { + await deleteIsolatedGroup(group); + group = null; + }); + + test('member A sends an encrypted group message and member B decrypts it', async ({ + browser, + }) => { + const [a, b] = group!.participants; + const convId = group!.conversationId; + + // Open both members concurrently — serializing the two Argon2 ReAuth + // unlocks nearly exhausts the per-test budget before the send. + // openConversationAs does goto + dismiss + ReAuth + wait-for-thread. + const [A, B] = await Promise.all([ + openConversationAs(browser, a.session, convId), + openConversationAs(browser, b.session, convId), + ]); + + // Forward browser console for CI diagnostics. + for (const [label, pg] of [ + ['A', A.page], + ['B', B.page], + ] as const) { + pg.on('console', (msg) => { + if ( + msg.type() === 'error' || + msg.text().includes('DECRYPTION') || + msg.text().includes('group') + ) { + console.log(`[${label} console.${msg.type()}] ${msg.text()}`); + } + }); + } + + try { + // Opening a group must NOT throw the #182 "not yet implemented" — the + // regression this whole feature guards against. + await expect(A.page.locator('text=/not yet implemented/i')).toHaveCount( + 0 + ); + + // A sends into the group (real group encrypt path: getGroupKeyForConversation + // at current_key_version → encryptMessage → messages.key_version stamped). + const body = `deterministic group hello ${Date.now()}`; + await fillMessageInput(A.page, body); + const sendButton = A.page.getByRole('button', { name: /Send message/i }); + await sendButton.click(); + await expect(sendButton).not.toContainText('Sending', { timeout: 60000 }); + + // A sees its OWN message decrypt + render (own bubble goes through the + // full group-key decrypt path, not an isOwn shortcut). + await scrollThreadToBottom(A.page); + await expect(A.page.getByText(body)).toBeVisible({ timeout: 30000 }); + + // B (a DIFFERENT member, second context) decrypts + sees it — this is the + // cross-member proof. B unwraps the group key with B's re-derived private + // key + the creator's public key, then decrypts A's message. The thread + // updates via ~10s polling; reload between attempts to survive cloud + // read-after-write tail latency. + const bText = B.page.getByText(body); + for (let i = 0; i < 5; i++) { + await scrollThreadToBottom(B.page); + if (await bText.isVisible({ timeout: 12000 }).catch(() => false)) break; + await B.page.reload({ waitUntil: 'domcontentloaded' }); + await handleReAuthModal(B.page, DEFAULT_TEST_PASSWORD).catch(() => {}); + await B.page.waitForSelector('[data-testid="message-thread"]', { + state: 'visible', + timeout: 30000, + }); + } + await scrollThreadToBottom(B.page); + await expect(bText).toBeVisible({ timeout: 15000 }); + + // Neither member shows a decryption placeholder / "not yet implemented". + await expect(B.page.locator('text=/not yet implemented/i')).toHaveCount( + 0 + ); + await expect( + B.page.getByText('Encrypted with previous keys') + ).toHaveCount(0); + } finally { + await Promise.all([A.close(), B.close()]); + } + }); +}); diff --git a/tests/e2e/utils/test-user-factory.ts b/tests/e2e/utils/test-user-factory.ts index 6c7e2f1b..585bb76d 100644 --- a/tests/e2e/utils/test-user-factory.ts +++ b/tests/e2e/utils/test-user-factory.ts @@ -13,6 +13,12 @@ import { createClient, SupabaseClient, User } from '@supabase/supabase-js'; import { expect, type Page, type Browser } from '@playwright/test'; import { KeyDerivationService } from '@/lib/messaging/key-derivation'; +// Static import so Playwright's TS loader transpiles it: a dynamic import() of +// this raw .ts module fails under the Playwright node runtime ("Cannot use +// import statement outside a module"). new GroupKeyService() is Node-safe +// (createClient() returns an inert {} when window is undefined), and the +// transitive messaging Dexie instance constructs without indexedDB. +import { GroupKeyService } from '@/services/messaging/group-key-service'; /** * Email domain for test users. @@ -1872,18 +1878,149 @@ export interface IsolatedGroup { conversationId: string; } +/** + * Distribute a group encryption key to every member, server-side (Node), so a + * seeded group is send/decrypt-ready without the in-browser createGroup() flow. + * + * Reuses the PRODUCTION wrap (`GroupKeyService.encryptGroupKeyForMember`) — no + * duplicated crypto, so the seeded `group_keys` rows are byte-identical in shape + * to what the app writes. That method is pure (crypto.subtle + ECDH only, no + * auth/DB), and `new GroupKeyService()` is Node-safe because `createClient()` + * returns an inert object when `window` is undefined. + * + * Because member keypairs are DETERMINISTIC (Argon2id over password + stored + * salt), we re-derive the creator's private key in Node from + * DEFAULT_TEST_PASSWORD + the creator's stored `encryption_salt`, then wrap the + * one group key for each member using `ECDH(creatorPriv, memberPub)`. Members' + * PUBLIC keys are read straight from `user_encryption_keys` (no per-member + * private-key derivation needed). `created_by` is the creator for every row, so + * each member unwraps with `ECDH(memberPriv, creatorPub)` at runtime. + * + * @returns true on success, false if any salt/public key is missing or a step + * throws (caller rolls back the conversation). + */ +async function distributeGroupKeysForFixture( + admin: SupabaseClient, + conversationId: string, + memberIds: string[], + creatorId: string +): Promise { + try { + // Pull every member's stored public key + salt in one query. + const { data: keyRows, error: keyErr } = await admin + .from('user_encryption_keys') + .select('user_id, public_key, encryption_salt') + .in('user_id', memberIds) + .eq('revoked', false); + if (keyErr || !keyRows) { + console.warn( + 'distributeGroupKeysForFixture: could not read user_encryption_keys:', + keyErr?.message + ); + return false; + } + const keyByUser = new Map(keyRows.map((r) => [r.user_id, r])); + + const creatorRow = keyByUser.get(creatorId); + if (!creatorRow?.encryption_salt || !creatorRow.public_key) { + console.warn('distributeGroupKeysForFixture: creator has no keys/salt'); + return false; + } + + // Re-derive the creator's keypair in Node (deterministic: same password + + // salt ⇒ same keys the browser derives). The stored salt is base64. + const kds = new KeyDerivationService(); + const creatorKeys = await kds.deriveKeyPair({ + password: DEFAULT_TEST_PASSWORD, + salt: new Uint8Array(Buffer.from(creatorRow.encryption_salt, 'base64')), + }); + // Cheap sanity guard: the re-derived public key must match what's stored, + // else the ECDH counterparty members fetch at runtime won't line up. + if ( + !kds.verifyPublicKey( + creatorKeys.publicKeyJwk, + creatorRow.public_key as JsonWebKey + ) + ) { + console.warn( + 'distributeGroupKeysForFixture: re-derived creator key mismatch' + ); + return false; + } + + // One AES-GCM-256 group key, wrapped per member with the real prod code. + const gks = new GroupKeyService(); + const groupKey = await gks.generateGroupKey(); + + const rows: Array<{ + conversation_id: string; + user_id: string; + key_version: number; + encrypted_key: string; + created_by: string; + }> = []; + for (const memberId of memberIds) { + const memberRow = keyByUser.get(memberId); + if (!memberRow?.public_key) { + console.warn( + `distributeGroupKeysForFixture: member ${memberId} has no public key` + ); + return false; + } + const encrypted_key = await gks.encryptGroupKeyForMember( + groupKey, + memberRow.public_key as JsonWebKey, + creatorKeys.privateKey + ); + rows.push({ + conversation_id: conversationId, + user_id: memberId, + key_version: 1, + encrypted_key, + created_by: creatorId, + }); + } + + const { error: insertErr } = await admin.from('group_keys').insert(rows); + if (insertErr) { + console.warn( + 'distributeGroupKeysForFixture: group_keys insert failed:', + insertErr.message + ); + return false; + } + return true; + } catch (err) { + console.warn( + 'distributeGroupKeysForFixture: unexpected error:', + (err as Error).message + ); + return false; + } +} + /** * Create a fully isolated group of `participantCount` throwaway users plus a * group conversation containing all of them. For the group-chat spec. * - * The conversation is created with is_group=true and the creator as - * participant_1; remaining members are added via conversation_participants - * (falls back gracefully if that table is absent on older schemas). Returns null - * if the admin client / anon key is unavailable or the conversation can't be made. + * The conversation is created with is_group=true (participant_1/2 NULL per the + * CHK023 constraint); membership lives in conversation_members (creator = owner). + * + * When `opts.withKeys` is set, the fixture ALSO distributes the group encryption + * key server-side: it generates one AES-GCM-256 group key and inserts a + * `group_keys` row per member, each ECDH-wrapped for that member using the + * creator's re-derived private key — exactly what the in-browser + * createGroup()/distributeGroupKey path produces, but off the slow UI critical + * path. This is what makes a DETERMINISTIC (non-skipping) group send/decrypt E2E + * possible: opening the group in a member's browser can then unwrap the key and + * decrypt, without driving the >90s in-browser group-creation flow. Without it + * (default), opening the group in a browser fails with "Group key not found". + * + * Returns null if the admin client / anon key is unavailable or seeding fails. */ export async function seedIsolatedGroup( participantCount = 3, - opts?: { prefix?: string } + opts?: { prefix?: string; withKeys?: boolean } ): Promise { const admin = getAdminClient(); if (!admin) return null; @@ -1957,8 +2094,28 @@ export async function seedIsolatedGroup( return null; } + // Optionally distribute the group key server-side so the group is + // send/decrypt-ready without the in-browser createGroup() flow. + if (opts?.withKeys) { + const memberIds = participants.map((p) => p.user.id); + const distributed = await distributeGroupKeysForFixture( + admin, + conversationId, + memberIds, + creatorId + ); + if (!distributed) { + console.warn('seedIsolatedGroup: group-key distribution failed'); + await admin.from('conversations').delete().eq('id', conversationId); + await cleanup(); + return null; + } + } + console.log( - `✓ Isolated group ${conversationId} (${participantCount} members)` + `✓ Isolated group ${conversationId} (${participantCount} members${ + opts?.withKeys ? ', keyed' : '' + })` ); return { participants, conversationId }; }