mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
115 lines
5.2 KiB
JavaScript
115 lines
5.2 KiB
JavaScript
import { webcrypto } from 'node:crypto';
|
|
import { Buffer } from 'node:buffer';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
clearChatKeyCache,
|
|
countCodePoints,
|
|
decryptChatMessage,
|
|
deriveChatKey,
|
|
encryptChatMessage,
|
|
generateChatSecret,
|
|
normalizeOutgoingChatText,
|
|
validateChatSecret
|
|
} from './chat-crypto.js';
|
|
|
|
afterEach(clearChatKeyCache);
|
|
|
|
describe('chat crypto', () => {
|
|
it('generates canonical 128-bit secrets', () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
|
expect(validateChatSecret(secret)).toBe(secret);
|
|
expect(validateChatSecret(`${secret}=`)).toBe('');
|
|
});
|
|
|
|
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
|
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
|
expect(secondKey).toBe(firstKey);
|
|
|
|
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
|
|
const first = await encryptChatMessage(input, webcrypto);
|
|
const second = await encryptChatMessage(input, webcrypto);
|
|
expect(second).not.toBe(first);
|
|
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
|
|
});
|
|
|
|
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
let deriveCalls = 0;
|
|
let releaseDerive;
|
|
const delayedCrypto = {
|
|
...webcrypto,
|
|
subtle: {
|
|
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
|
deriveKey: async (...args) => {
|
|
deriveCalls++;
|
|
await new Promise(resolve => { releaseDerive = resolve; });
|
|
return webcrypto.subtle.deriveKey(...args);
|
|
}
|
|
}
|
|
};
|
|
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
|
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
|
await Promise.resolve();
|
|
expect(deriveCalls).toBe(1);
|
|
clearChatKeyCache();
|
|
releaseDerive();
|
|
await Promise.all([first, second]);
|
|
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
|
|
expect(fresh).not.toBe(first);
|
|
await fresh;
|
|
});
|
|
|
|
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
|
|
clearChatKeyCache();
|
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
|
|
clearChatKeyCache();
|
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
|
|
clearChatKeyCache();
|
|
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
|
|
});
|
|
|
|
it('enforces 500 Unicode code points before encryption', () => {
|
|
expect(countCodePoints('😀'.repeat(500))).toBe(500);
|
|
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
|
|
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
|
|
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
|
|
});
|
|
|
|
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
const ciphertext = await encryptChatMessage({
|
|
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
|
|
}, webcrypto);
|
|
const bytes = Buffer.from(ciphertext, 'base64url');
|
|
expect(bytes).toHaveLength(2028);
|
|
});
|
|
|
|
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
|
|
const secret = generateChatSecret(webcrypto);
|
|
const roomId = 'ROOM-1';
|
|
const senderId = 'alice';
|
|
const key = await deriveChatKey(roomId, secret, webcrypto);
|
|
const encoder = new globalThis.TextEncoder();
|
|
|
|
async function encryptRaw(plaintext) {
|
|
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
|
|
name: 'AES-GCM',
|
|
iv,
|
|
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
|
}, key, plaintext));
|
|
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
|
|
}
|
|
|
|
const whitespace = await encryptRaw(encoder.encode(' '));
|
|
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
|
|
const malformed = await encryptRaw(Uint8Array.of(0xff));
|
|
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
|
|
});
|
|
});
|