From 7d214dc6141249b283b991b7b05ecf1b3cd528c0 Mon Sep 17 00:00:00 2001 From: Sterister Date: Fri, 10 Apr 2026 00:19:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20persistent=20storage=20=E2=80=94=20SQLi?= =?UTF-8?q?te=20backends=20for=20crash=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shade sessions and keys now survive server crashes, container restarts, and power outages via SQLite with WAL mode. New packages: - @shade/storage-sqlite: SQLiteStorage (StorageProvider) + SqlitePrekeyStore (PrekeyStore), both using bun:sqlite with auto-created tables and WAL mode - Serialization layer in shade-core for SessionState/keys ↔ JSON/base64 Docker usage: mount volume at /data, set SHADE_DB_PATH=/data/shade-client.db Prekey server auto-detects SHADE_PREKEY_DB_PATH for SQLite persistence Includes crash recovery integration test: encrypt → close DB → reopen → conversation continues seamlessly. 129 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 11 + docs/shade-overview.html | 618 ++++++++++++++++++ packages/shade-core/src/index.ts | 1 + packages/shade-core/src/serialization.ts | 158 +++++ .../shade-core/tests/serialization.test.ts | 170 +++++ packages/shade-server/src/standalone.ts | 26 +- packages/shade-storage-sqlite/package.json | 12 + packages/shade-storage-sqlite/src/index.ts | 2 + .../src/sqlite-prekey-store.ts | 138 ++++ .../src/sqlite-storage.ts | 202 ++++++ .../tests/sqlite-prekey-store.test.ts | 140 ++++ .../tests/sqlite-storage.test.ts | 243 +++++++ packages/shade-storage-sqlite/tsconfig.json | 5 + 13 files changed, 1719 insertions(+), 7 deletions(-) create mode 100644 docs/shade-overview.html create mode 100644 packages/shade-core/src/serialization.ts create mode 100644 packages/shade-core/tests/serialization.test.ts create mode 100644 packages/shade-storage-sqlite/package.json create mode 100644 packages/shade-storage-sqlite/src/index.ts create mode 100644 packages/shade-storage-sqlite/src/sqlite-prekey-store.ts create mode 100644 packages/shade-storage-sqlite/src/sqlite-storage.ts create mode 100644 packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts create mode 100644 packages/shade-storage-sqlite/tests/sqlite-storage.test.ts create mode 100644 packages/shade-storage-sqlite/tsconfig.json diff --git a/bun.lock b/bun.lock index 212f2b9..b3fce2a 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,15 @@ "hono": "^4.12.12", }, }, + "packages/shade-storage-sqlite": { + "name": "@shade/storage-sqlite", + "version": "0.1.0", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/crypto-web": "workspace:*", + "@shade/server": "workspace:*", + }, + }, "packages/shade-transport": { "name": "@shade/transport", "version": "0.1.0", @@ -67,6 +76,8 @@ "@shade/server": ["@shade/server@workspace:packages/shade-server"], + "@shade/storage-sqlite": ["@shade/storage-sqlite@workspace:packages/shade-storage-sqlite"], + "@shade/transport": ["@shade/transport@workspace:packages/shade-transport"], "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], diff --git a/docs/shade-overview.html b/docs/shade-overview.html new file mode 100644 index 0000000..788a2a8 --- /dev/null +++ b/docs/shade-overview.html @@ -0,0 +1,618 @@ + + + + + + Shade — ende-til-ende kryptering som modul + + + + + + +
+
+

Shade

+

+ En gjenbrukbar modul for ende-til-ende-kryptert kommunikasjon i egne apper — med samme type protokoll som brukes i Signal. +

+
+ X3DH + Double Ratchet + TypeScript + Plattformagnostisk crypto +
+
+ +
+

Hva gjør prosjektet?

+

+ Shade er et monorepo som implementerer sikker meldingskryptering mellom to parter (for eksempel nettleser og server, eller to klienter). Meldingene er kryptert slik at transportlaget (HTTP, WebSocket, e.l.) bare ser uleselige bytes — ikke innholdet. +

+
+ Kjerneideen: Du bygger inn ShadeSessionManager (fra @shade/core) sammen med en CryptoProvider (f.eks. Web Crypto i nettleser/Bun) og lagring. Deretter kan du kalle encrypt / decrypt per motpart, akkurat som i demo-koden demo.ts. +
+

+ Første melding til noen ny inneholder nøkkelavtale (X3DH). Etterpå bruker hver melding Double Ratchet: nye meldingsnøkler og periodiske DH-steg gir forward secrecy (gamle meldinger overlever ikke nøkkellekkasje) og post-compromise security (systemet «helbreder» seg over tid etter kompromittering). +

+
+ +
+

Pakkene (hvordan det henger sammen)

+
+ + + + + +
+
+

Protokollen. X3DH, Double Ratchet, sesjonstyper, feiltyper. Ingen plattformkrypto her — bare grensesnittet CryptoProvider.

+
    +
  • ShadeSessionManager — høynivå-API: initialize, createPreKeyBundle, initSessionFromBundle, encrypt, decrypt
  • +
  • Symmetrisk kryptering: AES-256-GCM med AAD fra ratchet-header
  • +
+
+ + + + +
+ +
+

Nøkler i korthet

+
+
+ +
+ Ed25519 brukes til å signere den «signerte prekeyen». X25519 brukes i Diffie-Hellman i X3DH og i ratchet. Én identitet per enhet/bruker i typisk oppsett. +
+
+
+ + +
+
+ + +
+
+
+ +
+

Interaktiv flyt: fra null til kryptert melding

+

+ Klikk «Neste» for å gå gjennom rekkefølgen slik Shade er bygget. Dette speiler ShadeSessionManager og demoen i repoet. +

+
+

Sesjon og meldinger

+
+
+ + +
+
+
+ +
+

