Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/services/messaging/__tests__/group-key-roundtrip.node.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
114 changes: 114 additions & 0 deletions tests/e2e/messaging/group-chat-multiuser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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()]);
}
});
});
Loading
Loading