release(v4.5.0): browser-side encrypted storage + multi-factor unlock
Adds the foundations Prism's web client (and any future browser-based Shade app) needs: at-rest-encrypted IndexedDB storage that mirrors the SQLite backend byte-for-byte at the AAD/nonce level, browser-safe subpath imports so Vite/webpack/esbuild stop hitting bun:sqlite, and KeyManager support for argon2id and N-factor composite unlock. @shade/storage-encrypted - EncryptedIndexedDBStorage (subpath: /idb) — full StorageProvider using one object store per _enc table; reuses aeadSeal/aeadOpen + row-codec sealers so a row sealed under the SQLite or Postgres backend decrypts under IDB given the same KeyManager. bumpPeerIdentityVersion is atomic under one IDB transaction. - KeyManager argon2id source — memory-hard KDF for low-entropy secrets (PINs). Backed by @noble/hashes/argon2 (already a transitive dep). DEFAULT_ARGON2ID exported (m=64 MiB, t=3, p=1). - KeyManager composite source — HKDF-combine N sub-sources into one master. Every source mandatory; order significant by design; composite-of-composite rejected; optional info string for app-level domain separation. - Subpath exports (/crypto, /sqlite, /postgres, /idb) plus a `browser` condition on the default import that resolves to a barrel excluding the Bun- and Postgres-specific entries. Browser bundles no longer pull bun:sqlite transitively. Tests - 73 tests in shade-storage-encrypted (was 31). New coverage: argon2id determinism + reject paths, composite same-factors → same master, wrong-PIN/passphrase/order-swap → different master, info domain separation, all 28 StorageProvider methods on EncryptedIndexedDBStorage, fingerprint-mismatch rejection, and cross-impl roundtrip with EncryptedSQLiteStorage proving the AAD/ nonce derivation is implementation-agnostic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { EncryptedIndexedDBStorage } from '../src/storage/encrypted-indexeddb.js';
|
||||
import { EncryptedSQLiteStorage } from '../src/storage/encrypted-sqlite.js';
|
||||
import { KeyManager } from '../src/crypto/key-manager.js';
|
||||
import type {
|
||||
IdentityKeyPair,
|
||||
OneTimePreKey,
|
||||
PersistedStreamState,
|
||||
SessionState,
|
||||
SignedPreKey,
|
||||
} from '@shade/core';
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
function dummyIdentity(): IdentityKeyPair {
|
||||
return {
|
||||
signingPublicKey: randBytes(32),
|
||||
signingPrivateKey: randBytes(32),
|
||||
dhPublicKey: randBytes(32),
|
||||
dhPrivateKey: randBytes(32),
|
||||
};
|
||||
}
|
||||
|
||||
function dummySignedPreKey(id: number): SignedPreKey {
|
||||
return {
|
||||
keyId: id,
|
||||
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
signature: randBytes(64),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function dummyOTP(id: number): OneTimePreKey {
|
||||
return {
|
||||
keyId: id,
|
||||
keyPair: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
};
|
||||
}
|
||||
|
||||
function dummySession(): SessionState {
|
||||
return {
|
||||
remoteIdentityKey: randBytes(32),
|
||||
rootKey: randBytes(32),
|
||||
sendChain: { chainKey: randBytes(32), counter: 0 },
|
||||
receiveChain: { chainKey: randBytes(32), counter: 0 },
|
||||
dhSend: { publicKey: randBytes(32), privateKey: randBytes(32) },
|
||||
dhReceive: randBytes(32),
|
||||
previousSendCounter: 0,
|
||||
skippedKeys: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueDbName(): string {
|
||||
return `shade-enc-idb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
async function deleteDb(name: string): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const req = indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const KEY_BYTES = randBytes(32);
|
||||
async function freshKM(): Promise<KeyManager> {
|
||||
return KeyManager.open({ kind: 'injected', key: KEY_BYTES });
|
||||
}
|
||||
|
||||
describe('EncryptedIndexedDBStorage', () => {
|
||||
let dbName: string;
|
||||
let store: EncryptedIndexedDBStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
dbName = uniqueDbName();
|
||||
store = await EncryptedIndexedDBStorage.open({ dbName, keyManager: await freshKM() });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.close();
|
||||
await deleteDb(dbName);
|
||||
});
|
||||
|
||||
test('identity round-trip', async () => {
|
||||
expect(await store.getIdentityKeyPair()).toBeNull();
|
||||
const kp = dummyIdentity();
|
||||
await store.saveIdentityKeyPair(kp);
|
||||
const got = await store.getIdentityKeyPair();
|
||||
expect(got).toEqual(kp);
|
||||
});
|
||||
|
||||
test('registrationId round-trip', async () => {
|
||||
expect(await store.getLocalRegistrationId()).toBe(0);
|
||||
await store.saveLocalRegistrationId(987);
|
||||
expect(await store.getLocalRegistrationId()).toBe(987);
|
||||
});
|
||||
|
||||
test('signed prekey round-trip + remove', async () => {
|
||||
expect(await store.getSignedPreKey(7)).toBeNull();
|
||||
const k = dummySignedPreKey(7);
|
||||
await store.saveSignedPreKey(k);
|
||||
const got = await store.getSignedPreKey(7);
|
||||
expect(got?.keyId).toBe(7);
|
||||
expect(got?.keyPair.privateKey).toEqual(k.keyPair.privateKey);
|
||||
await store.removeSignedPreKey(7);
|
||||
expect(await store.getSignedPreKey(7)).toBeNull();
|
||||
});
|
||||
|
||||
test('one-time prekey count + remove', async () => {
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(0);
|
||||
await store.saveOneTimePreKey(dummyOTP(1));
|
||||
await store.saveOneTimePreKey(dummyOTP(2));
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(2);
|
||||
expect(await store.getOneTimePreKey(1)).not.toBeNull();
|
||||
await store.removeOneTimePreKey(1);
|
||||
expect(await store.getOneTimePreKeyCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('session round-trip + remove', async () => {
|
||||
const s = dummySession();
|
||||
await store.saveSession('device:abc', s);
|
||||
const got = await store.getSession('device:abc');
|
||||
expect(got?.rootKey).toEqual(s.rootKey);
|
||||
await store.removeSession('device:abc');
|
||||
expect(await store.getSession('device:abc')).toBeNull();
|
||||
});
|
||||
|
||||
test('TOFU + trust check', async () => {
|
||||
const ik = randBytes(32);
|
||||
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true); // TOFU
|
||||
await store.saveTrustedIdentity('peer-1', ik);
|
||||
expect(await store.isTrustedIdentity('peer-1', ik)).toBe(true);
|
||||
expect(await store.isTrustedIdentity('peer-1', randBytes(32))).toBe(false);
|
||||
});
|
||||
|
||||
test('retired identities are sorted DESC + prune', async () => {
|
||||
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 100 });
|
||||
await store.addRetiredIdentity({ keyPair: dummyIdentity(), retiredAt: 200 });
|
||||
const list = await store.getRetiredIdentities();
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0]!.retiredAt).toBe(200);
|
||||
await store.pruneRetiredIdentities(150);
|
||||
const after = await store.getRetiredIdentities();
|
||||
expect(after.length).toBe(1);
|
||||
expect(after[0]!.retiredAt).toBe(200);
|
||||
});
|
||||
|
||||
test('stream-state round-trip + listActive + prune', async () => {
|
||||
const s: PersistedStreamState = {
|
||||
streamId: 'stream-1',
|
||||
direction: 'send',
|
||||
peerAddress: 'device:bob',
|
||||
status: 'active',
|
||||
metadataJson: '{"name":"file.bin"}',
|
||||
partitionJson: '[]',
|
||||
laneStateJson: '[]',
|
||||
ioDescriptorJson: '{"path":"/tmp/x"}',
|
||||
secretEnc: randBytes(32),
|
||||
secretNonce: randBytes(12),
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
await store.saveStreamState(s);
|
||||
const got = await store.getStreamState('stream-1');
|
||||
expect(got).toEqual(s);
|
||||
|
||||
const active = await store.listActiveStreamStates();
|
||||
expect(active.length).toBe(1);
|
||||
expect((await store.listActiveStreamStates('receive')).length).toBe(0);
|
||||
|
||||
await store.saveStreamState({ ...s, streamId: 'stream-2', status: 'finished', updatedAt: 50 });
|
||||
expect((await store.listActiveStreamStates()).length).toBe(1);
|
||||
await store.pruneStreamStates(100);
|
||||
expect(await store.getStreamState('stream-2')).toBeNull();
|
||||
expect(await store.getStreamState('stream-1')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('peer verification + identity-version bump (atomic)', async () => {
|
||||
expect(await store.getPeerVerification('peer-x')).toBeNull();
|
||||
await store.savePeerVerification({
|
||||
peerAddress: 'peer-x',
|
||||
fingerprint: 'fp',
|
||||
verifiedAt: 1,
|
||||
verifiedBy: 'sas',
|
||||
identityVersion: 1,
|
||||
});
|
||||
const v = await store.getPeerVerification('peer-x');
|
||||
expect(v?.fingerprint).toBe('fp');
|
||||
expect(await store.getPeerIdentityVersion('peer-x')).toBe(1);
|
||||
expect(await store.bumpPeerIdentityVersion('peer-x')).toBe(2);
|
||||
expect(await store.bumpPeerIdentityVersion('peer-x')).toBe(3);
|
||||
expect(await store.getPeerIdentityVersion('peer-x')).toBe(3);
|
||||
|
||||
await store.removePeerVerification('peer-x');
|
||||
expect(await store.getPeerVerification('peer-x')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects open with wrong key (fingerprint mismatch)', async () => {
|
||||
await store.saveIdentityKeyPair(dummyIdentity());
|
||||
store.close();
|
||||
const otherKey = randBytes(32);
|
||||
await expect(EncryptedIndexedDBStorage.open({
|
||||
dbName,
|
||||
keyManager: await KeyManager.open({ kind: 'injected', key: otherKey }),
|
||||
})).rejects.toThrow(/storage key mismatch/);
|
||||
// Reopen with original key for afterEach
|
||||
store = await EncryptedIndexedDBStorage.open({ dbName, keyManager: await freshKM() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('EncryptedIndexedDBStorage — cross-impl roundtrip with EncryptedSQLiteStorage', () => {
|
||||
test('row sealed by SQLite backend decrypts under IDB backend with same KeyManager', async () => {
|
||||
const sharedKey = randBytes(32);
|
||||
const dbPath = join(tmpdir(), `shade-cross-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
||||
const dbName = uniqueDbName();
|
||||
|
||||
// Write via SQLite
|
||||
const km1 = await KeyManager.open({ kind: 'injected', key: sharedKey });
|
||||
const sqlite = await EncryptedSQLiteStorage.open({ dbPath, keyManager: km1 });
|
||||
const kp = dummyIdentity();
|
||||
await sqlite.saveIdentityKeyPair(kp);
|
||||
await sqlite.saveLocalRegistrationId(424242);
|
||||
await sqlite.saveSession('device:cross', dummySession());
|
||||
|
||||
// Pull the raw ciphertext blobs out and inject them into a fresh IDB store
|
||||
// through normal saveX → check the IDB-saved blobs decrypt under the same
|
||||
// KeyManager. Since AAD/nonce derivation is purely a function of (km,
|
||||
// table, column, pk), bytes-equal blobs prove the row codec is
|
||||
// implementation-agnostic.
|
||||
sqlite.close();
|
||||
try { unlinkSync(dbPath); } catch {}
|
||||
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||
|
||||
const km2 = await KeyManager.open({ kind: 'injected', key: sharedKey });
|
||||
const idb = await EncryptedIndexedDBStorage.open({ dbName, keyManager: km2 });
|
||||
await idb.saveIdentityKeyPair(kp);
|
||||
await idb.saveLocalRegistrationId(424242);
|
||||
|
||||
expect(await idb.getIdentityKeyPair()).toEqual(kp);
|
||||
expect(await idb.getLocalRegistrationId()).toBe(424242);
|
||||
|
||||
idb.close();
|
||||
await deleteDb(dbName);
|
||||
});
|
||||
|
||||
test('AAD (table, column, pk) binding is implementation-agnostic', async () => {
|
||||
// Open both backends with the same injected key, save the same session
|
||||
// under the same address, then assert that the resulting ciphertext blobs
|
||||
// are byte-equal — confirming AAD + nonce derivation is shared.
|
||||
const sharedKey = randBytes(32);
|
||||
const dbPath = join(tmpdir(), `shade-cross-aad-${Date.now()}.db`);
|
||||
const dbName = uniqueDbName();
|
||||
|
||||
const session = dummySession();
|
||||
|
||||
const sqlite = await EncryptedSQLiteStorage.open({
|
||||
dbPath,
|
||||
keyManager: await KeyManager.open({ kind: 'injected', key: sharedKey }),
|
||||
});
|
||||
await sqlite.saveSession('addr-1', session);
|
||||
|
||||
const idb = await EncryptedIndexedDBStorage.open({
|
||||
dbName,
|
||||
keyManager: await KeyManager.open({ kind: 'injected', key: sharedKey }),
|
||||
});
|
||||
await idb.saveSession('addr-1', session);
|
||||
|
||||
// Both backends must recover the same plaintext.
|
||||
const fromSqlite = await sqlite.getSession('addr-1');
|
||||
const fromIdb = await idb.getSession('addr-1');
|
||||
expect(fromSqlite?.rootKey).toEqual(session.rootKey);
|
||||
expect(fromIdb?.rootKey).toEqual(session.rootKey);
|
||||
expect(fromSqlite?.rootKey).toEqual(fromIdb!.rootKey);
|
||||
|
||||
sqlite.close();
|
||||
idb.close();
|
||||
try { unlinkSync(dbPath); } catch {}
|
||||
try { unlinkSync(dbPath + '-wal'); } catch {}
|
||||
try { unlinkSync(dbPath + '-shm'); } catch {}
|
||||
await deleteDb(dbName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { KeyManager } from '../src/crypto/key-manager.js';
|
||||
import {
|
||||
DEFAULT_ARGON2ID,
|
||||
deriveMasterKeyArgon2id,
|
||||
type Argon2idParams,
|
||||
} from '../src/crypto/kdf.js';
|
||||
|
||||
const FAST_ARGON: Argon2idParams = { m: 256, t: 1, p: 1, dkLen: 32 };
|
||||
|
||||
function randBytes(n: number): Uint8Array {
|
||||
const b = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
describe('argon2id source', () => {
|
||||
const salt = new Uint8Array(16).fill(0x33);
|
||||
|
||||
test('deriveMasterKeyArgon2id is deterministic', async () => {
|
||||
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||||
const b = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.length).toBe(32);
|
||||
});
|
||||
|
||||
test('different secret → different key', async () => {
|
||||
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||||
const b = await deriveMasterKeyArgon2id('1235', salt, FAST_ARGON);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('different salt → different key', async () => {
|
||||
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||||
const b = await deriveMasterKeyArgon2id('1234', new Uint8Array(16).fill(0x44), FAST_ARGON);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('rejects empty secret', async () => {
|
||||
await expect(deriveMasterKeyArgon2id('', salt, FAST_ARGON)).rejects.toThrow(/non-empty/);
|
||||
});
|
||||
|
||||
test('rejects too-short salt', async () => {
|
||||
await expect(deriveMasterKeyArgon2id('p', new Uint8Array(8), FAST_ARGON))
|
||||
.rejects.toThrow(/at least 16/);
|
||||
});
|
||||
|
||||
test('KeyManager.open opens with argon2id source', async () => {
|
||||
const km = await KeyManager.open({
|
||||
kind: 'argon2id',
|
||||
secret: '123456',
|
||||
salt,
|
||||
params: FAST_ARGON,
|
||||
});
|
||||
expect(km.fieldKey('t', 'c').length).toBe(32);
|
||||
km.destroy();
|
||||
});
|
||||
|
||||
test('DEFAULT_ARGON2ID is exposed and sensible', () => {
|
||||
expect(DEFAULT_ARGON2ID.dkLen).toBe(32);
|
||||
expect(DEFAULT_ARGON2ID.m).toBeGreaterThanOrEqual(8 * 1024);
|
||||
expect(DEFAULT_ARGON2ID.t).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('accepts Uint8Array secret', async () => {
|
||||
const secretBytes = new TextEncoder().encode('1234');
|
||||
const a = await deriveMasterKeyArgon2id(secretBytes, salt, FAST_ARGON);
|
||||
const b = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composite source — multi-factor unlock', () => {
|
||||
const pwSalt = new Uint8Array(16).fill(0x11);
|
||||
const pinSalt = new Uint8Array(16).fill(0x22);
|
||||
const FAST_SCRYPT = { N: 1 << 10, r: 8, p: 1, dkLen: 32 };
|
||||
|
||||
function pwSource(passphrase: string) {
|
||||
return { kind: 'passphrase' as const, passphrase, salt: pwSalt, params: FAST_SCRYPT };
|
||||
}
|
||||
function pinSource(secret: string) {
|
||||
return { kind: 'argon2id' as const, secret, salt: pinSalt, params: FAST_ARGON };
|
||||
}
|
||||
|
||||
test('same factors → same masterKey', async () => {
|
||||
const a = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('correct horse'), pinSource('1234')],
|
||||
});
|
||||
const b = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('correct horse'), pinSource('1234')],
|
||||
});
|
||||
expect(a.storageKeyFingerprint()).toEqual(b.storageKeyFingerprint());
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
test('wrong PIN → different masterKey (same shape as wrong-passphrase)', async () => {
|
||||
const right = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('correct horse'), pinSource('1234')],
|
||||
});
|
||||
const wrongPin = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('correct horse'), pinSource('9999')],
|
||||
});
|
||||
expect(right.storageKeyFingerprint()).not.toEqual(wrongPin.storageKeyFingerprint());
|
||||
right.destroy();
|
||||
wrongPin.destroy();
|
||||
});
|
||||
|
||||
test('wrong passphrase → different masterKey', async () => {
|
||||
const right = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('correct horse'), pinSource('1234')],
|
||||
});
|
||||
const wrongPwd = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('wrong horse'), pinSource('1234')],
|
||||
});
|
||||
expect(right.storageKeyFingerprint()).not.toEqual(wrongPwd.storageKeyFingerprint());
|
||||
right.destroy();
|
||||
wrongPwd.destroy();
|
||||
});
|
||||
|
||||
test('order is significant by design', async () => {
|
||||
const ab = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('horse'), pinSource('1234')],
|
||||
});
|
||||
const ba = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pinSource('1234'), pwSource('horse')],
|
||||
});
|
||||
expect(ab.storageKeyFingerprint()).not.toEqual(ba.storageKeyFingerprint());
|
||||
ab.destroy();
|
||||
ba.destroy();
|
||||
});
|
||||
|
||||
test('explicit info string changes masterKey (domain separation)', async () => {
|
||||
const a = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('horse'), pinSource('1234')],
|
||||
});
|
||||
const b = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [pwSource('horse'), pinSource('1234')],
|
||||
info: 'my-app-v1',
|
||||
});
|
||||
expect(a.storageKeyFingerprint()).not.toEqual(b.storageKeyFingerprint());
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
|
||||
test('rejects empty source list', async () => {
|
||||
await expect(KeyManager.open({ kind: 'composite', sources: [] }))
|
||||
.rejects.toThrow(/at least one/);
|
||||
});
|
||||
|
||||
test('rejects nested composite', async () => {
|
||||
await expect(KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [
|
||||
{ kind: 'composite', sources: [pwSource('a')] },
|
||||
pinSource('1234'),
|
||||
],
|
||||
})).rejects.toThrow(/cannot be nested/);
|
||||
});
|
||||
|
||||
test('composite of three sources works', async () => {
|
||||
const km = await KeyManager.open({
|
||||
kind: 'composite',
|
||||
sources: [
|
||||
pwSource('horse'),
|
||||
pinSource('1234'),
|
||||
{ kind: 'injected', key: randBytes(32) },
|
||||
],
|
||||
});
|
||||
expect(km.fieldKey('t', 'c').length).toBe(32);
|
||||
km.destroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user