/** * 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' import { keyPinningService } from '../js/services/key-pinning.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('kashilo_keypair') localStorage.removeItem('kashilo_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('kashilo_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('kashilo_keypair') !== null) }) asyncTest('destroyKeyPair clears memory and storage', async () => { await cryptoService.unlock(TEST_UUID) cryptoService.destroyKeyPair() assertEquals(cryptoService.keyPair, null) assertEquals(localStorage.getItem('kashilo_keypair'), null) assertEquals(localStorage.getItem('kashilo_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('kashilo_keypair', JSON.stringify(plaintext)) localStorage.removeItem('kashilo_keypair_salt') await cryptoService.unlock(TEST_UUID) assertEquals(cryptoService.getPublicKey(), plaintext.publicKey) const stored = JSON.parse(localStorage.getItem('kashilo_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) }) }) // ── Per-listing keypair management ── describe('Per-listing keypair management', () => { asyncTest('generateListingKeyPair creates and stores keypair', async () => { localStorage.removeItem('kashilo_listing_keys') await cryptoService.unlock(TEST_UUID) const publicKey = await cryptoService.generateListingKeyPair('test-listing-1') assertTrue(publicKey !== null) assertTrue(publicKey.length > 10) }) asyncTest('getListingSecretKey retrieves stored key', async () => { const secretKey = await cryptoService.getListingSecretKey('test-listing-1') assertTrue(secretKey !== null) assertTrue(secretKey.length > 10) }) asyncTest('getListingSecretKey returns null for unknown listing', async () => { const secretKey = await cryptoService.getListingSecretKey('nonexistent') assertEquals(secretKey, null) }) asyncTest('encrypt/decrypt with listing-specific secret key', async () => { await cryptoService.ready const nacl = cryptoService.nacl const naclUtil = cryptoService.naclUtil // Simulate: seller creates listing key, buyer uses account key const buyerKp = nacl.box.keyPair() const buyerPub = naclUtil.encodeBase64(buyerKp.publicKey) // Seller generates listing keypair await cryptoService.unlock(TEST_UUID) const sellerListingPub = await cryptoService.generateListingKeyPair('test-listing-2') const sellerListingSk = await cryptoService.getListingSecretKey('test-listing-2') // Buyer encrypts to seller listing key cryptoService.keyPair = buyerKp const { nonce, ciphertext } = cryptoService.encrypt('Hello seller!', sellerListingPub) // Seller decrypts using listing secret key (not account key) const plaintext = cryptoService.decrypt(ciphertext, nonce, buyerPub, sellerListingSk) assertEquals(plaintext, 'Hello seller!') }) asyncTest('seller can encrypt back to buyer using listing key', async () => { await cryptoService.ready const nacl = cryptoService.nacl const naclUtil = cryptoService.naclUtil const buyerKp = nacl.box.keyPair() const buyerPub = naclUtil.encodeBase64(buyerKp.publicKey) const sellerListingSk = await cryptoService.getListingSecretKey('test-listing-2') assertTrue(sellerListingSk !== null) // Seller encrypts with listing key const { nonce, ciphertext } = cryptoService.encrypt('Hi buyer!', buyerPub, sellerListingSk) // Buyer decrypts const sk = naclUtil.decodeBase64(sellerListingSk) const kp = nacl.box.keyPair.fromSecretKey(sk) const sellerListingPub = naclUtil.encodeBase64(kp.publicKey) cryptoService.keyPair = buyerKp const plaintext = cryptoService.decrypt(ciphertext, nonce, sellerListingPub) assertEquals(plaintext, 'Hi buyer!') }) asyncTest('destroyKeyPair also clears listing keys', async () => { await cryptoService.unlock(TEST_UUID) await cryptoService.generateListingKeyPair('temp-listing') cryptoService.destroyKeyPair() assertEquals(localStorage.getItem('kashilo_listing_keys'), null) }) }) // ── Key pinning (TOFU) ── describe('KeyPinningService (TOFU)', () => { asyncTest('check returns "new" for unknown listing and pins it', async () => { keyPinningService.clear() const result = keyPinningService.check('listing-a', 'pubkey-123') assertEquals(result, 'new') }) asyncTest('check returns "ok" for matching pinned key', async () => { const result = keyPinningService.check('listing-a', 'pubkey-123') assertEquals(result, 'ok') }) asyncTest('check returns "changed" when key differs from pinned', async () => { const result = keyPinningService.check('listing-a', 'pubkey-different') assertEquals(result, 'changed') }) asyncTest('acceptChange updates pinned key', async () => { keyPinningService.acceptChange('listing-a', 'pubkey-different') const result = keyPinningService.check('listing-a', 'pubkey-different') assertEquals(result, 'ok') }) asyncTest('unpin removes pin for listing', async () => { keyPinningService.unpin('listing-a') const result = keyPinningService.check('listing-a', 'pubkey-new') assertEquals(result, 'new') }) asyncTest('clear removes all pinned keys', async () => { keyPinningService.check('listing-x', 'key-x') keyPinningService.check('listing-y', 'key-y') keyPinningService.clear() assertEquals(keyPinningService.check('listing-x', 'key-x'), 'new') assertEquals(keyPinningService.check('listing-y', 'key-y'), 'new') keyPinningService.clear() }) }) // Cleanup after all tests describe('Crypto test cleanup', () => { asyncTest('cleanup test data from storage', async () => { cryptoService.destroyKeyPair() assertTrue(true) }) })