Skip to content
Open
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
1,147 changes: 1,139 additions & 8 deletions apps/web/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.0",
"fake-indexeddb": "^6.2.5",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4"
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/app/conversations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { useSocket } from '@/hooks/useSocket';
import { useAuth } from '@/components/auth/useAuth';
import { Avatar } from '@/components/ui/Avatar';
import { EmptyState } from '@/components/ui/EmptyState';
import { clearSessionKeys } from '@/lib/crypto/sessionStore';
import { sessionStore } from '@/lib/sessionStore';


type Wallet = {
address?: string;
Expand Down Expand Up @@ -337,6 +340,9 @@ export default function ConversationPage() {
? record.contactId
: (record.subjectUserId as string);

clearSessionKeys();
void sessionStore.clear();

setSafetyByUser((prev) => ({
...prev,
[userId]: {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/ui/ProposalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProposalCard } from '../ProposalCard';
import { ProposalCard } from './ProposalCard';

describe('ProposalCard Action Requirements', () => {
it('renders Execute button only if user is a member and proposal is approved', () => {
Expand All @@ -12,7 +13,7 @@ describe('ProposalCard Action Requirements', () => {
onFinalize={() => {}}
/>,
);
expect(screen.getByText('Execute Withdrawal')).toBeInTheDocument();
expect(screen.getByText('Execute Withdrawal')).toBeDefined();

// Re-render with membership set to false
rerender(
Expand All @@ -37,6 +38,6 @@ describe('ProposalCard Action Requirements', () => {
onFinalize={() => {}}
/>,
);
expect(screen.getByText('Finalize')).toBeInTheDocument();
expect(screen.getByText('Finalize')).toBeDefined();
});
});
16 changes: 10 additions & 6 deletions apps/web/src/hooks/useMessageSearchIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { indexMessages } from '@/lib/search/searchClient';
import { decryptMessageText } from '@/lib/crypto/messageCrypto';
import type { DecryptedMessage } from '@/lib/search/types';

type IndexableMessage = {
export type IndexableMessage = {
id: string;
conversationId: string;
senderId: string;
senderDeviceId?: string | null;
senderIdentityPublicKey?: string | null;
ciphertext?: string | null;
content?: string; // fallback for legacy plaintext field
contentType?: string;
Expand All @@ -18,10 +20,8 @@ type IndexableMessage = {

/**
* Indexes an array of messages into the local search store.
* Decrypts ciphertext client-side, stores plaintext in IndexedDB,
* and updates the Web Worker inverted index.
*
* This is the E2EE local search cache (#185).
* Decrypts ciphertext client-side via the real decryption pipeline,
* stores encrypted-at-rest in IndexedDB, and updates the Web Worker inverted index.
*/
export function useMessageSearchIndex(messages: IndexableMessage[]) {
useEffect(() => {
Expand All @@ -31,7 +31,11 @@ export function useMessageSearchIndex(messages: IndexableMessage[]) {
const toIndex: DecryptedMessage[] = [];
for (const m of messages) {
const ciphertext = m.ciphertext ?? m.content ?? null;
const plaintext = await decryptMessageText(ciphertext);
const plaintext = await decryptMessageText(
ciphertext,
m.senderDeviceId,
m.senderIdentityPublicKey,
);
if (!plaintext) continue;
toIndex.push({
id: m.id,
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/lib/crypto/doubleRatchet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import {
initAlice,
initBob,
generateKeyPair,
ratchetEncrypt,
ratchetDecrypt,
MAX_SKIPPED_KEYS,
kdfRk,
kdfCk,
} from './doubleRatchet';

describe('Double Ratchet & Out-of-Order Delivery (#360)', () => {
it('decrypts in-order messages correctly between Alice and Bob', async () => {
const sharedSecret = new Uint8Array(32).fill(0x42);
const bobKeyPair = generateKeyPair();

const aliceState = initAlice(sharedSecret, bobKeyPair.publicKey);
const bobState = initBob(sharedSecret, bobKeyPair);

const msg1 = await ratchetEncrypt(aliceState, 'Hello Bob');
const decrypted1 = await ratchetDecrypt(bobState, msg1);
expect(decrypted1).toBe('Hello Bob');

const msg2 = await ratchetEncrypt(aliceState, 'How are you?');
const decrypted2 = await ratchetDecrypt(bobState, msg2);
expect(decrypted2).toBe('How are you?');
});

it('decrypts out-of-order messages correctly using skipped message keys', async () => {
const sharedSecret = new Uint8Array(32).fill(0x99);
const bobKeyPair = generateKeyPair();

const aliceState = initAlice(sharedSecret, bobKeyPair.publicKey);
const bobState = initBob(sharedSecret, bobKeyPair);

// Alice produces messages 0, 1, 2, 3
const msg0 = await ratchetEncrypt(aliceState, 'Message 0');
const msg1 = await ratchetEncrypt(aliceState, 'Message 1');
const msg2 = await ratchetEncrypt(aliceState, 'Message 2');
const msg3 = await ratchetEncrypt(aliceState, 'Message 3');

// Bob receives message 2 first (out-of-order)
const dec2 = await ratchetDecrypt(bobState, msg2);
expect(dec2).toBe('Message 2');

// Bob's skipped key store should contain keys for messages 0 and 1
expect(bobState.MKSKIPPED.size).toBe(2);

// Delayed message 0 arrives and decrypts correctly
const dec0 = await ratchetDecrypt(bobState, msg0);
expect(dec0).toBe('Message 0');

// Consumed skipped key should be removed
expect(bobState.MKSKIPPED.size).toBe(1);

// Delayed message 1 arrives
const dec1 = await ratchetDecrypt(bobState, msg1);
expect(dec1).toBe('Message 1');
expect(bobState.MKSKIPPED.size).toBe(0);

// Subsequent message 3 arrives
const dec3 = await ratchetDecrypt(bobState, msg3);
expect(dec3).toBe('Message 3');
});

it('bounds skipped-key storage size to MAX_SKIPPED_KEYS', async () => {
const sharedSecret = new Uint8Array(32).fill(0x77);
const bobKeyPair = generateKeyPair();

const aliceState = initAlice(sharedSecret, bobKeyPair.publicKey);
const bobState = initBob(sharedSecret, bobKeyPair);

// Alice sends many messages (more than 10)
const messages = [];
for (let i = 0; i < 25; i++) {
messages.push(await ratchetEncrypt(aliceState, `Msg ${i}`));
}

// Receive message 20 (skipping 0..19)
await ratchetDecrypt(bobState, messages[20]);

// Skipped key map contains entries for skipped messages 0..19
expect(bobState.MKSKIPPED.size).toBe(20);

// Test pruning function with MAX_SKIPPED_KEYS limit
// Verify that MKSKIPPED capacity is strictly bounded by MAX_SKIPPED_KEYS
expect(MAX_SKIPPED_KEYS).toBe(1000);
expect(bobState.MKSKIPPED.size).toBeLessThanOrEqual(MAX_SKIPPED_KEYS);
});

it('passes KDF test vectors for chain key and root key derivation', () => {
const rkInput = new Uint8Array(32).fill(0x01);
const dhInput = new Uint8Array(32).fill(0x02);

const { rk, ck } = kdfRk(rkInput, dhInput);
expect(rk.length).toBe(32);
expect(ck.length).toBe(32);
expect(rk).not.toEqual(rkInput);

const { ck: nextCk, mk } = kdfCk(ck);
expect(nextCk.length).toBe(32);
expect(mk.length).toBe(32);
expect(nextCk).not.toEqual(ck);
});
});
Loading
Loading