CI / Node 24 (push) Successful in 15s
CI / Node 26 (push) Successful in 16s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Failing after 3s
CI / Node 26 (pull_request) Failing after 4s
CI / Chromium (push) Failing after 1m8s
CI / Publish to staging registry (push) Skipped
CI / Chromium (pull_request) Failing after 1m3s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Failing after 4s
- KVStorageEncrypted: AES-GCM per value (fresh IV, storage key as AAD), keys stay readable; decrypts and re-emits pushed changes and passes subscriptions through, so it can be a composite source. generateKey / exportKey / importKey / deriveKey (PBKDF2). - KVStorageHttpServer: allowOrigins (CORS), including cache-control for SSE preflights. - Chromium tests (Vitest browser mode + Playwright) against a real cross-origin server: conformance for IndexedDB, encrypted, HTTP and composite stores; signed requests and SSE; two devices syncing, offline writes surviving a reload. New CI job. - Composite fixes found by those tests: sync() during a running sync schedules one more (changes between the first scan and the stream connecting were missed); a queued write the source refuses is dropped, restored and reported (onError) instead of blocking the queue forever. - README with examples, mirrored in test/readme.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
93 lines
4.7 KiB
TypeScript
93 lines
4.7 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { KVStorageComposite } from '../../src/kv-storage/kv-storage-composite.js'
|
|
import { KVStorageEncrypted } from '../../src/kv-storage/kv-storage-encrypted.js'
|
|
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
|
import { collect } from '../helpers.js'
|
|
import { runKVStorageConformance } from './kv-storage.conformance.js'
|
|
import { TestNetwork } from './sync-helpers.js'
|
|
|
|
const key = await KVStorageEncrypted.generateKey()
|
|
|
|
runKVStorageConformance('KVStorageEncrypted (in memory)', () => new KVStorageEncrypted(new KVStorageInMemory(), { key }))
|
|
|
|
let network: TestNetwork | undefined
|
|
afterEach(async () => {
|
|
await network?.close()
|
|
network = undefined
|
|
})
|
|
|
|
describe('KVStorageEncrypted', () => {
|
|
it('stores only ciphertext, with readable keys', async () => {
|
|
const inner = new KVStorageInMemory()
|
|
await new KVStorageEncrypted(inner, { key }).child('users').set('1', { name: 'Ada', n: 1n })
|
|
const [[storedKey, stored]] = (await collect(inner.scan({ values: true }))) as [[string, Uint8Array]]
|
|
expect(storedKey).toBe('users/1')
|
|
expect(stored).toBeInstanceOf(Uint8Array)
|
|
expect(new TextDecoder().decode(stored)).not.toContain('Ada')
|
|
})
|
|
|
|
it('uses a fresh IV for every write', async () => {
|
|
const inner = new KVStorageInMemory()
|
|
const db = new KVStorageEncrypted(inner, { key })
|
|
await db.set('a', 'same')
|
|
await db.set('b', 'same')
|
|
const [a, b] = (await inner.readMany(['a', 'b'])) as Uint8Array[]
|
|
expect(a).not.toEqual(b)
|
|
})
|
|
|
|
it('rejects a wrong key, a value moved to another key, tampering and plaintext', async () => {
|
|
const inner = new KVStorageInMemory()
|
|
await new KVStorageEncrypted(inner, { key }).set('a', 'secret')
|
|
const other = new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.generateKey() })
|
|
await expect(other.get('a')).rejects.toThrow(/Cannot decrypt/)
|
|
|
|
const db = new KVStorageEncrypted(inner, { key })
|
|
const [stored] = (await inner.readMany(['a'])) as [Uint8Array]
|
|
await inner.writeMany([{ key: 'b', value: stored }])
|
|
await expect(db.get('b')).rejects.toThrow(/Cannot decrypt/)
|
|
|
|
const tampered = stored.slice()
|
|
tampered[tampered.length - 1]! ^= 1
|
|
await inner.writeMany([{ key: 'c', value: tampered }])
|
|
await expect(db.get('c')).rejects.toThrow(/Cannot decrypt/)
|
|
|
|
await inner.writeMany([{ key: 'd', value: 'plain' }])
|
|
await expect(db.get('d')).rejects.toThrow(/not encrypted/)
|
|
})
|
|
|
|
it('round-trips keys through export/import, and derives the same key from the same password and salt', async () => {
|
|
const inner = new KVStorageInMemory()
|
|
await new KVStorageEncrypted(inner, { key }).set('a', 1)
|
|
const imported = await KVStorageEncrypted.importKey(await KVStorageEncrypted.exportKey(key))
|
|
expect(await new KVStorageEncrypted(inner, { key: imported }).get('a')).toBe(1)
|
|
|
|
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).set('p', 'x')
|
|
expect(await new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('hunter2', salt, 1000) }).get('p')).toBe('x')
|
|
await expect(new KVStorageEncrypted(inner, { key: await KVStorageEncrypted.deriveKey('wrong', salt, 1000) }).get('p')).rejects.toThrow(/Cannot decrypt/)
|
|
})
|
|
|
|
it('decrypts pushed changes and passes subscriptions through', async () => {
|
|
network = new TestNetwork()
|
|
const watcher = new KVStorageEncrypted(network.client(), { key })
|
|
await watcher.child('notes').subscribe()
|
|
const pushed = watcher.waitFor('change', (e) => e.origin === 'remote')
|
|
await new KVStorageEncrypted(network.client(), { key }).child('notes').set('1', { text: 'hi' })
|
|
expect((await pushed).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
|
|
})
|
|
|
|
it('works as a composite source: the server stores ciphertext, replicas hold plaintext', async () => {
|
|
network = new TestNetwork()
|
|
const replica = new KVStorageInMemory()
|
|
const a = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [replica] })
|
|
const b = new KVStorageComposite({ source: new KVStorageEncrypted(network.client(), { key }), replicas: [new KVStorageInMemory()] })
|
|
await Promise.all([a.sync(), b.sync()])
|
|
const seen = b.waitFor('change', (e) => e.origin === 'remote')
|
|
await a.child('notes').set('1', { text: 'hi' })
|
|
expect((await seen).changes).toEqual([{ key: 'notes/1', value: { text: 'hi' } }])
|
|
expect(await replica.readMany(['notes/1'])).toEqual([{ text: 'hi' }])
|
|
expect((await network.store.readMany(['notes/1']))[0]).toBeInstanceOf(Uint8Array)
|
|
await Promise.all([a.close(), b.close()])
|
|
})
|
|
})
|