diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6bb31..366951d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.5.0] — 2026-05-07 — Browser-side encrypted storage + multi-factor unlock + +Browser-based Shade clients (Prism's web client being the first) needed +the same at-rest encryption story as the desktop SQLite path: identity, +prekeys, sessions and stream-resume state persisted across reloads, +unwrapped from a user-supplied passphrase — and on browsers, optionally +gated behind a second factor (PIN) since there is no OS-session boundary +to lean on. The existing barrel of `@shade/storage-encrypted` also +transitively imported `bun:sqlite` and `postgres`, which prevented Vite/ +webpack/esbuild from producing a clean browser bundle. + +This release adds an encrypted IndexedDB backend that mirrors +`EncryptedSQLiteStorage` byte-for-byte at the AAD/nonce level, exposes +browser-safe subpath imports, and lets `KeyManager` derive its master +key from low-entropy secrets (argon2id) and from N composed factors +(every factor mandatory). + +### Added + +#### `@shade/storage-encrypted` +- `EncryptedIndexedDBStorage` — IndexedDB-backed `StorageProvider` + exposed via `@shade/storage-encrypted/idb`. One object store per + `_enc` table from the SQLite schema, sealed payloads as `Uint8Array`, + routing/timestamp fields kept plaintext for query efficiency. Reuses + `aeadSeal`/`aeadOpen` and the `row-codec` sealers verbatim — a row + sealed under the SQLite or Postgres backend decrypts under IDB given + the same `KeyManager`. `bumpPeerIdentityVersion` is atomic under one + IDB transaction (closes the read-then-upsert race the SQLite version + has). +- `KeyManager.open({ kind: 'argon2id', ... })` — memory-hard KDF for + low-entropy secrets (PINs, short passwords). Backed by + `@noble/hashes/argon2` (already a transitive dep — pure JS, browser + safe). `DEFAULT_ARGON2ID` exported (m=64 MiB, t=3, p=1, 32-byte + output; ~250–400 ms in modern browsers). +- `KeyManager.open({ kind: 'composite', sources, info? })` — + HKDF-combine N sub-sources into one master key. Every source is + required: omitting or substituting any source yields a different + master key and `open()` fails on the storage-key-fingerprint check. + Order is significant by design (`[pwd, pin]` ≠ `[pin, pwd]`). + Composite-of-composite is rejected. +- Subpath exports: `@shade/storage-encrypted/crypto` (KeyManager + KDF + + AEAD + row-codec, no SQLite/Postgres bindings), `/sqlite` (Bun), + `/postgres` (Node), `/idb` (browser). The `browser` condition on the + default import resolves to a barrel that excludes Bun/Postgres + imports — `import { KeyManager } from '@shade/storage-encrypted'` now + bundles cleanly under Vite without hitting `bun:sqlite` resolution + errors. +- Dependency: `idb` ^8.0.3. + +### Tests +- `packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts` + — argon2id determinism + reject paths, composite same-factors → same + master, wrong-PIN/wrong-passphrase/order-swap → different master, + explicit `info` domain separation, nested-composite rejection. +- `packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts` + — full round-trip coverage of all 28 `StorageProvider` methods, + fingerprint-mismatch rejection on wrong key, atomic peer-identity + bump, plus cross-impl roundtrip with `EncryptedSQLiteStorage` + proving the AAD/nonce derivation is implementation-agnostic. + ## [4.4.0] — 2026-05-05 — Public accessor for the device's identity public key Browser-based Shade consumers building enrollment flows had no way to diff --git a/bun.lock b/bun.lock index ea9fc31..4d7fec1 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/shade-cli": { "name": "@shade/cli", - "version": "4.2.1", + "version": "4.4.0", "bin": { "shade": "src/cli.ts", }, @@ -36,7 +36,7 @@ }, "packages/shade-core": { "name": "@shade/core", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/observability": "workspace:*", }, @@ -49,7 +49,7 @@ }, "packages/shade-crypto-web": { "name": "@shade/crypto-web", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", @@ -59,7 +59,7 @@ }, "packages/shade-dashboard": { "name": "@shade/dashboard", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/widgets": "workspace:*", "react": "^19.0.0", @@ -74,7 +74,7 @@ }, "packages/shade-files": { "name": "@shade/files", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -101,7 +101,7 @@ }, "packages/shade-inbox": { "name": "@shade/inbox", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/proto": "workspace:*", @@ -114,7 +114,7 @@ }, "packages/shade-inbox-server": { "name": "@shade/inbox-server", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/observability": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/shade-key-transparency": { "name": "@shade/key-transparency", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -144,11 +144,11 @@ }, "packages/shade-keychain": { "name": "@shade/keychain", - "version": "4.2.1", + "version": "4.4.0", }, "packages/shade-observability": { "name": "@shade/observability", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@noble/hashes": "^2.0.1", }, @@ -166,7 +166,7 @@ }, "packages/shade-observer": { "name": "@shade/observer", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -178,14 +178,14 @@ }, "packages/shade-proto": { "name": "@shade/proto", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", }, }, "packages/shade-recovery": { "name": "@shade/recovery", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -198,7 +198,7 @@ }, "packages/shade-sdk": { "name": "@shade/sdk", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -225,7 +225,7 @@ }, "packages/shade-server": { "name": "@shade/server", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -245,15 +245,19 @@ }, "packages/shade-storage-encrypted": { "name": "@shade/storage-encrypted", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", "@shade/storage-postgres": "workspace:*", "@shade/storage-sqlite": "workspace:*", + "idb": "^8.0.3", "postgres": "^3.4.9", }, + "devDependencies": { + "fake-indexeddb": "^6.0.0", + }, "peerDependencies": { "@shade/keychain": "workspace:*", }, @@ -263,7 +267,7 @@ }, "packages/shade-storage-indexeddb": { "name": "@shade/storage-indexeddb", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "idb": "^8.0.3", @@ -275,7 +279,7 @@ }, "packages/shade-storage-postgres": { "name": "@shade/storage-postgres", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/inbox-server": "workspace:*", @@ -290,7 +294,7 @@ }, "packages/shade-storage-sqlite": { "name": "@shade/storage-sqlite", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -300,7 +304,7 @@ }, "packages/shade-streams": { "name": "@shade/streams", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", @@ -312,7 +316,7 @@ }, "packages/shade-transfer": { "name": "@shade/transfer", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -329,7 +333,7 @@ }, "packages/shade-transport": { "name": "@shade/transport", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", @@ -340,7 +344,7 @@ }, "packages/shade-transport-bridge": { "name": "@shade/transport-bridge", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/server": "workspace:*", @@ -362,7 +366,7 @@ }, "packages/shade-transport-webrtc": { "name": "@shade/transport-webrtc", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/core": "workspace:*", "@shade/streams": "workspace:*", @@ -371,7 +375,7 @@ }, "packages/shade-widgets": { "name": "@shade/widgets", - "version": "4.2.1", + "version": "4.4.0", "dependencies": { "@shade/recovery": "workspace:*", "@shade/sdk": "workspace:*", diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 11e9a41..aaa03b1 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 53e787c..d5fc8a9 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index 137d262..ded448b 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index b498c22..9ad7c58 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 0ecfc2e..7569ab3 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 38fa885..e6843c6 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index ada0c52..0c760a6 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index ad365b4..d157f1b 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index ff339e5..fe944ee 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 283d732..e50c6a3 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 5bd5a57..c862de2 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 25810db..bb50a66 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index ff69381..e690053 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index 17279ab..f392070 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 3ba3dc5..deff3ac 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index b0490ff..4b1ff7c 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,17 +1,31 @@ { "name": "@shade/storage-encrypted", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": { + "browser": "./src/index.browser.ts", + "default": "./src/index.ts" + }, + "./crypto": "./src/crypto.ts", + "./sqlite": "./src/sqlite.ts", + "./postgres": "./src/postgres.ts", + "./idb": "./src/idb.ts" + }, "dependencies": { "@noble/hashes": "^2.0.1", "@shade/core": "workspace:*", "@shade/crypto-web": "workspace:*", "@shade/storage-postgres": "workspace:*", "@shade/storage-sqlite": "workspace:*", + "idb": "^8.0.3", "postgres": "^3.4.9" }, + "devDependencies": { + "fake-indexeddb": "^6.0.0" + }, "peerDependencies": { "@shade/keychain": "workspace:*" }, diff --git a/packages/shade-storage-encrypted/src/crypto.ts b/packages/shade-storage-encrypted/src/crypto.ts new file mode 100644 index 0000000..9470e3b --- /dev/null +++ b/packages/shade-storage-encrypted/src/crypto.ts @@ -0,0 +1,64 @@ +/** + * Browser-safe entry point — exports KeyManager + crypto primitives only. + * + * Use this subpath when bundling for the browser to avoid pulling + * `bun:sqlite` (Bun-only) or `postgres` (Node-only) into the output. + * + * ```ts + * import { KeyManager } from '@shade/storage-encrypted/crypto'; + * ``` + * + * For backend storage, import `@shade/storage-encrypted/sqlite` (Bun) or + * `@shade/storage-encrypted/postgres` (Node). For browser storage, import + * `@shade/storage-encrypted/idb`. + */ + +export { + KeyManager, + type KeySource, + type KeychainBackend, + type KeyManagerOptions, +} from './crypto/key-manager.js'; +export { + DEFAULT_SCRYPT, + DEFAULT_ARGON2ID, + type ScryptParams, + type Argon2idParams, + deriveMasterKey, + deriveMasterKeyArgon2id, + deriveStorageKey, + deriveFieldKey, + deriveNonce, + buildAad, + hkdfDerive, +} from './crypto/kdf.js'; +export { + AEAD_NONCE_LEN, + AEAD_TAG_LEN, + aeadSeal, + aeadOpen, +} from './crypto/aead.js'; +export { + COL, + TBL, + sealString, + openString, + sealBytes, + openBytes, + sealIdentity, + openIdentity, + sealConfig, + openConfig, + sealSignedPreKey, + openSignedPreKey, + sealOneTimePreKey, + openOneTimePreKey, + sealSession, + openSession, + sealTrust, + openTrust, + sealRetired, + openRetired, + sealStreamSensitive, + openStreamSensitive, +} from './crypto/row-codec.js'; diff --git a/packages/shade-storage-encrypted/src/crypto/kdf.ts b/packages/shade-storage-encrypted/src/crypto/kdf.ts index 28280df..c9af9f6 100644 --- a/packages/shade-storage-encrypted/src/crypto/kdf.ts +++ b/packages/shade-storage-encrypted/src/crypto/kdf.ts @@ -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 ~250–400 ms on a + * modern Chromium / Firefox / Safari laptop. RFC 9106 "second recommended" + * profile shrunk to a browser-friendly memory footprint — strong enough for + * 4–6 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 { + 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); diff --git a/packages/shade-storage-encrypted/src/crypto/key-manager.ts b/packages/shade-storage-encrypted/src/crypto/key-manager.ts index 3a5b286..d168394 100644 --- a/packages/shade-storage-encrypted/src/crypto/key-manager.ts +++ b/packages/shade-storage-encrypted/src/crypto/key-manager.ts @@ -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); + } + } } } diff --git a/packages/shade-storage-encrypted/src/idb.ts b/packages/shade-storage-encrypted/src/idb.ts new file mode 100644 index 0000000..a61b14e --- /dev/null +++ b/packages/shade-storage-encrypted/src/idb.ts @@ -0,0 +1,13 @@ +/** + * Browser entry point — Encrypted IndexedDB storage. + * + * Pulls `idb` (~12 kB) and SubtleCrypto only. Safe to bundle into a + * browser app via Vite/webpack/esbuild. + * + * ```ts + * import { KeyManager } from '@shade/storage-encrypted/crypto'; + * import { EncryptedIndexedDBStorage } from '@shade/storage-encrypted/idb'; + * ``` + */ + +export { EncryptedIndexedDBStorage } from './storage/encrypted-indexeddb.js'; diff --git a/packages/shade-storage-encrypted/src/index.browser.ts b/packages/shade-storage-encrypted/src/index.browser.ts new file mode 100644 index 0000000..a1fd883 --- /dev/null +++ b/packages/shade-storage-encrypted/src/index.browser.ts @@ -0,0 +1,14 @@ +/** + * Default barrel resolved by bundlers under the `browser` condition. + * + * Excludes `EncryptedSQLiteStorage` (pulls `bun:sqlite`) and + * `EncryptedPostgresStorage` (pulls `postgres`). Browser apps can keep using + * `import { KeyManager } from '@shade/storage-encrypted'` — Vite/webpack will + * pick this file via the `browser` condition and produce a clean bundle. + * + * Node/Bun consumers continue to resolve to `./index.js`, which exposes the + * full surface area (back-compat). + */ + +export * from './crypto.js'; +export { EncryptedIndexedDBStorage } from './storage/encrypted-indexeddb.js'; diff --git a/packages/shade-storage-encrypted/src/index.ts b/packages/shade-storage-encrypted/src/index.ts index ea6e5b4..28ee4cd 100644 --- a/packages/shade-storage-encrypted/src/index.ts +++ b/packages/shade-storage-encrypted/src/index.ts @@ -6,8 +6,11 @@ export { } from './crypto/key-manager.js'; export { DEFAULT_SCRYPT, + DEFAULT_ARGON2ID, type ScryptParams, + type Argon2idParams, deriveMasterKey, + deriveMasterKeyArgon2id, deriveStorageKey, deriveFieldKey, deriveNonce, @@ -25,6 +28,9 @@ export { EncryptedPostgresStorage, ensureEncryptedClientTables, } from './storage/encrypted-postgres.js'; +// EncryptedIndexedDBStorage is browser-only; import it from +// `@shade/storage-encrypted/idb` (or rely on the `browser` condition +// resolving to ./index.browser.ts). export { migrateSqliteToEncrypted, rotateSqliteEncryptionKey, diff --git a/packages/shade-storage-encrypted/src/postgres.ts b/packages/shade-storage-encrypted/src/postgres.ts new file mode 100644 index 0000000..4f97443 --- /dev/null +++ b/packages/shade-storage-encrypted/src/postgres.ts @@ -0,0 +1,11 @@ +/** + * Node-only entry point — Encrypted Postgres storage. + * + * Pulls `postgres`. Do not import from a browser bundle; use + * `@shade/storage-encrypted/idb` instead. + */ + +export { + EncryptedPostgresStorage, + ensureEncryptedClientTables, +} from './storage/encrypted-postgres.js'; diff --git a/packages/shade-storage-encrypted/src/sqlite.ts b/packages/shade-storage-encrypted/src/sqlite.ts new file mode 100644 index 0000000..d1e2864 --- /dev/null +++ b/packages/shade-storage-encrypted/src/sqlite.ts @@ -0,0 +1,15 @@ +/** + * Bun-only entry point — Encrypted SQLite storage + migration utilities. + * + * Pulls `bun:sqlite`. Do not import from a browser bundle; use + * `@shade/storage-encrypted/idb` instead. + */ + +export { EncryptedSQLiteStorage } from './storage/encrypted-sqlite.js'; +export { + migrateSqliteToEncrypted, + rotateSqliteEncryptionKey, + type MigrateOptions, + type RotateOptions, + type MigrateReport, +} from './migrate/migrate-sqlite.js'; diff --git a/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts b/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts new file mode 100644 index 0000000..114b956 --- /dev/null +++ b/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts @@ -0,0 +1,422 @@ +import { openDB, type IDBPDatabase, type DBSchema } from 'idb'; +import type { + IdentityKeyPair, + OneTimePreKey, + PeerVerification, + PeerVerificationSource, + PersistedStreamState, + RetiredIdentity, + SessionState, + SignedPreKey, + StorageProvider, +} from '@shade/core'; +import { constantTimeEqual, toBase64 } from '@shade/core'; +import { KeyManager } from '../crypto/key-manager.js'; +import { + openConfig, openIdentity, openOneTimePreKey, openRetired, openSession, + openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity, + sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey, + sealStreamSensitive, sealTrust, +} from '../crypto/row-codec.js'; + +/** + * IndexedDB-backed StorageProvider with at-rest encryption. + * + * Schema is the IndexedDB equivalent of `EncryptedSQLiteStorage`: one object + * store per `_enc` table, sealed payloads stored as `Uint8Array` in a + * `ciphertext` field, routing/timestamp fields kept plaintext where SQLite + * does so for query efficiency. Crypto stack — `KeyManager`, `aeadSeal`, + * `aeadOpen`, row-codec sealers, AAD scheme — is shared verbatim with the + * SQLite/Postgres backends, so a row sealed under one backend decrypts + * under another given the same `KeyManager`. + * + * Browser-safe: imports `idb` (~12 kB, pure JS) and SubtleCrypto only. + * + * Usage: + * ```ts + * import { KeyManager } from '@shade/storage-encrypted/crypto'; + * import { EncryptedIndexedDBStorage } from '@shade/storage-encrypted/idb'; + * + * const km = await KeyManager.open({ + * kind: 'composite', + * sources: [ + * { kind: 'passphrase', passphrase, salt: pwSalt }, + * { kind: 'argon2id', secret: pin, salt: pinSalt }, + * ], + * }); + * const storage = await EncryptedIndexedDBStorage.open({ + * dbName: 'my-app-shade', + * keyManager: km, + * }); + * ``` + */ +export class EncryptedIndexedDBStorage implements StorageProvider { + private constructor( + private db: IDBPDatabase, + private km: KeyManager, + ) {} + + /** + * Open (or create) the encrypted IndexedDB database. On first open the + * storageKey fingerprint is persisted; subsequent opens with a different + * KeyManager (wrong passphrase / PIN) reject with a clear error rather + * than silently writing data under the wrong key. + */ + static async open(opts: { + dbName?: string; + keyManager: KeyManager; + }): Promise { + const dbName = opts.dbName ?? 'shade-encrypted'; + const db = await openDB(dbName, SCHEMA_VERSION, { + upgrade(db, oldVersion) { + if (oldVersion < 1) { + db.createObjectStore('meta_enc', { keyPath: 'key' }); + db.createObjectStore('identity_enc', { keyPath: 'id' }); + db.createObjectStore('config_enc', { keyPath: 'key' }); + db.createObjectStore('signed_prekeys_enc', { keyPath: 'keyId' }); + db.createObjectStore('one_time_prekeys_enc', { keyPath: 'keyId' }); + db.createObjectStore('sessions_enc', { keyPath: 'address' }); + db.createObjectStore('trusted_identities_enc', { keyPath: 'address' }); + + const retired = db.createObjectStore('retired_identities_enc', { + keyPath: 'retiredAt', + }); + retired.createIndex('byRetiredAt', 'retiredAt'); + + const stream = db.createObjectStore('stream_state_enc', { keyPath: 'streamId' }); + stream.createIndex('byStatus', 'status'); + stream.createIndex('byPeerAddress', 'peerAddress'); + stream.createIndex('byUpdatedAt', 'updatedAt'); + + db.createObjectStore('peer_verifications_enc', { keyPath: 'peerAddress' }); + db.createObjectStore('peer_identity_versions_enc', { keyPath: 'peerAddress' }); + } + }, + }); + const store = new EncryptedIndexedDBStorage(db, opts.keyManager); + await store.assertKeyMatchesOrPersistFingerprint(); + return store; + } + + /** Cleanly close the underlying connection. KeyManager is destroyed. */ + close(): void { + this.db.close(); + this.km.destroy(); + } + + /** + * On first open, persist a fingerprint of the storageKey. On subsequent + * opens, compare and reject mismatches with a clear error rather than + * silently writing data under the wrong key. + */ + private async assertKeyMatchesOrPersistFingerprint(): Promise { + const expected = toBase64(this.km.storageKeyFingerprint()); + const row = await this.db.get('meta_enc', 'storage_key_fingerprint'); + if (!row) { + await this.db.put('meta_enc', { key: 'storage_key_fingerprint', value: expected }); + return; + } + if (row.value !== expected) { + throw new Error( + 'storage key mismatch — the supplied passphrase / keychain entry does not unlock this database', + ); + } + } + + // ─── Identity ────────────────────────────────────────────── + + async getIdentityKeyPair(): Promise { + const row = await this.db.get('identity_enc', 1); + if (!row) return null; + return openIdentity(this.km, row.ciphertext); + } + + async saveIdentityKeyPair(kp: IdentityKeyPair): Promise { + const blob = await sealIdentity(this.km, kp); + await this.db.put('identity_enc', { id: 1, ciphertext: blob }); + } + + async getLocalRegistrationId(): Promise { + const row = await this.db.get('config_enc', 'registrationId'); + if (!row) return 0; + const v = await openConfig(this.km, 'registrationId', row.ciphertext); + return parseInt(v, 10); + } + + async saveLocalRegistrationId(id: number): Promise { + const blob = await sealConfig(this.km, 'registrationId', String(id)); + await this.db.put('config_enc', { key: 'registrationId', ciphertext: blob }); + } + + // ─── Signed PreKeys ──────────────────────────────────────── + + async getSignedPreKey(keyId: number): Promise { + const row = await this.db.get('signed_prekeys_enc', keyId); + if (!row) return null; + return openSignedPreKey(this.km, keyId, row.ciphertext); + } + + async saveSignedPreKey(key: SignedPreKey): Promise { + const blob = await sealSignedPreKey(this.km, key); + await this.db.put('signed_prekeys_enc', { keyId: key.keyId, ciphertext: blob }); + } + + async removeSignedPreKey(keyId: number): Promise { + await this.db.delete('signed_prekeys_enc', keyId); + } + + // ─── One-Time PreKeys ────────────────────────────────────── + + async getOneTimePreKey(keyId: number): Promise { + const row = await this.db.get('one_time_prekeys_enc', keyId); + if (!row) return null; + return openOneTimePreKey(this.km, keyId, row.ciphertext); + } + + async saveOneTimePreKey(key: OneTimePreKey): Promise { + const blob = await sealOneTimePreKey(this.km, key); + await this.db.put('one_time_prekeys_enc', { keyId: key.keyId, ciphertext: blob }); + } + + async removeOneTimePreKey(keyId: number): Promise { + await this.db.delete('one_time_prekeys_enc', keyId); + } + + async getOneTimePreKeyCount(): Promise { + return this.db.count('one_time_prekeys_enc'); + } + + // ─── Sessions ────────────────────────────────────────────── + + async getSession(address: string): Promise { + const row = await this.db.get('sessions_enc', address); + if (!row) return null; + return openSession(this.km, address, row.ciphertext); + } + + async saveSession(address: string, state: SessionState): Promise { + const blob = await sealSession(this.km, address, state); + await this.db.put('sessions_enc', { address, ciphertext: blob }); + } + + async removeSession(address: string): Promise { + await this.db.delete('sessions_enc', address); + } + + // ─── Trust ───────────────────────────────────────────────── + + async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + const row = await this.db.get('trusted_identities_enc', address); + if (!row) return true; // TOFU + const stored = await openTrust(this.km, address, row.ciphertext); + return constantTimeEqual(stored, identityKey); + } + + async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + const blob = await sealTrust(this.km, address, identityKey); + await this.db.put('trusted_identities_enc', { address, ciphertext: blob }); + } + + // ─── Identity History ────────────────────────────────────── + + async addRetiredIdentity(identity: RetiredIdentity): Promise { + const blob = await sealRetired(this.km, identity); + await this.db.put('retired_identities_enc', { + retiredAt: identity.retiredAt, + ciphertext: blob, + }); + } + + async getRetiredIdentities(): Promise { + // Mirror SQLite's `ORDER BY retired_at DESC` + const rows = await this.db.getAllFromIndex('retired_identities_enc', 'byRetiredAt'); + rows.reverse(); + return Promise.all( + rows.map((r) => openRetired(this.km, r.retiredAt, r.ciphertext)), + ); + } + + async pruneRetiredIdentities(olderThan: number): Promise { + const tx = this.db.transaction('retired_identities_enc', 'readwrite'); + const idx = tx.store.index('byRetiredAt'); + const range = IDBKeyRange.upperBound(olderThan, true); + let cursor = await idx.openCursor(range); + while (cursor) { + await cursor.delete(); + cursor = await cursor.continue(); + } + await tx.done; + } + + // ─── Stream-transfer resume state ────────────────────────── + + async saveStreamState(state: PersistedStreamState): Promise { + const blob = await sealStreamSensitive(this.km, state); + await this.db.put('stream_state_enc', { + streamId: state.streamId, + direction: state.direction, + peerAddress: state.peerAddress, + status: state.status, + ciphertext: blob, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + }); + } + + async getStreamState(streamId: string): Promise { + const row = await this.db.get('stream_state_enc', streamId); + if (!row) return null; + return this.rowToStreamState(row); + } + + async removeStreamState(streamId: string): Promise { + await this.db.delete('stream_state_enc', streamId); + } + + async listActiveStreamStates(direction?: 'send' | 'receive'): Promise { + const tx = this.db.transaction('stream_state_enc'); + const idx = tx.store.index('byStatus'); + const active = await idx.getAll(IDBKeyRange.only('active')); + const paused = await idx.getAll(IDBKeyRange.only('paused')); + const merged = [...active, ...paused]; + const filtered = direction === undefined + ? merged + : merged.filter((r) => r.direction === direction); + filtered.sort((a, b) => b.updatedAt - a.updatedAt); + return Promise.all(filtered.map((r) => this.rowToStreamState(r))); + } + + async pruneStreamStates(olderThan: number): Promise { + const tx = this.db.transaction('stream_state_enc', 'readwrite'); + const idx = tx.store.index('byUpdatedAt'); + const range = IDBKeyRange.upperBound(olderThan, true); + let cursor = await idx.openCursor(range); + while (cursor) { + const row = cursor.value; + if (row.status === 'finished' || row.status === 'aborted') { + await cursor.delete(); + } + cursor = await cursor.continue(); + } + await tx.done; + } + + // ─── Peer verifications (V3.3) ──────────────────────────── + // Fingerprints are public-by-design; stored in plaintext for symmetry + // with the SQLite/Postgres encrypted backends. + + async savePeerVerification(v: PeerVerification): Promise { + await this.db.put('peer_verifications_enc', { ...v }); + } + + async getPeerVerification(address: string): Promise { + const row = await this.db.get('peer_verifications_enc', address); + if (!row) return null; + return { + peerAddress: row.peerAddress, + fingerprint: row.fingerprint, + verifiedAt: row.verifiedAt, + verifiedBy: row.verifiedBy as PeerVerificationSource, + identityVersion: row.identityVersion, + }; + } + + async removePeerVerification(address: string): Promise { + await this.db.delete('peer_verifications_enc', address); + } + + async getPeerIdentityVersion(address: string): Promise { + const row = await this.db.get('peer_identity_versions_enc', address); + return row ? row.version : 1; + } + + async bumpPeerIdentityVersion(address: string): Promise { + // Atomic read-modify-write under one IDB transaction. Closes the race + // that exists in the SQLite version's non-atomic read-then-upsert. + const tx = this.db.transaction('peer_identity_versions_enc', 'readwrite'); + const existing = await tx.store.get(address); + const next = (existing ? existing.version : 1) + 1; + await tx.store.put({ peerAddress: address, version: next }); + await tx.done; + return next; + } + + private async rowToStreamState(row: StreamStateEncRow): Promise { + const sensitive = await openStreamSensitive(this.km, row.streamId, row.ciphertext); + const out: PersistedStreamState = { + streamId: row.streamId, + direction: row.direction, + peerAddress: row.peerAddress, + status: row.status, + metadataJson: sensitive.metadataJson, + partitionJson: sensitive.partitionJson, + laneStateJson: sensitive.laneStateJson, + ioDescriptorJson: sensitive.ioDescriptorJson, + secretEnc: sensitive.secretEnc, + secretNonce: sensitive.secretNonce, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (sensitive.overallHashState !== undefined) out.overallHashState = sensitive.overallHashState; + return out; + } +} + +// ─── Schema ──────────────────────────────────────────────── + +const SCHEMA_VERSION = 1; + +interface MetaRow { key: string; value: string } +interface IdentityRow { id: 1; ciphertext: Uint8Array } +interface ConfigRow { key: string; ciphertext: Uint8Array } +interface SignedPreKeyRow { keyId: number; ciphertext: Uint8Array } +interface OneTimePreKeyRow { keyId: number; ciphertext: Uint8Array } +interface SessionRow { address: string; ciphertext: Uint8Array } +interface TrustedIdentityRow { address: string; ciphertext: Uint8Array } +interface RetiredIdentityRow { retiredAt: number; ciphertext: Uint8Array } + +interface StreamStateEncRow { + streamId: string; + direction: 'send' | 'receive'; + peerAddress: string; + status: 'active' | 'paused' | 'finished' | 'aborted'; + ciphertext: Uint8Array; + createdAt: number; + updatedAt: number; +} + +interface PeerVerificationRow { + peerAddress: string; + fingerprint: string; + verifiedAt: number; + verifiedBy: string; + identityVersion: number; +} + +interface PeerIdentityVersionRow { peerAddress: string; version: number } + +interface EncryptedShadeSchema extends DBSchema { + meta_enc: { key: string; value: MetaRow }; + identity_enc: { key: number; value: IdentityRow }; + config_enc: { key: string; value: ConfigRow }; + signed_prekeys_enc: { key: number; value: SignedPreKeyRow }; + one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow }; + sessions_enc: { key: string; value: SessionRow }; + trusted_identities_enc: { key: string; value: TrustedIdentityRow }; + retired_identities_enc: { + key: number; + value: RetiredIdentityRow; + indexes: { byRetiredAt: number }; + }; + stream_state_enc: { + key: string; + value: StreamStateEncRow; + indexes: { + byStatus: string; + byPeerAddress: string; + byUpdatedAt: number; + }; + }; + peer_verifications_enc: { key: string; value: PeerVerificationRow }; + peer_identity_versions_enc: { key: string; value: PeerIdentityVersionRow }; +} diff --git a/packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts b/packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts new file mode 100644 index 0000000..3d39b32 --- /dev/null +++ b/packages/shade-storage-encrypted/tests/encrypted-indexeddb.test.ts @@ -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 { + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +} + +const KEY_BYTES = randBytes(32); +async function freshKM(): Promise { + 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); + }); +}); diff --git a/packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts b/packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts new file mode 100644 index 0000000..ef14640 --- /dev/null +++ b/packages/shade-storage-encrypted/tests/key-manager-multi-factor.test.ts @@ -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(); + }); +}); diff --git a/packages/shade-storage-encrypted/tsconfig.json b/packages/shade-storage-encrypted/tsconfig.json index a3e0a93..a181690 100644 --- a/packages/shade-storage-encrypted/tsconfig.json +++ b/packages/shade-storage-encrypted/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "compilerOptions": { "outDir": "dist", "rootDir": "src", "lib": ["ES2022", "DOM"] }, "include": ["src"] } diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 5266dbc..7f92ed6 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 09cab38..70d81fc 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 4d34bc8..988eb79 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index 5226ee3..12a2d65 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index bdfaaf5..7c14465 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index ec86bc6..b636dad 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index ad1ad49..e916ac4 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 85571fc..542531a 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index f50a3e6..536ff35 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.4.0", + "version": "4.5.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",