X3DH og Double Ratchet (kort forklart)

+

+ X3DH løser problemet «jeg vil snakke med Bob nå, men Bob svarer ikke før senere». Bob legger ut en prekey bundle på serveren. Alice henter den, gjør 3 eller 4 DH-operasjoner (avhengig av om engangsnøkkel brukes), og deriverer en felles rot-nøkkel som begge kan rekonstruere uten at serveren kjenner hemmeligheten. +

+

+ Double Ratchet bruker den roten som startpunkt. For hver melding (eller ved nye DH-nøkler) avledes nye nøkler; meldinger på ledningen er AES-GCM med autentisering (AAD binder kryptoteksten til ratchet-header). Protokollen håndterer også meldinger i feil rekkefølge innenfor grenser (MAX_SKIP). +

+

+ Spesifikasjoner fra Signal (engelsk): X3DH · Double Ratchet. +

+
+ +
+

Bruke Shade i flere prosjekter

+

+ Tenk på Shade som tre lag du kan kombinere etter behov: +

+
    +
  1. Core + crypto-provider + storage — selve E2EE-motoren (kan kjøre i klient eller serverprosess som skal dekryptere).
  2. +
  3. Proto — når du vil ha kompakt binær serialisering.
  4. +
  5. Transport + prekey-server — når du vil standardisere nøkkelutveksling og kanaler.
  6. +
+

+ Referansekjøring: bun demo.ts i rotmappen viser frontend/backend-flyt med minnelager og ekte kryptoprimitiver. +

+
+ +
+

Shade — oversikt generert som statisk HTML i docs/shade-overview.html. Åpne filen direkte i nettleseren eller server den statisk.

