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:
170
packages/shade-core/tests/serialization.test.ts
Normal file
170
packages/shade-core/tests/serialization.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user