Files
Shade/packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts

184 lines
5.9 KiB
TypeScript
Raw Normal View History

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>
2026-05-07 10:58:49 +02:00
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();
});
});