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:
@@ -1,17 +1,29 @@
|
||||
/**
|
||||
* KeyManager — owns the masterKey lifecycle for at-rest encryption.
|
||||
*
|
||||
* Three sources are supported:
|
||||
* Five sources are supported:
|
||||
* 1. passphrase — scrypt-derived from a developer-supplied secret
|
||||
* 2. keychain — fetched from OS keychain via @shade/keychain (optional dep)
|
||||
* 3. injected — caller supplies the 32-byte raw key directly
|
||||
* 2. argon2id — memory-hard KDF for low-entropy secrets (PINs)
|
||||
* 3. keychain — fetched from OS keychain via @shade/keychain (optional dep)
|
||||
* 4. injected — caller supplies the 32-byte raw key directly
|
||||
* 5. composite — HKDF-combine N sub-sources into one master key
|
||||
* (multi-factor unlock — every source is required)
|
||||
*
|
||||
* The KeyManager pre-derives storageKey at construction time and caches the
|
||||
* per-(table, column) field keys. masterKey is zeroized after storageKey
|
||||
* derivation to limit residency.
|
||||
*/
|
||||
|
||||
import { deriveFieldKey, deriveMasterKey, deriveStorageKey, type ScryptParams, DEFAULT_SCRYPT } from './kdf.js';
|
||||
import {
|
||||
deriveFieldKey,
|
||||
deriveMasterKey,
|
||||
deriveMasterKeyArgon2id,
|
||||
deriveStorageKey,
|
||||
hkdfDerive,
|
||||
DEFAULT_SCRYPT,
|
||||
type Argon2idParams,
|
||||
type ScryptParams,
|
||||
} from './kdf.js';
|
||||
|
||||
export type KeySource =
|
||||
| {
|
||||
@@ -21,6 +33,14 @@ export type KeySource =
|
||||
salt: Uint8Array;
|
||||
params?: ScryptParams;
|
||||
}
|
||||
| {
|
||||
kind: 'argon2id';
|
||||
/** Low-entropy secret (PIN, short password). */
|
||||
secret: string | Uint8Array;
|
||||
/** Stable 16+ byte salt persisted alongside the DB. */
|
||||
salt: Uint8Array;
|
||||
params?: Argon2idParams;
|
||||
}
|
||||
| {
|
||||
kind: 'keychain';
|
||||
/** Service identifier (e.g. "shade.storage"). */
|
||||
@@ -34,6 +54,25 @@ export type KeySource =
|
||||
kind: 'injected';
|
||||
/** Raw 32-byte master key. */
|
||||
key: Uint8Array;
|
||||
}
|
||||
| {
|
||||
kind: 'composite';
|
||||
/**
|
||||
* Sub-sources HKDF-combined into the master key, in order. Every
|
||||
* source is mandatory: omitting or substituting any source yields
|
||||
* a different master key and the storage open() will fail.
|
||||
*
|
||||
* Order is significant by design — `[pwd, pin]` and `[pin, pwd]`
|
||||
* derive different master keys.
|
||||
*/
|
||||
sources: KeySource[];
|
||||
/**
|
||||
* HKDF info string for domain separation. Defaults to
|
||||
* `shade-composite-master-v1`. Apps that want their composite key
|
||||
* to be cryptographically distinct from any other Shade composite
|
||||
* should override this with an app-specific string.
|
||||
*/
|
||||
info?: string;
|
||||
};
|
||||
|
||||
/** Pluggable keychain backend. Implementations live in @shade/keychain. */
|
||||
@@ -107,6 +146,9 @@ async function resolveMasterKey(source: KeySource, opts: KeyManagerOptions): Pro
|
||||
case 'passphrase':
|
||||
return deriveMasterKey(source.passphrase, source.salt, source.params ?? DEFAULT_SCRYPT);
|
||||
|
||||
case 'argon2id':
|
||||
return deriveMasterKeyArgon2id(source.secret, source.salt, source.params);
|
||||
|
||||
case 'injected':
|
||||
if (source.key.length !== 32) throw new Error('injected key must be exactly 32 bytes');
|
||||
return new Uint8Array(source.key); // copy, in case caller mutates
|
||||
@@ -128,5 +170,39 @@ async function resolveMasterKey(source: KeySource, opts: KeyManagerOptions): Pro
|
||||
await opts.keychain.set(source.service, source.account, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
case 'composite': {
|
||||
if (source.sources.length === 0) {
|
||||
throw new Error('composite source requires at least one sub-source');
|
||||
}
|
||||
const subKeys: Uint8Array[] = [];
|
||||
try {
|
||||
for (const sub of source.sources) {
|
||||
if (sub.kind === 'composite') {
|
||||
// Composite-of-composite would silently flatten the unlock
|
||||
// semantics ("any of N" vs "all of N") if the inner is misread.
|
||||
// Forbidding nesting keeps the contract clear.
|
||||
throw new Error('composite sources cannot be nested');
|
||||
}
|
||||
subKeys.push(await resolveMasterKey(sub, opts));
|
||||
}
|
||||
let total = 0;
|
||||
for (const k of subKeys) total += k.length;
|
||||
const ikm = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const k of subKeys) {
|
||||
ikm.set(k, off);
|
||||
off += k.length;
|
||||
}
|
||||
const info = source.info ?? 'shade-composite-master-v1';
|
||||
try {
|
||||
return hkdfDerive(ikm, info, 32);
|
||||
} finally {
|
||||
ikm.fill(0);
|
||||
}
|
||||
} finally {
|
||||
for (const k of subKeys) k.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user