diff --git a/tests/crypto.test.js b/tests/crypto.test.js new file mode 100644 index 0000000..4cd2935 --- /dev/null +++ b/tests/crypto.test.js @@ -0,0 +1,259 @@ +/** + * Tests for E2E encryption (crypto service + conversations) + */ + +import { asyncTest, describe, assertEquals, assertTrue, assertFalse, assertNotEquals } from './test-runner.js' +import { cryptoService } from '../js/services/crypto.js' +import { conversationsService } from '../js/services/conversations.js' + +const TEST_UUID = '00000000-0000-4000-a000-000000000001' +const TEST_UUID_2 = '00000000-0000-4000-a000-000000000002' + +// ── Crypto init & keypair management ── + +describe('CryptoService', () => { + asyncTest('loads NaCl library', async () => { + await cryptoService.ready + assertTrue(cryptoService.nacl !== null) + assertTrue(cryptoService.naclUtil !== null) + }) + + asyncTest('getPublicKey returns null when locked', async () => { + await cryptoService.ready + cryptoService.lock() + assertEquals(cryptoService.getPublicKey(), null) + }) + + asyncTest('unlock generates keypair and stores encrypted', async () => { + localStorage.removeItem('dgray_keypair') + localStorage.removeItem('dgray_keypair_salt') + + await cryptoService.unlock(TEST_UUID) + + assertTrue(cryptoService.keyPair !== null) + assertTrue(cryptoService.getPublicKey() !== null) + assertTrue(cryptoService.getPublicKey().length > 10) + + const stored = JSON.parse(localStorage.getItem('dgray_keypair')) + assertTrue(stored.ct !== undefined, 'stored keypair should be encrypted (has ct)') + assertTrue(stored.iv !== undefined, 'stored keypair should be encrypted (has iv)') + assertFalse(stored.publicKey !== undefined, 'stored keypair should NOT have plaintext publicKey') + }) + + asyncTest('unlock restores same keypair from encrypted storage', async () => { + await cryptoService.unlock(TEST_UUID) + const pubKey1 = cryptoService.getPublicKey() + + cryptoService.lock() + assertEquals(cryptoService.getPublicKey(), null) + + await cryptoService.unlock(TEST_UUID) + const pubKey2 = cryptoService.getPublicKey() + + assertEquals(pubKey1, pubKey2) + }) + + asyncTest('unlock with wrong UUID generates new keypair', async () => { + await cryptoService.unlock(TEST_UUID) + const pubKey1 = cryptoService.getPublicKey() + + cryptoService.lock() + + const origWarn = console.warn + console.warn = () => {} + await cryptoService.unlock(TEST_UUID_2) + console.warn = origWarn + const pubKey2 = cryptoService.getPublicKey() + + assertNotEquals(pubKey1, pubKey2) + }) + + asyncTest('lock clears keypair from memory', async () => { + cryptoService.destroyKeyPair() + await cryptoService.unlock(TEST_UUID) + assertTrue(cryptoService.keyPair !== null) + + cryptoService.lock() + assertEquals(cryptoService.keyPair, null) + + assertTrue(localStorage.getItem('dgray_keypair') !== null) + }) + + asyncTest('destroyKeyPair clears memory and storage', async () => { + await cryptoService.unlock(TEST_UUID) + cryptoService.destroyKeyPair() + + assertEquals(cryptoService.keyPair, null) + assertEquals(localStorage.getItem('dgray_keypair'), null) + assertEquals(localStorage.getItem('dgray_keypair_salt'), null) + }) + + asyncTest('migrates plaintext keypair to encrypted on unlock', async () => { + await cryptoService.ready + const kp = cryptoService.nacl.box.keyPair() + const plaintext = { + publicKey: cryptoService.naclUtil.encodeBase64(kp.publicKey), + secretKey: cryptoService.naclUtil.encodeBase64(kp.secretKey) + } + localStorage.setItem('dgray_keypair', JSON.stringify(plaintext)) + localStorage.removeItem('dgray_keypair_salt') + + await cryptoService.unlock(TEST_UUID) + + assertEquals(cryptoService.getPublicKey(), plaintext.publicKey) + + const stored = JSON.parse(localStorage.getItem('dgray_keypair')) + assertTrue(stored.ct !== undefined, 'should be migrated to encrypted format') + assertTrue(stored.iv !== undefined) + }) +}) + +// ── Encrypt / Decrypt roundtrip ── + +describe('E2E encryption roundtrip', () => { + asyncTest('encrypt and decrypt between two keypairs', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + const alicePub = naclUtil.encodeBase64(alice.publicKey) + const bobPub = naclUtil.encodeBase64(bob.publicKey) + + cryptoService.keyPair = alice + const { nonce, ciphertext } = cryptoService.encrypt('Hello Bob!', bobPub) + + assertTrue(nonce.length > 0) + assertTrue(ciphertext.length > 0) + assertNotEquals(ciphertext, 'Hello Bob!') + + cryptoService.keyPair = bob + const plaintext = cryptoService.decrypt(ciphertext, nonce, alicePub) + assertEquals(plaintext, 'Hello Bob!') + }) + + asyncTest('decrypt with wrong key returns null', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + const eve = nacl.box.keyPair() + const bobPub = naclUtil.encodeBase64(bob.publicKey) + const alicePub = naclUtil.encodeBase64(alice.publicKey) + + cryptoService.keyPair = alice + const { nonce, ciphertext } = cryptoService.encrypt('Secret message', bobPub) + + cryptoService.keyPair = eve + const result = cryptoService.decrypt(ciphertext, nonce, alicePub) + assertEquals(result, null) + }) + + asyncTest('handles empty string message', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + + cryptoService.keyPair = alice + const { nonce, ciphertext } = cryptoService.encrypt('', naclUtil.encodeBase64(bob.publicKey)) + + cryptoService.keyPair = bob + const plaintext = cryptoService.decrypt(ciphertext, nonce, naclUtil.encodeBase64(alice.publicKey)) + assertEquals(plaintext, '') + }) + + asyncTest('handles unicode and emoji', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + + const message = 'Preis: 50€ 🎉 日本語テスト' + + cryptoService.keyPair = alice + const { nonce, ciphertext } = cryptoService.encrypt(message, naclUtil.encodeBase64(bob.publicKey)) + + cryptoService.keyPair = bob + const plaintext = cryptoService.decrypt(ciphertext, nonce, naclUtil.encodeBase64(alice.publicKey)) + assertEquals(plaintext, message) + }) + + asyncTest('each encryption produces different ciphertext (random nonce)', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + const bobPub = naclUtil.encodeBase64(bob.publicKey) + + cryptoService.keyPair = alice + const enc1 = cryptoService.encrypt('Same message', bobPub) + const enc2 = cryptoService.encrypt('Same message', bobPub) + + assertNotEquals(enc1.nonce, enc2.nonce) + assertNotEquals(enc1.ciphertext, enc2.ciphertext) + }) + + asyncTest('bidirectional communication works', async () => { + await cryptoService.ready + const nacl = cryptoService.nacl + const naclUtil = cryptoService.naclUtil + + const alice = nacl.box.keyPair() + const bob = nacl.box.keyPair() + const alicePub = naclUtil.encodeBase64(alice.publicKey) + const bobPub = naclUtil.encodeBase64(bob.publicKey) + + // Alice → Bob + cryptoService.keyPair = alice + const msg1 = cryptoService.encrypt('Hi Bob', bobPub) + + cryptoService.keyPair = bob + assertEquals(cryptoService.decrypt(msg1.ciphertext, msg1.nonce, alicePub), 'Hi Bob') + + // Bob → Alice + const msg2 = cryptoService.encrypt('Hi Alice', alicePub) + + cryptoService.keyPair = alice + assertEquals(cryptoService.decrypt(msg2.ciphertext, msg2.nonce, bobPub), 'Hi Alice') + }) +}) + +// ── Participant hash (conversations) ── + +describe('ConversationsService.hashPublicKey', () => { + asyncTest('produces deterministic SHA-256 hash', async () => { + const hash1 = await conversationsService.hashPublicKey('test-key-123') + const hash2 = await conversationsService.hashPublicKey('test-key-123') + assertEquals(hash1, hash2) + }) + + asyncTest('produces 64-char hex string', async () => { + const hash = await conversationsService.hashPublicKey('any-key') + assertEquals(hash.length, 64) + assertTrue(/^[0-9a-f]+$/.test(hash)) + }) + + asyncTest('different keys produce different hashes', async () => { + const hash1 = await conversationsService.hashPublicKey('key-a') + const hash2 = await conversationsService.hashPublicKey('key-b') + assertNotEquals(hash1, hash2) + }) +}) + +// Cleanup after all tests +describe('Crypto test cleanup', () => { + asyncTest('cleanup test data from storage', async () => { + cryptoService.destroyKeyPair() + assertTrue(true) + }) +}) diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 4ee2dbe..b09c9c1 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -42,7 +42,10 @@ describe('i18n.t (translation)', () => { // They test the parameter interpolation logic test('returns key if translation missing', () => { + const origWarn = console.warn + console.warn = () => {} const result = i18n.t('this.key.does.not.exist') + console.warn = origWarn assertEquals(result, 'this.key.does.not.exist') }) }) diff --git a/tests/index.html b/tests/index.html index cb0d50b..3f18a9a 100644 --- a/tests/index.html +++ b/tests/index.html @@ -108,6 +108,7 @@ // Service tests (sync + async) await import('./client.test.js') await import('./services.test.js') + await import('./crypto.test.js') // Run queued async tests await runAsyncTests()