feat: persistent storage — SQLite backends for crash resilience

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 00:19:54 +02:00
parent d071551b2f
commit 7d214dc614
13 changed files with 1719 additions and 7 deletions

View File

@@ -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';

View File

@@ -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<string, string> = {};
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<string, Uint8Array>();
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;
}

View File

@@ -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();
});
});
});