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:
2026-05-07 10:58:49 +02:00
parent dbb3a090d8
commit 2b1b4d6630
39 changed files with 1274 additions and 55 deletions

View File

@@ -11,6 +11,7 @@
*/
import { scryptAsync } from '@noble/hashes/scrypt.js';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
@@ -45,6 +46,50 @@ export async function deriveMasterKey(
return scryptAsync(TEXT.encode(passphrase.normalize('NFKC')), salt, params);
}
/** Argon2id parameters — memory-hard KDF preferred for low-entropy secrets (PINs). */
export interface Argon2idParams {
/** Memory cost in KiB. */
m: number;
/** Time cost (iterations). */
t: number;
/** Parallelism. */
p: number;
/** Output length in bytes. */
dkLen: number;
}
/**
* Default: m=64 MiB, t=3, p=1, 32-byte output. Tuned for ~250400 ms on a
* modern Chromium / Firefox / Safari laptop. RFC 9106 "second recommended"
* profile shrunk to a browser-friendly memory footprint — strong enough for
* 46 digit PINs as a defense-in-depth factor on top of a passphrase.
*/
export const DEFAULT_ARGON2ID: Argon2idParams = { m: 64 * 1024, t: 3, p: 1, dkLen: 32 };
/**
* Derive a 32-byte master key from a low-entropy secret + salt using
* argon2id. Salt MUST be persisted alongside the DB (16-byte random).
*/
export async function deriveMasterKeyArgon2id(
secret: string | Uint8Array,
salt: Uint8Array,
params: Argon2idParams = DEFAULT_ARGON2ID,
): Promise<Uint8Array> {
if (typeof secret === 'string' ? secret.length === 0 : secret.length === 0) {
throw new Error('argon2id secret must be non-empty');
}
if (salt.length < 16) {
throw new Error('salt must be at least 16 bytes');
}
const password = typeof secret === 'string' ? TEXT.encode(secret.normalize('NFKC')) : secret;
return argon2idAsync(password, salt, {
m: params.m,
t: params.t,
p: params.p,
dkLen: params.dkLen,
});
}
/** HKDF-SHA-256 with explicit info string. */
export function hkdfDerive(ikm: Uint8Array, info: string, length = 32, salt?: Uint8Array): Uint8Array {
return hkdf(sha256, ikm, salt, TEXT.encode(info), length);