+
+
+ + + + diff --git a/packages/shade-core/src/index.ts b/packages/shade-core/src/index.ts index 1ebffc7..707f760 100644 --- a/packages/shade-core/src/index.ts +++ b/packages/shade-core/src/index.ts @@ -6,3 +6,4 @@ export * from './errors.js'; export * from './x3dh.js'; export * from './ratchet.js'; export { ShadeSessionManager } from './session.js'; +export * from './serialization.js'; diff --git a/packages/shade-core/src/serialization.ts b/packages/shade-core/src/serialization.ts new file mode 100644 index 0000000..53e14b0 --- /dev/null +++ b/packages/shade-core/src/serialization.ts @@ -0,0 +1,158 @@ +import type { IdentityKeyPair, KeyPair, SignedPreKey, OneTimePreKey, SessionState, ChainState } from './types.js'; + +// ─── Base64 Helpers ────────────────────────────────────────── + +export function toBase64(buf: Uint8Array): string { + return Buffer.from(buf).toString('base64'); +} + +export function fromBase64(str: string): Uint8Array { + return new Uint8Array(Buffer.from(str, 'base64')); +} + +// ─── KeyPair ───────────────────────────────────────────────── + +interface SerializedKeyPair { publicKey: string; privateKey: string } + +function serializeKeyPair(kp: KeyPair): SerializedKeyPair { + return { publicKey: toBase64(kp.publicKey), privateKey: toBase64(kp.privateKey) }; +} + +function deserializeKeyPair(s: SerializedKeyPair): KeyPair { + return { publicKey: fromBase64(s.publicKey), privateKey: fromBase64(s.privateKey) }; +} + +// ─── IdentityKeyPair ───────────────────────────────────────── + +export function serializeIdentityKeyPair(ikp: IdentityKeyPair): string { + return JSON.stringify({ + signingPublicKey: toBase64(ikp.signingPublicKey), + signingPrivateKey: toBase64(ikp.signingPrivateKey), + dhPublicKey: toBase64(ikp.dhPublicKey), + dhPrivateKey: toBase64(ikp.dhPrivateKey), + }); +} + +export function deserializeIdentityKeyPair(json: string): IdentityKeyPair { + const o = JSON.parse(json); + return { + signingPublicKey: fromBase64(o.signingPublicKey), + signingPrivateKey: fromBase64(o.signingPrivateKey), + dhPublicKey: fromBase64(o.dhPublicKey), + dhPrivateKey: fromBase64(o.dhPrivateKey), + }; +} + +// ─── SignedPreKey ──────────────────────────────────────────── + +export function serializeSignedPreKey(spk: SignedPreKey): string { + return JSON.stringify({ + keyId: spk.keyId, + keyPair: serializeKeyPair(spk.keyPair), + signature: toBase64(spk.signature), + timestamp: spk.timestamp, + }); +} + +export function deserializeSignedPreKey(json: string): SignedPreKey { + const o = JSON.parse(json); + return { + keyId: o.keyId, + keyPair: deserializeKeyPair(o.keyPair), + signature: fromBase64(o.signature), + timestamp: o.timestamp, + }; +} + +// ─── OneTimePreKey ────────────────────────────────────────── + +export function serializeOneTimePreKey(otpk: OneTimePreKey): string { + return JSON.stringify({ + keyId: otpk.keyId, + keyPair: serializeKeyPair(otpk.keyPair), + }); +} + +export function deserializeOneTimePreKey(json: string): OneTimePreKey { + const o = JSON.parse(json); + return { keyId: o.keyId, keyPair: deserializeKeyPair(o.keyPair) }; +} + +// ─── ChainState ───────────────────────────────────────────── + +function serializeChain(c: ChainState): { chainKey: string; counter: number } { + return { chainKey: toBase64(c.chainKey), counter: c.counter }; +} + +function deserializeChain(o: { chainKey: string; counter: number }): ChainState { + return { chainKey: fromBase64(o.chainKey), counter: o.counter }; +} + +// ─── SessionState ─────────────────────────────────────────── + +export function serializeSessionState(state: SessionState): string { + const skipped: Record = {}; + for (const [k, v] of state.skippedKeys) { + skipped[k] = toBase64(v); + } + + const obj: any = { + remoteIdentityKey: toBase64(state.remoteIdentityKey), + rootKey: toBase64(state.rootKey), + sendChain: serializeChain(state.sendChain), + receiveChain: state.receiveChain ? serializeChain(state.receiveChain) : null, + dhSend: serializeKeyPair(state.dhSend), + dhReceive: state.dhReceive ? toBase64(state.dhReceive) : null, + previousSendCounter: state.previousSendCounter, + skippedKeys: skipped, + }; + + // Preserve __x3dh metadata if present (used for first PreKeyMessage) + const x3dh = (state as any).__x3dh; + if (x3dh) { + obj.__x3dh = { + ephemeralPublicKey: toBase64(x3dh.ephemeralPublicKey), + signedPreKeyId: x3dh.signedPreKeyId, + preKeyId: x3dh.preKeyId, + identityDHKey: toBase64(x3dh.identityDHKey), + registrationId: x3dh.registrationId, + }; + } + + return JSON.stringify(obj); +} + +export function deserializeSessionState(json: string): SessionState { + const o = JSON.parse(json); + + const skippedKeys = new Map(); + if (o.skippedKeys) { + for (const [k, v] of Object.entries(o.skippedKeys)) { + skippedKeys.set(k, fromBase64(v as string)); + } + } + + const state: SessionState = { + remoteIdentityKey: fromBase64(o.remoteIdentityKey), + rootKey: fromBase64(o.rootKey), + sendChain: deserializeChain(o.sendChain), + receiveChain: o.receiveChain ? deserializeChain(o.receiveChain) : null, + dhSend: deserializeKeyPair(o.dhSend), + dhReceive: o.dhReceive ? fromBase64(o.dhReceive) : null, + previousSendCounter: o.previousSendCounter, + skippedKeys, + }; + + // Restore __x3dh metadata if present + if (o.__x3dh) { + (state as any).__x3dh = { + ephemeralPublicKey: fromBase64(o.__x3dh.ephemeralPublicKey), + signedPreKeyId: o.__x3dh.signedPreKeyId, + preKeyId: o.__x3dh.preKeyId, + identityDHKey: fromBase64(o.__x3dh.identityDHKey), + registrationId: o.__x3dh.registrationId, + }; + } + + return state; +} diff --git a/packages/shade-core/tests/serialization.test.ts b/packages/shade-core/tests/serialization.test.ts new file mode 100644 index 0000000..c6647d0 --- /dev/null +++ b/packages/shade-core/tests/serialization.test.ts @@ -0,0 +1,170 @@ +import { describe, test, expect } from 'bun:test'; +import { + toBase64, fromBase64, + serializeIdentityKeyPair, deserializeIdentityKeyPair, + serializeSignedPreKey, deserializeSignedPreKey, + serializeOneTimePreKey, deserializeOneTimePreKey, + serializeSessionState, deserializeSessionState, +} from '../src/serialization.js'; +import type { IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '../src/types.js'; + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + crypto.getRandomValues(buf); + return buf; +} + +describe('Serialization', () => { + describe('base64', () => { + test('roundtrip for various lengths', () => { + for (const len of [0, 1, 12, 32, 64, 256]) { + const buf = randBytes(len); + expect(fromBase64(toBase64(buf))).toEqual(buf); + } + }); + }); + + describe('IdentityKeyPair', () => { + test('roundtrip', () => { + const ikp: IdentityKeyPair = { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }; + const json = serializeIdentityKeyPair(ikp); + const restored = deserializeIdentityKeyPair(json); + expect(restored.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(restored.signingPrivateKey).toEqual(ikp.signingPrivateKey); + expect(restored.dhPublicKey).toEqual(ikp.dhPublicKey); + expect(restored.dhPrivateKey).toEqual(ikp.dhPrivateKey); + }); + }); + + describe('SignedPreKey', () => { + test('roundtrip', () => { + const spk: SignedPreKey = { + keyId: 42, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + signature: randBytes(64), + timestamp: Date.now(), + }; + const restored = deserializeSignedPreKey(serializeSignedPreKey(spk)); + expect(restored.keyId).toBe(42); + expect(restored.keyPair.publicKey).toEqual(spk.keyPair.publicKey); + expect(restored.signature).toEqual(spk.signature); + expect(restored.timestamp).toBe(spk.timestamp); + }); + }); + + describe('OneTimePreKey', () => { + test('roundtrip', () => { + const otpk: OneTimePreKey = { + keyId: 100, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + }; + const restored = deserializeOneTimePreKey(serializeOneTimePreKey(otpk)); + expect(restored.keyId).toBe(100); + expect(restored.keyPair.publicKey).toEqual(otpk.keyPair.publicKey); + expect(restored.keyPair.privateKey).toEqual(otpk.keyPair.privateKey); + }); + }); + + describe('SessionState', () => { + test('roundtrip with all fields', () => { + const state: SessionState = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 5 }, + receiveChain: { chainKey: randBytes(32), counter: 3 }, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: randBytes(32), + previousSendCounter: 4, + skippedKeys: new Map([ + ['abc:1', randBytes(32)], + ['def:2', randBytes(32)], + ]), + }; + + const restored = deserializeSessionState(serializeSessionState(state)); + expect(restored.remoteIdentityKey).toEqual(state.remoteIdentityKey); + expect(restored.rootKey).toEqual(state.rootKey); + expect(restored.sendChain.chainKey).toEqual(state.sendChain.chainKey); + expect(restored.sendChain.counter).toBe(5); + expect(restored.receiveChain!.chainKey).toEqual(state.receiveChain!.chainKey); + expect(restored.receiveChain!.counter).toBe(3); + expect(restored.dhSend.publicKey).toEqual(state.dhSend.publicKey); + expect(restored.dhReceive).toEqual(state.dhReceive); + expect(restored.previousSendCounter).toBe(4); + expect(restored.skippedKeys.size).toBe(2); + expect(restored.skippedKeys.get('abc:1')).toEqual(state.skippedKeys.get('abc:1')); + expect(restored.skippedKeys.get('def:2')).toEqual(state.skippedKeys.get('def:2')); + }); + + test('roundtrip with null receiveChain and dhReceive', () => { + const state: SessionState = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 0 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }; + + const restored = deserializeSessionState(serializeSessionState(state)); + expect(restored.receiveChain).toBeNull(); + expect(restored.dhReceive).toBeNull(); + expect(restored.skippedKeys.size).toBe(0); + }); + + test('roundtrip preserves __x3dh metadata', () => { + const state: SessionState = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 0 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }; + + const ephKey = randBytes(32); + const idKey = randBytes(32); + (state as any).__x3dh = { + ephemeralPublicKey: ephKey, + signedPreKeyId: 1, + preKeyId: 100, + identityDHKey: idKey, + registrationId: 42, + }; + + const restored = deserializeSessionState(serializeSessionState(state)); + const x3dh = (restored as any).__x3dh; + expect(x3dh).toBeDefined(); + expect(x3dh.ephemeralPublicKey).toEqual(ephKey); + expect(x3dh.signedPreKeyId).toBe(1); + expect(x3dh.preKeyId).toBe(100); + expect(x3dh.identityDHKey).toEqual(idKey); + expect(x3dh.registrationId).toBe(42); + }); + + test('roundtrip without __x3dh', () => { + const state: SessionState = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 0 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }; + + const restored = deserializeSessionState(serializeSessionState(state)); + expect((restored as any).__x3dh).toBeUndefined(); + }); + }); +}); diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts index 6fd4bf9..55ab23f 100644 --- a/packages/shade-server/src/standalone.ts +++ b/packages/shade-server/src/standalone.ts @@ -1,10 +1,22 @@ -import { createPrekeyServer } from './index.js'; +import { createPrekeyRoutes } from './routes.js'; +import type { PrekeyStore } from './store.js'; -const server = createPrekeyServer(); +async function createStore(): Promise { + const dbPath = process.env.SHADE_PREKEY_DB_PATH; + if (dbPath) { + const { SqlitePrekeyStore } = await import('@shade/storage-sqlite'); + console.log(`[Shade] Using SQLite prekey store: ${dbPath}`); + return new SqlitePrekeyStore(dbPath); + } + const { MemoryPrekeyStore } = await import('./memory-store.js'); + console.log('[Shade] Using in-memory prekey store (data will not persist)'); + return new MemoryPrekeyStore(); +} -export default { - port: Number(process.env.PORT ?? 3900), - fetch: server.fetch, -}; +const store = await createStore(); +const server = createPrekeyRoutes(store); +const port = Number(process.env.PORT ?? 3900); -console.log(`Shade Prekey Server listening on port ${process.env.PORT ?? 3900}`); +export default { port, fetch: server.fetch }; + +console.log(`Shade Prekey Server listening on port ${port}`); diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json new file mode 100644 index 0000000..8361765 --- /dev/null +++ b/packages/shade-storage-sqlite/package.json @@ -0,0 +1,12 @@ +{ + "name": "@shade/storage-sqlite", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/crypto-web": "workspace:*", + "@shade/server": "workspace:*" + } +} diff --git a/packages/shade-storage-sqlite/src/index.ts b/packages/shade-storage-sqlite/src/index.ts new file mode 100644 index 0000000..f8a5ab5 --- /dev/null +++ b/packages/shade-storage-sqlite/src/index.ts @@ -0,0 +1,2 @@ +export { SQLiteStorage } from './sqlite-storage.js'; +export { SqlitePrekeyStore } from './sqlite-prekey-store.js'; diff --git a/packages/shade-storage-sqlite/src/sqlite-prekey-store.ts b/packages/shade-storage-sqlite/src/sqlite-prekey-store.ts new file mode 100644 index 0000000..59011c5 --- /dev/null +++ b/packages/shade-storage-sqlite/src/sqlite-prekey-store.ts @@ -0,0 +1,138 @@ +import { Database } from 'bun:sqlite'; +import type { PrekeyStore } from '@shade/server/src/store.js'; +import { toBase64, fromBase64 } from '@shade/core'; + +/** + * SQLite-backed PrekeyStore for the Shade Prekey Server. + * + * Stores PUBLIC keys only (never private keys). Used by the prekey server + * Docker container to persist registered identities and prekey bundles. + * + * Docker usage: + * Volume mount /data, set SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db + */ +export class SqlitePrekeyStore implements PrekeyStore { + private db: Database; + + private stmts!: { + saveIdentity: ReturnType; + getIdentity: ReturnType; + saveSignedPreKey: ReturnType; + getSignedPreKey: ReturnType; + insertOTPK: ReturnType; + consumeOTPK: ReturnType; + countOTPK: ReturnType; + deleteIdentity: ReturnType; + deleteSignedPreKey: ReturnType; + deleteOTPKs: ReturnType; + }; + + constructor(dbPath?: string) { + const path = dbPath ?? process.env.SHADE_PREKEY_DB_PATH ?? '/data/shade-prekeys.db'; + this.db = new Database(path, { create: true }); + this.db.exec('PRAGMA journal_mode=WAL'); + this.ensureTables(); + this.prepareStatements(); + } + + private ensureTables() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS identities ( + address TEXT PRIMARY KEY, + identity_signing_key TEXT NOT NULL, + identity_dh_key TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS signed_prekeys ( + address TEXT PRIMARY KEY, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + signature TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS one_time_prekeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_otp_address ON one_time_prekeys(address); + `); + } + + private prepareStatements() { + this.stmts = { + saveIdentity: this.db.prepare('INSERT OR REPLACE INTO identities (address, identity_signing_key, identity_dh_key) VALUES (?, ?, ?)'), + getIdentity: this.db.prepare('SELECT identity_signing_key, identity_dh_key FROM identities WHERE address = ?'), + saveSignedPreKey: this.db.prepare('INSERT OR REPLACE INTO signed_prekeys (address, key_id, public_key, signature) VALUES (?, ?, ?, ?)'), + getSignedPreKey: this.db.prepare('SELECT key_id, public_key, signature FROM signed_prekeys WHERE address = ?'), + insertOTPK: this.db.prepare('INSERT INTO one_time_prekeys (address, key_id, public_key) VALUES (?, ?, ?)'), + consumeOTPK: this.db.prepare('DELETE FROM one_time_prekeys WHERE id = (SELECT id FROM one_time_prekeys WHERE address = ? ORDER BY id LIMIT 1) RETURNING key_id, public_key'), + countOTPK: this.db.prepare('SELECT COUNT(*) as count FROM one_time_prekeys WHERE address = ?'), + deleteIdentity: this.db.prepare('DELETE FROM identities WHERE address = ?'), + deleteSignedPreKey: this.db.prepare('DELETE FROM signed_prekeys WHERE address = ?'), + deleteOTPKs: this.db.prepare('DELETE FROM one_time_prekeys WHERE address = ?'), + }; + } + + close() { + this.db.close(); + } + + async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise { + this.stmts.saveIdentity.run(address, toBase64(identitySigningKey), toBase64(identityDHKey)); + } + + async getIdentity(address: string): Promise<{ identitySigningKey: Uint8Array; identityDHKey: Uint8Array } | null> { + const row = this.stmts.getIdentity.get(address) as any; + if (!row) return null; + return { + identitySigningKey: fromBase64(row.identity_signing_key), + identityDHKey: fromBase64(row.identity_dh_key), + }; + } + + async saveSignedPreKey(address: string, keyId: number, publicKey: Uint8Array, signature: Uint8Array): Promise { + this.stmts.saveSignedPreKey.run(address, keyId, toBase64(publicKey), toBase64(signature)); + } + + async getSignedPreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array; signature: Uint8Array } | null> { + const row = this.stmts.getSignedPreKey.get(address) as any; + if (!row) return null; + return { + keyId: row.key_id, + publicKey: fromBase64(row.public_key), + signature: fromBase64(row.signature), + }; + } + + async saveOneTimePreKeys(address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>): Promise { + const insertMany = this.db.transaction(() => { + for (const k of keys) { + this.stmts.insertOTPK.run(address, k.keyId, toBase64(k.publicKey)); + } + }); + insertMany(); + } + + async consumeOneTimePreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array } | null> { + const row = this.stmts.consumeOTPK.get(address) as any; + if (!row) return null; + return { + keyId: row.key_id, + publicKey: fromBase64(row.public_key), + }; + } + + async getOneTimePreKeyCount(address: string): Promise { + const row = this.stmts.countOTPK.get(address) as any; + return row.count; + } + + async deleteAll(address: string): Promise { + const deleteAllTx = this.db.transaction(() => { + this.stmts.deleteIdentity.run(address); + this.stmts.deleteSignedPreKey.run(address); + this.stmts.deleteOTPKs.run(address); + }); + deleteAllTx(); + } +} diff --git a/packages/shade-storage-sqlite/src/sqlite-storage.ts b/packages/shade-storage-sqlite/src/sqlite-storage.ts new file mode 100644 index 0000000..2c4d4c0 --- /dev/null +++ b/packages/shade-storage-sqlite/src/sqlite-storage.ts @@ -0,0 +1,202 @@ +import { Database } from 'bun:sqlite'; +import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState } from '@shade/core'; +import { + toBase64, fromBase64, + serializeSessionState, deserializeSessionState, + serializeSignedPreKey, deserializeSignedPreKey, + serializeOneTimePreKey, deserializeOneTimePreKey, +} from '@shade/core'; + +/** + * SQLite-backed StorageProvider for Shade client-side key/session storage. + * + * Uses bun:sqlite (built-in, zero deps). Stores private keys — for trusted environments only. + * WAL mode enabled for crash safety. Auto-creates tables on first use. + * + * Docker usage: + * Volume mount /data, set SHADE_DB_PATH=/data/shade-client.db + */ +export class SQLiteStorage implements StorageProvider { + private db: Database; + + // Prepared statements + private stmts!: { + getIdentity: ReturnType; + saveIdentity: ReturnType; + getConfig: ReturnType; + saveConfig: ReturnType; + getSignedPreKey: ReturnType; + saveSignedPreKey: ReturnType; + removeSignedPreKey: ReturnType; + getOneTimePreKey: ReturnType; + saveOneTimePreKey: ReturnType; + removeOneTimePreKey: ReturnType; + countOneTimePreKeys: ReturnType; + getSession: ReturnType; + saveSession: ReturnType; + removeSession: ReturnType; + getTrust: ReturnType; + saveTrust: ReturnType; + }; + + constructor(dbPath?: string) { + const path = dbPath ?? process.env.SHADE_DB_PATH ?? '/data/shade-client.db'; + this.db = new Database(path, { create: true }); + this.db.exec('PRAGMA journal_mode=WAL'); + this.ensureTables(); + this.prepareStatements(); + } + + private ensureTables() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + signing_public_key TEXT NOT NULL, + signing_private_key TEXT NOT NULL, + dh_public_key TEXT NOT NULL, + dh_private_key TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS signed_prekeys ( + key_id INTEGER PRIMARY KEY, + data_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS one_time_prekeys ( + key_id INTEGER PRIMARY KEY, + data_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + address TEXT PRIMARY KEY, + state_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS trusted_identities ( + address TEXT PRIMARY KEY, + identity_key TEXT NOT NULL + ); + `); + } + + private prepareStatements() { + this.stmts = { + getIdentity: this.db.prepare('SELECT * FROM identity WHERE id = 1'), + saveIdentity: this.db.prepare('INSERT OR REPLACE INTO identity (id, signing_public_key, signing_private_key, dh_public_key, dh_private_key) VALUES (1, ?, ?, ?, ?)'), + getConfig: this.db.prepare('SELECT value FROM config WHERE key = ?'), + saveConfig: this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)'), + getSignedPreKey: this.db.prepare('SELECT data_json FROM signed_prekeys WHERE key_id = ?'), + saveSignedPreKey: this.db.prepare('INSERT OR REPLACE INTO signed_prekeys (key_id, data_json) VALUES (?, ?)'), + removeSignedPreKey: this.db.prepare('DELETE FROM signed_prekeys WHERE key_id = ?'), + getOneTimePreKey: this.db.prepare('SELECT data_json FROM one_time_prekeys WHERE key_id = ?'), + saveOneTimePreKey: this.db.prepare('INSERT OR REPLACE INTO one_time_prekeys (key_id, data_json) VALUES (?, ?)'), + removeOneTimePreKey: this.db.prepare('DELETE FROM one_time_prekeys WHERE key_id = ?'), + countOneTimePreKeys: this.db.prepare('SELECT COUNT(*) as count FROM one_time_prekeys'), + getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'), + saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'), + removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'), + getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'), + saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'), + }; + } + + close() { + this.db.close(); + } + + // ─── Identity ────────────────────────────────────────────── + + async getIdentityKeyPair(): Promise { + const row = this.stmts.getIdentity.get() as any; + if (!row) return null; + return { + signingPublicKey: fromBase64(row.signing_public_key), + signingPrivateKey: fromBase64(row.signing_private_key), + dhPublicKey: fromBase64(row.dh_public_key), + dhPrivateKey: fromBase64(row.dh_private_key), + }; + } + + async saveIdentityKeyPair(kp: IdentityKeyPair): Promise { + this.stmts.saveIdentity.run( + toBase64(kp.signingPublicKey), + toBase64(kp.signingPrivateKey), + toBase64(kp.dhPublicKey), + toBase64(kp.dhPrivateKey), + ); + } + + async getLocalRegistrationId(): Promise { + const row = this.stmts.getConfig.get('registrationId') as any; + return row ? parseInt(row.value, 10) : 0; + } + + async saveLocalRegistrationId(id: number): Promise { + this.stmts.saveConfig.run('registrationId', String(id)); + } + + // ─── Signed PreKeys ─────────────────────────────────────── + + async getSignedPreKey(keyId: number): Promise { + const row = this.stmts.getSignedPreKey.get(keyId) as any; + if (!row) return null; + return deserializeSignedPreKey(row.data_json); + } + + async saveSignedPreKey(key: SignedPreKey): Promise { + this.stmts.saveSignedPreKey.run(key.keyId, serializeSignedPreKey(key)); + } + + async removeSignedPreKey(keyId: number): Promise { + this.stmts.removeSignedPreKey.run(keyId); + } + + // ─── One-Time PreKeys ───────────────────────────────────── + + async getOneTimePreKey(keyId: number): Promise { + const row = this.stmts.getOneTimePreKey.get(keyId) as any; + if (!row) return null; + return deserializeOneTimePreKey(row.data_json); + } + + async saveOneTimePreKey(key: OneTimePreKey): Promise { + this.stmts.saveOneTimePreKey.run(key.keyId, serializeOneTimePreKey(key)); + } + + async removeOneTimePreKey(keyId: number): Promise { + this.stmts.removeOneTimePreKey.run(keyId); + } + + async getOneTimePreKeyCount(): Promise { + const row = this.stmts.countOneTimePreKeys.get() as any; + return row.count; + } + + // ─── Sessions ───────────────────────────────────────────── + + async getSession(address: string): Promise { + const row = this.stmts.getSession.get(address) as any; + if (!row) return null; + return deserializeSessionState(row.state_json); + } + + async saveSession(address: string, state: SessionState): Promise { + this.stmts.saveSession.run(address, serializeSessionState(state)); + } + + async removeSession(address: string): Promise { + this.stmts.removeSession.run(address); + } + + // ─── Trust ──────────────────────────────────────────────── + + async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + const row = this.stmts.getTrust.get(address) as any; + if (!row) return true; // TOFU + return row.identity_key === toBase64(identityKey); + } + + async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + this.stmts.saveTrust.run(address, toBase64(identityKey)); + } +} diff --git a/packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts b/packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts new file mode 100644 index 0000000..6fdb58d --- /dev/null +++ b/packages/shade-storage-sqlite/tests/sqlite-prekey-store.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { unlinkSync } from 'fs'; +import { SqlitePrekeyStore } from '../src/sqlite-prekey-store.js'; + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +function tempDbPath(): string { + return join(tmpdir(), `shade-prekey-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe('SqlitePrekeyStore', () => { + let dbPath: string; + let store: SqlitePrekeyStore; + + beforeEach(() => { + dbPath = tempDbPath(); + store = new SqlitePrekeyStore(dbPath); + }); + + afterEach(() => { + store.close(); + try { unlinkSync(dbPath); } catch {} + try { unlinkSync(dbPath + '-wal'); } catch {} + try { unlinkSync(dbPath + '-shm'); } catch {} + }); + + describe('identity', () => { + test('save and get identity', async () => { + const sigKey = randBytes(32); + const dhKey = randBytes(32); + await store.saveIdentity('bob', sigKey, dhKey); + const id = await store.getIdentity('bob'); + expect(id).not.toBeNull(); + expect(id!.identitySigningKey).toEqual(sigKey); + expect(id!.identityDHKey).toEqual(dhKey); + }); + + test('returns null for unknown address', async () => { + expect(await store.getIdentity('nobody')).toBeNull(); + }); + }); + + describe('signed prekeys', () => { + test('save and get signed prekey', async () => { + const pubKey = randBytes(32); + const sig = randBytes(64); + await store.saveSignedPreKey('bob', 1, pubKey, sig); + const spk = await store.getSignedPreKey('bob'); + expect(spk!.keyId).toBe(1); + expect(spk!.publicKey).toEqual(pubKey); + expect(spk!.signature).toEqual(sig); + }); + }); + + describe('one-time prekeys', () => { + test('FIFO consumption', async () => { + await store.saveOneTimePreKeys('bob', [ + { keyId: 100, publicKey: randBytes(32) }, + { keyId: 101, publicKey: randBytes(32) }, + { keyId: 102, publicKey: randBytes(32) }, + ]); + + expect(await store.getOneTimePreKeyCount('bob')).toBe(3); + + const k1 = await store.consumeOneTimePreKey('bob'); + expect(k1!.keyId).toBe(100); + expect(await store.getOneTimePreKeyCount('bob')).toBe(2); + + const k2 = await store.consumeOneTimePreKey('bob'); + expect(k2!.keyId).toBe(101); + + const k3 = await store.consumeOneTimePreKey('bob'); + expect(k3!.keyId).toBe(102); + + const k4 = await store.consumeOneTimePreKey('bob'); + expect(k4).toBeNull(); + expect(await store.getOneTimePreKeyCount('bob')).toBe(0); + }); + + test('replenish adds more keys', async () => { + await store.saveOneTimePreKeys('bob', [{ keyId: 100, publicKey: randBytes(32) }]); + await store.saveOneTimePreKeys('bob', [{ keyId: 200, publicKey: randBytes(32) }, { keyId: 201, publicKey: randBytes(32) }]); + expect(await store.getOneTimePreKeyCount('bob')).toBe(3); + }); + + test('different addresses are isolated', async () => { + await store.saveOneTimePreKeys('alice', [{ keyId: 1, publicKey: randBytes(32) }]); + await store.saveOneTimePreKeys('bob', [{ keyId: 1, publicKey: randBytes(32) }, { keyId: 2, publicKey: randBytes(32) }]); + + expect(await store.getOneTimePreKeyCount('alice')).toBe(1); + expect(await store.getOneTimePreKeyCount('bob')).toBe(2); + }); + }); + + describe('deleteAll', () => { + test('removes everything for an address', async () => { + await store.saveIdentity('bob', randBytes(32), randBytes(32)); + await store.saveSignedPreKey('bob', 1, randBytes(32), randBytes(64)); + await store.saveOneTimePreKeys('bob', [{ keyId: 100, publicKey: randBytes(32) }]); + + await store.deleteAll('bob'); + + expect(await store.getIdentity('bob')).toBeNull(); + expect(await store.getSignedPreKey('bob')).toBeNull(); + expect(await store.getOneTimePreKeyCount('bob')).toBe(0); + }); + + test('does not affect other addresses', async () => { + await store.saveIdentity('alice', randBytes(32), randBytes(32)); + await store.saveIdentity('bob', randBytes(32), randBytes(32)); + + await store.deleteAll('bob'); + + expect(await store.getIdentity('alice')).not.toBeNull(); + expect(await store.getIdentity('bob')).toBeNull(); + }); + }); + + describe('persistence', () => { + test('data survives close and reopen', async () => { + await store.saveIdentity('bob', randBytes(32), randBytes(32)); + await store.saveOneTimePreKeys('bob', [ + { keyId: 1, publicKey: randBytes(32) }, + { keyId: 2, publicKey: randBytes(32) }, + ]); + + store.close(); + store = new SqlitePrekeyStore(dbPath); + + expect(await store.getIdentity('bob')).not.toBeNull(); + expect(await store.getOneTimePreKeyCount('bob')).toBe(2); + }); + }); +}); diff --git a/packages/shade-storage-sqlite/tests/sqlite-storage.test.ts b/packages/shade-storage-sqlite/tests/sqlite-storage.test.ts new file mode 100644 index 0000000..0e80970 --- /dev/null +++ b/packages/shade-storage-sqlite/tests/sqlite-storage.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { unlinkSync } from 'fs'; +import { SQLiteStorage } from '../src/sqlite-storage.js'; +import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web'; +import { ShadeSessionManager } from '@shade/core'; +import type { IdentityKeyPair, SignedPreKey, OneTimePreKey } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + globalThis.crypto.getRandomValues(buf); + return buf; +} + +function tempDbPath(): string { + return join(tmpdir(), `shade-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe('SQLiteStorage', () => { + let dbPath: string; + let storage: SQLiteStorage; + + beforeEach(() => { + dbPath = tempDbPath(); + storage = new SQLiteStorage(dbPath); + }); + + afterEach(() => { + storage.close(); + try { unlinkSync(dbPath); } catch {} + try { unlinkSync(dbPath + '-wal'); } catch {} + try { unlinkSync(dbPath + '-shm'); } catch {} + }); + + // ─── Identity ────────────────────────────────────────────── + + describe('identity', () => { + test('returns null when no identity stored', async () => { + expect(await storage.getIdentityKeyPair()).toBeNull(); + }); + + test('save and retrieve identity keypair', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }; + await storage.saveIdentityKeyPair(ikp); + const restored = await storage.getIdentityKeyPair(); + expect(restored).not.toBeNull(); + expect(restored!.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(restored!.dhPrivateKey).toEqual(ikp.dhPrivateKey); + }); + + test('registration ID roundtrip', async () => { + expect(await storage.getLocalRegistrationId()).toBe(0); + await storage.saveLocalRegistrationId(42); + expect(await storage.getLocalRegistrationId()).toBe(42); + }); + }); + + // ─── Signed PreKeys ─────────────────────────────────────── + + describe('signed prekeys', () => { + test('save, get, remove', async () => { + const spk: SignedPreKey = { + keyId: 1, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + signature: randBytes(64), + timestamp: Date.now(), + }; + await storage.saveSignedPreKey(spk); + const restored = await storage.getSignedPreKey(1); + expect(restored).not.toBeNull(); + expect(restored!.keyId).toBe(1); + expect(restored!.keyPair.publicKey).toEqual(spk.keyPair.publicKey); + + await storage.removeSignedPreKey(1); + expect(await storage.getSignedPreKey(1)).toBeNull(); + }); + }); + + // ─── One-Time PreKeys ───────────────────────────────────── + + describe('one-time prekeys', () => { + test('save, get, remove, count', async () => { + const otpk: OneTimePreKey = { + keyId: 100, + keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) }, + }; + await storage.saveOneTimePreKey(otpk); + expect(await storage.getOneTimePreKeyCount()).toBe(1); + + const restored = await storage.getOneTimePreKey(100); + expect(restored!.keyId).toBe(100); + + await storage.removeOneTimePreKey(100); + expect(await storage.getOneTimePreKeyCount()).toBe(0); + expect(await storage.getOneTimePreKey(100)).toBeNull(); + }); + }); + + // ─── Sessions ───────────────────────────────────────────── + + describe('sessions', () => { + test('save and restore session with skipped keys', async () => { + const state = { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 5 }, + receiveChain: { chainKey: randBytes(32), counter: 3 }, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: randBytes(32), + previousSendCounter: 4, + skippedKeys: new Map([['key:1', randBytes(32)]]), + }; + + await storage.saveSession('bob', state); + const restored = await storage.getSession('bob'); + expect(restored).not.toBeNull(); + expect(restored!.sendChain.counter).toBe(5); + expect(restored!.skippedKeys.size).toBe(1); + expect(restored!.skippedKeys.get('key:1')).toEqual(state.skippedKeys.get('key:1')); + }); + + test('remove session', async () => { + await storage.saveSession('bob', { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 0 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }); + await storage.removeSession('bob'); + expect(await storage.getSession('bob')).toBeNull(); + }); + }); + + // ─── Trust ──────────────────────────────────────────────── + + describe('trust', () => { + test('TOFU: first use is trusted', async () => { + expect(await storage.isTrustedIdentity('bob', randBytes(32))).toBe(true); + }); + + test('saved identity matches', async () => { + const key = randBytes(32); + await storage.saveTrustedIdentity('bob', key); + expect(await storage.isTrustedIdentity('bob', key)).toBe(true); + expect(await storage.isTrustedIdentity('bob', randBytes(32))).toBe(false); + }); + }); + + // ─── Crash Recovery ─────────────────────────────────────── + + describe('persistence across close/reopen', () => { + test('data survives close and reopen', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: randBytes(32), + signingPrivateKey: randBytes(32), + dhPublicKey: randBytes(32), + dhPrivateKey: randBytes(32), + }; + await storage.saveIdentityKeyPair(ikp); + await storage.saveLocalRegistrationId(99); + await storage.saveSession('alice', { + remoteIdentityKey: randBytes(32), + rootKey: randBytes(32), + sendChain: { chainKey: randBytes(32), counter: 7 }, + receiveChain: null, + dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map(), + }); + + // Close and reopen + storage.close(); + storage = new SQLiteStorage(dbPath); + + const restored = await storage.getIdentityKeyPair(); + expect(restored!.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(await storage.getLocalRegistrationId()).toBe(99); + const session = await storage.getSession('alice'); + expect(session!.sendChain.counter).toBe(7); + }); + }); + + // ─── Full E2EE with SQLiteStorage ───────────────────────── + + describe('full E2EE conversation with persistent storage', () => { + test('encrypt, close, reopen, continue conversation', async () => { + const bobDbPath = tempDbPath(); + let bobStorage = new SQLiteStorage(bobDbPath); + + try { + const alice = new ShadeSessionManager(crypto, storage); + let bob = new ShadeSessionManager(crypto, bobStorage); + await alice.initialize(); + await bob.initialize(); + + const otpks = await bob.generateOneTimePreKeys(5); + const bundle = await bob.createPreKeyBundle(); + bundle.oneTimePreKey = { keyId: otpks[0].keyId, publicKey: otpks[0].keyPair.publicKey }; + + await alice.initSessionFromBundle('bob', bundle); + + // Alice → Bob + const env1 = await alice.encrypt('bob', 'Hello persistent!'); + expect(await bob.decrypt('alice', env1)).toBe('Hello persistent!'); + + // Bob → Alice + const env2 = await bob.encrypt('alice', 'Got it!'); + expect(await alice.decrypt('bob', env2)).toBe('Got it!'); + + // "Crash" Bob — close DB and reopen + bobStorage.close(); + bobStorage = new SQLiteStorage(bobDbPath); + bob = new ShadeSessionManager(crypto, bobStorage); + await bob.initialize(); + + // Continue conversation after "crash" + const env3 = await alice.encrypt('bob', 'After your restart'); + expect(await bob.decrypt('alice', env3)).toBe('After your restart'); + + const env4 = await bob.encrypt('alice', 'I survived!'); + expect(await alice.decrypt('bob', env4)).toBe('I survived!'); + } finally { + bobStorage.close(); + try { unlinkSync(bobDbPath); } catch {} + try { unlinkSync(bobDbPath + '-wal'); } catch {} + try { unlinkSync(bobDbPath + '-shm'); } catch {} + } + }); + }); +}); diff --git a/packages/shade-storage-sqlite/tsconfig.json b/packages/shade-storage-sqlite/tsconfig.json new file mode 100644 index 0000000..a3e0a93 --- /dev/null +++ b/packages/shade-storage-sqlite/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +}