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,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