test: add E2E crypto roundtrip and keypair management tests, suppress expected warnings
This commit is contained in:
259
tests/crypto.test.js
Normal file
259
tests/crypto.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -42,7 +42,10 @@ describe('i18n.t (translation)', () => {
|
|||||||
// They test the parameter interpolation logic
|
// They test the parameter interpolation logic
|
||||||
|
|
||||||
test('returns key if translation missing', () => {
|
test('returns key if translation missing', () => {
|
||||||
|
const origWarn = console.warn
|
||||||
|
console.warn = () => {}
|
||||||
const result = i18n.t('this.key.does.not.exist')
|
const result = i18n.t('this.key.does.not.exist')
|
||||||
|
console.warn = origWarn
|
||||||
assertEquals(result, 'this.key.does.not.exist')
|
assertEquals(result, 'this.key.does.not.exist')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
// Service tests (sync + async)
|
// Service tests (sync + async)
|
||||||
await import('./client.test.js')
|
await import('./client.test.js')
|
||||||
await import('./services.test.js')
|
await import('./services.test.js')
|
||||||
|
await import('./crypto.test.js')
|
||||||
|
|
||||||
// Run queued async tests
|
// Run queued async tests
|
||||||
await runAsyncTests()
|
await runAsyncTests()
|
||||||
|
|||||||
Reference in New Issue
Block a user