feat(mana/web): encryption foundation — phase 1 (no-op)

Lays the groundwork for selective field-level encryption-at-rest in the
data layer. Phase 1 ships ONLY the building blocks; nothing is actually
encrypted yet (every registry entry has enabled:false), so this commit
is a no-op for app behaviour and safe to merge.

New module: src/lib/data/crypto/

aes.ts — pure Web Crypto AES-GCM-256 wrap/unwrap
  - wrapValue / unwrapValue with format-versioned envelope
    `enc:1:<base64-iv>.<base64-ct>` — one-scan detection, survives
    JSON.stringify on the sync wire, ~1.4× original byte length.
  - JSON-stringifies the input so any value type works (string, number,
    object, array). null/undefined pass through unchanged so optional
    fields don't need a guard at every call site.
  - Authenticated encryption: tampered ciphertext throws on decrypt.
  - generateMasterKey / importMasterKey / exportMasterKey for the
    Phase 2 server-side vault flow.
  - toBufferSource() helper works around the TS 5.7 Uint8Array generic
    parameterisation that broke the WebCrypto BufferSource overloads.

key-provider.ts — pluggable master-key source
  - KeyProvider interface (getKey, isUnlocked, onChange).
  - NullKeyProvider (default): always-locked, encryption call sites
    silently skip. Safe for the rollout window where individual tables
    are still flipping enabled:true.
  - MemoryKeyProvider: holds a CryptoKey in process memory only,
    notifies subscribers on lock/unlock transitions, sets a sentinel
    in sessionStorage so the UI can detect the unlock state on hard
    reload before the vault fetch completes.
  - setKeyProvider / getKeyProvider / getActiveKey / isVaultUnlocked
    are the boundary the rest of the data layer calls — no direct
    references to the concrete provider.

registry.ts — strict per-table allowlist
  - 30 tables registered, all enabled:false in Phase 1.
  - Field selection rule: encrypt user-typed text, transcripts, PII,
    free-form notes; leave IDs, timestamps, status flags, foreign
    keys, sort keys plaintext so the query/index/sync layer keeps
    working unchanged.
  - getEncryptedFields(table) returns null for the common (disabled)
    case so the Dexie hook hot-path stays allocation-free.
  - hasAnyEncryption() lets the boot path skip the vault fetch
    entirely while everything is still disabled.

index.ts — barrel export so consumers don't reach into sub-files.

aes.test.ts — 31 tests covering:
  - isEncrypted detection (string prefix, non-strings, wrong version)
  - wrap/unwrap roundtrip for string, empty string, unicode, object,
    array, number, boolean, 10KB blob, null, undefined, plaintext
    pass-through, null/undefined unwrap pass-through
  - IV uniqueness across repeated wraps of the same plaintext
  - Wrong-key rejection
  - Tampered-ciphertext rejection (auth tag mismatch)
  - Malformed-blob handling (missing iv/ct separator)
  - importMasterKey / exportMasterKey raw byte roundtrip
  - importMasterKey rejects non-32-byte input
  - KeyProvider lifecycle: NullKeyProvider default, MemoryKeyProvider
    set/get, listener fires only on transitions, dispose unsubscribes
  - Registry: returns null for unregistered/disabled tables, every
    entry has non-empty + duplicate-free fields list, hasAnyEncryption
    returns false in Phase 1

All tests pass against Node 20 native Web Crypto. No fake-indexeddb
needed — the foundation is pure functions over crypto.subtle.

Verified: 31/31 new tests + 291/291 full mana/web suite passing.

Phase 2: mana-auth server-side vault (encryption_vaults table, KEK
loading, GET /me/encryption-key endpoint).
Phase 3: wire MemoryKeyProvider to the vault fetch on login, flip
registry entries to enabled:true table by table, extend Dexie hooks
to call wrapValue/unwrapValue on configured fields.
Phase 4: settings UI (lock state, key rotation, recovery code opt-in).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 18:19:41 +02:00
parent 9e802b1e17
commit 1ba5948ce5
5 changed files with 816 additions and 0 deletions

View file

@ -0,0 +1,285 @@
/**
* AES-GCM wrap/unwrap primitive tests.
*
* Runs against the native Web Crypto API exposed on `globalThis.crypto`
* by Node 20+. No Dexie or fake-indexeddb needed these are pure
* functions over the standard subtle crypto interface.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import {
wrapValue,
unwrapValue,
isEncrypted,
generateMasterKey,
importMasterKey,
exportMasterKey,
ENC_PREFIX,
} from './aes';
import { MemoryKeyProvider, setKeyProvider, getActiveKey, isVaultUnlocked } from './key-provider';
import {
getEncryptedFields,
hasAnyEncryption,
getRegisteredTables,
ENCRYPTION_REGISTRY,
} from './registry';
let key: CryptoKey;
let otherKey: CryptoKey;
beforeAll(async () => {
key = await generateMasterKey();
otherKey = await generateMasterKey();
});
describe('isEncrypted', () => {
it('detects the encryption prefix', () => {
expect(isEncrypted('enc:1:abc.def')).toBe(true);
});
it('rejects non-string values', () => {
expect(isEncrypted(null)).toBe(false);
expect(isEncrypted(undefined)).toBe(false);
expect(isEncrypted(42)).toBe(false);
expect(isEncrypted({})).toBe(false);
expect(isEncrypted([])).toBe(false);
});
it('rejects strings without the prefix', () => {
expect(isEncrypted('hello world')).toBe(false);
expect(isEncrypted('')).toBe(false);
expect(isEncrypted('enc:')).toBe(false);
expect(isEncrypted('enc:2:abc.def')).toBe(false); // wrong version
});
});
describe('wrapValue / unwrapValue roundtrip', () => {
it('roundtrips a plain string', async () => {
const blob = await wrapValue('Buy milk', key);
expect(typeof blob).toBe('string');
expect((blob as string).startsWith(ENC_PREFIX)).toBe(true);
expect(await unwrapValue(blob, key)).toBe('Buy milk');
});
it('roundtrips an empty string', async () => {
const blob = await wrapValue('', key);
expect(await unwrapValue(blob, key)).toBe('');
});
it('roundtrips a unicode string', async () => {
const original = 'Schlüssel — 日本語 — 🔐 — ☃';
const blob = await wrapValue(original, key);
expect(await unwrapValue(blob, key)).toBe(original);
});
it('roundtrips an object', async () => {
const original = { title: 'Meeting', attendees: ['Alice', 'Bob'], priority: 2 };
const blob = await wrapValue(original, key);
expect(await unwrapValue(blob, key)).toEqual(original);
});
it('roundtrips an array', async () => {
const original = [1, 'two', { three: true }, null];
const blob = await wrapValue(original, key);
expect(await unwrapValue(blob, key)).toEqual(original);
});
it('roundtrips a number', async () => {
const blob = await wrapValue(42, key);
expect(await unwrapValue(blob, key)).toBe(42);
});
it('roundtrips a boolean', async () => {
const trueBlob = await wrapValue(true, key);
const falseBlob = await wrapValue(false, key);
expect(await unwrapValue(trueBlob, key)).toBe(true);
expect(await unwrapValue(falseBlob, key)).toBe(false);
});
it('roundtrips a large string (10KB)', async () => {
const large = 'x'.repeat(10_000);
const blob = await wrapValue(large, key);
expect(await unwrapValue(blob, key)).toBe(large);
});
it('passes null through unchanged on wrap', async () => {
expect(await wrapValue(null, key)).toBe(null);
});
it('passes undefined through unchanged on wrap', async () => {
expect(await wrapValue(undefined, key)).toBe(undefined);
});
it('passes plaintext strings through unchanged on unwrap', async () => {
expect(await unwrapValue('not encrypted', key)).toBe('not encrypted');
});
it('passes null/undefined through unchanged on unwrap', async () => {
expect(await unwrapValue(null, key)).toBe(null);
expect(await unwrapValue(undefined, key)).toBe(undefined);
});
});
describe('IV uniqueness', () => {
it('produces a different ciphertext for the same plaintext on each call', async () => {
const a = await wrapValue('same input', key);
const b = await wrapValue('same input', key);
const c = await wrapValue('same input', key);
expect(a).not.toBe(b);
expect(b).not.toBe(c);
expect(a).not.toBe(c);
// All three still decrypt to the same value
expect(await unwrapValue(a, key)).toBe('same input');
expect(await unwrapValue(b, key)).toBe('same input');
expect(await unwrapValue(c, key)).toBe('same input');
});
});
describe('wrong key rejection', () => {
it('throws when the wrong key tries to decrypt', async () => {
const blob = await wrapValue('secret', key);
await expect(unwrapValue(blob, otherKey)).rejects.toThrow();
});
});
describe('tampered ciphertext rejection', () => {
it('throws when the ciphertext bytes are flipped', async () => {
const blob = (await wrapValue('secret', key)) as string;
// Flip the last character to corrupt the auth tag
const lastChar = blob.charAt(blob.length - 1);
const swapChar = lastChar === 'A' ? 'B' : 'A';
const tampered = blob.slice(0, -1) + swapChar;
await expect(unwrapValue(tampered, key)).rejects.toThrow();
});
});
describe('malformed blob handling', () => {
it('throws when the iv/ct separator is missing', async () => {
await expect(unwrapValue('enc:1:noSeparatorHere', key)).rejects.toThrow(
/missing iv\/ct separator/
);
});
});
describe('importMasterKey / exportMasterKey', () => {
it('roundtrips a key through raw bytes', async () => {
const exported = await exportMasterKey(key);
expect(exported.length).toBe(32);
const reimported = await importMasterKey(exported);
// Use the reimported key to decrypt something the original encrypted
const blob = await wrapValue('roundtrip via raw bytes', key);
expect(await unwrapValue(blob, reimported)).toBe('roundtrip via raw bytes');
});
it('rejects raw input that is not 32 bytes', async () => {
await expect(importMasterKey(new Uint8Array(16))).rejects.toThrow(/32-byte/);
await expect(importMasterKey(new Uint8Array(64))).rejects.toThrow(/32-byte/);
});
});
describe('KeyProvider', () => {
it('NullKeyProvider is the default and reports locked', () => {
// Re-set to default explicitly in case a previous test left state
setKeyProvider(
new (class {
getKey() {
return null;
}
isUnlocked() {
return false;
}
onChange() {
return () => {};
}
})()
);
expect(getActiveKey()).toBe(null);
expect(isVaultUnlocked()).toBe(false);
});
it('MemoryKeyProvider holds a key and reports unlocked', async () => {
const provider = new MemoryKeyProvider();
expect(provider.isUnlocked()).toBe(false);
expect(provider.getKey()).toBe(null);
provider.setKey(key);
expect(provider.isUnlocked()).toBe(true);
expect(provider.getKey()).toBe(key);
provider.setKey(null);
expect(provider.isUnlocked()).toBe(false);
});
it('MemoryKeyProvider notifies listeners on lock/unlock transitions', () => {
const provider = new MemoryKeyProvider();
const events: boolean[] = [];
const dispose = provider.onChange((unlocked) => events.push(unlocked));
provider.setKey(key);
provider.setKey(key); // no transition — should not fire
provider.setKey(null);
provider.setKey(null); // no transition — should not fire
provider.setKey(key);
expect(events).toEqual([true, false, true]);
dispose();
// After dispose, no more notifications
provider.setKey(null);
expect(events).toEqual([true, false, true]);
});
it('setKeyProvider swaps the active provider', async () => {
const provider = new MemoryKeyProvider();
provider.setKey(key);
setKeyProvider(provider);
expect(isVaultUnlocked()).toBe(true);
expect(getActiveKey()).toBe(key);
// Reset for next tests
provider.setKey(null);
expect(isVaultUnlocked()).toBe(false);
});
});
describe('encryption registry', () => {
it('returns null for tables not in the registry', () => {
expect(getEncryptedFields('not_a_real_table')).toBe(null);
});
it('returns null for registered tables that are disabled', () => {
// Phase 1: every entry is enabled:false
expect(getEncryptedFields('messages')).toBe(null);
expect(getEncryptedFields('notes')).toBe(null);
expect(getEncryptedFields('contacts')).toBe(null);
});
it('hasAnyEncryption returns false in Phase 1', () => {
expect(hasAnyEncryption()).toBe(false);
});
it('getRegisteredTables lists every table in the registry', () => {
const tables = getRegisteredTables();
// Spot-check the most sensitive ones
expect(tables).toContain('messages');
expect(tables).toContain('notes');
expect(tables).toContain('memos');
expect(tables).toContain('contacts');
expect(tables).toContain('cycleDayLogs');
});
it('every registry entry has a non-empty fields list', () => {
for (const [table, config] of Object.entries(ENCRYPTION_REGISTRY)) {
expect(config.fields.length, `${table} has empty fields list`).toBeGreaterThan(0);
}
});
it('field lists contain no duplicates', () => {
for (const [table, config] of Object.entries(ENCRYPTION_REGISTRY)) {
const unique = new Set(config.fields);
expect(unique.size, `${table} has duplicate field names`).toBe(config.fields.length);
}
});
});

View file

@ -0,0 +1,186 @@
/**
* AES-GCM-256 wrap/unwrap primitives.
*
* Pure crypto layer with no state and no Dexie dependency. The higher-level
* registry/key-provider modules use these to encrypt configured fields on
* the way into IndexedDB and decrypt them on the way out.
*
* Wire format
* `enc:${VERSION}:${base64(iv)}.${base64(ct)}`
*
* The string-prefix format (rather than a JSON envelope) is deliberate:
* - One scan to detect "is this encrypted?" `value.startsWith('enc:1:')`
* - Survives JSON.stringify when records flow through the sync wire
* - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope
* - Trivial to bump VERSION for future format migrations
*
* Authenticated encryption: AES-GCM provides both confidentiality and
* tamper-detection. A modified ciphertext fails decryption with an
* OperationError instead of returning silent garbage `unwrapValue`
* surfaces that as a thrown error so callers can react.
*
* Value types: anything JSON-serialisable. The plaintext is JSON.stringified
* before encryption, JSON.parsed after decryption. `null` and `undefined`
* pass through unchanged so callers can blindly wrap optional fields
* without checking each one first.
*/
/** Bumped if the wire format ever changes. Old blobs stay readable as long
* as `unwrapValue` knows how to handle their version prefix. */
export const ENCRYPTION_VERSION = 1;
/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */
export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`;
/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not
* recommended by NIST and would only burn entropy. */
const IV_LENGTH = 12;
// ─── Base64 helpers ───────────────────────────────────────────
//
// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator
// hits the JS argument limit (~65k) for large records. The manual loop is
// O(n) and works for any size.
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/**
* TypeScript 5.7+ parameterised Uint8Array with the underlying buffer
* type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource`
* type still expects a plain ArrayBuffer-backed view, so we need to copy
* the bytes through a fresh ArrayBuffer to satisfy the strict type check.
*
* This is a TypeScript-only annoyance at runtime the call would have
* worked fine with the original Uint8Array. The copy is O(n) and
* negligible for the field sizes we encrypt (< 100 KB typical).
*/
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}
// ─── Public API ───────────────────────────────────────────────
/**
* Returns true iff `value` is a string carrying the encryption prefix.
*
* Cheap synchronous detection no decryption attempted. Use this to
* decide whether a field needs to be unwrapped on read, or whether a
* value coming back from a backend pull is already encrypted.
*/
export function isEncrypted(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
}
/**
* Encrypts `value` with `key` and returns the wire-format string. Pass-
* through for `null` / `undefined` so optional-field call sites stay
* concise:
*
* record.title = await wrapValue(record.title, key);
* record.notes = await wrapValue(record.notes, key); // safe even if null
*
* Throws if `key` is unusable (wrong algorithm, wrong usages). Each call
* generates a fresh random IV never reuse one for the same key.
*/
export async function wrapValue(value: unknown, key: CryptoKey): Promise<unknown> {
if (value === null || value === undefined) return value;
const json = JSON.stringify(value);
const plaintext = new TextEncoder().encode(json);
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(plaintext)
);
return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct));
}
/**
* Decrypts a wire-format string back to its original JS value. Pass-
* through for non-strings, `null`/`undefined`, and any string that
* doesn't carry the encryption prefix that way `unwrapValue` is safe
* to apply unconditionally to mixed records.
*
* Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed
* blobs, or wrong key. Callers should treat the throw as data corruption
* there's no soft-recovery path.
*/
export async function unwrapValue(blob: unknown, key: CryptoKey): Promise<unknown> {
if (!isEncrypted(blob)) return blob;
const body = (blob as string).slice(ENC_PREFIX.length);
const dotIndex = body.indexOf('.');
if (dotIndex === -1) {
throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)');
}
const iv = base64ToBytes(body.slice(0, dotIndex));
const ct = base64ToBytes(body.slice(dotIndex + 1));
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(ct)
);
const json = new TextDecoder().decode(plaintext);
return JSON.parse(json);
}
/**
* Generates a fresh AES-GCM-256 key. Used at vault initialisation time
* (Phase 2: server-side; tests: in-memory) to mint the per-user master
* key. The key is `extractable: true` so the server can wrap it with
* the KEK before storing set to `false` for client-side derived keys
* that should never leave the browser.
*/
export async function generateMasterKey(extractable = true): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [
'encrypt',
'decrypt',
]);
}
/**
* Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the
* Phase 3 client to take the bytes the vault endpoint returns and turn
* them into a non-extractable CryptoKey instance for runtime use.
*/
export async function importMasterKey(rawBytes: Uint8Array): Promise<CryptoKey> {
if (rawBytes.length !== 32) {
throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`);
}
return crypto.subtle.importKey(
'raw',
toBufferSource(rawBytes),
{ name: 'AES-GCM', length: 256 },
false, // non-extractable: once it's in the browser, it stays there
['encrypt', 'decrypt']
);
}
/**
* Exports a key back to its raw 32 bytes. Only works on extractable
* keys; non-extractable keys throw. Used by tests and the Phase 2
* server-side wrap path.
*/
export async function exportMasterKey(key: CryptoKey): Promise<Uint8Array> {
const raw = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(raw);
}

View file

@ -0,0 +1,45 @@
/**
* Public surface of the data-layer encryption module.
*
* Phase 1 (this commit) ships the foundation only:
* - AES-GCM-256 wrap/unwrap primitives
* - KeyProvider interface with NullKeyProvider as the default
* - Strict allowlist registry of fields-to-encrypt (all `enabled: false`)
*
* No table is actually encrypted yet. Phase 2 wires up the mana-auth
* server vault that mints + serves the per-user master key. Phase 3
* flips the registry entries to `enabled: true` table by table and
* teaches the Dexie hooks to call wrapValue/unwrapValue on the right
* fields. Phase 4 polishes the UX (settings page, lock state UI).
*
* Importers should pull from this barrel rather than reaching into the
* sub-files directly, so future internal refactors stay invisible.
*/
export {
ENCRYPTION_VERSION,
ENC_PREFIX,
isEncrypted,
wrapValue,
unwrapValue,
generateMasterKey,
importMasterKey,
exportMasterKey,
} from './aes';
export {
type KeyProvider,
MemoryKeyProvider,
setKeyProvider,
getKeyProvider,
getActiveKey,
isVaultUnlocked,
} from './key-provider';
export {
type EncryptionConfig,
ENCRYPTION_REGISTRY,
getEncryptedFields,
hasAnyEncryption,
getRegisteredTables,
} from './registry';

View file

@ -0,0 +1,134 @@
/**
* Pluggable master-key provider.
*
* The encryption pipeline (Dexie hooks, liveQuery wrapper) reads the active
* master key through `getActiveKey()` rather than holding a reference itself.
* That indirection lets us swap implementations without touching call sites:
*
* - NullKeyProvider default. No key available encryption is a
* no-op. Used during the gradual rollout while
* individual tables are still `enabled: false`,
* and in tests that don't care about crypto.
* - MemoryKeyProvider holds an unwrapped CryptoKey in memory only.
* Wired up in Phase 3 with the bytes returned by
* the mana-auth `/me/encryption-key` endpoint.
*
* Why an interface and not a global variable?
* - Tests can swap in fixed keys without monkey-patching imports
* - Future PasskeyKeyProvider can replace the in-memory one without
* touching the rest of the data layer
* - Lock/unlock state changes are observable via `onChange`, so the
* UI can react (show "vault locked" overlay, refetch encrypted lists)
*/
export interface KeyProvider {
/** Returns the active master key, or `null` if the vault is locked
* or no key has been provided yet. Synchronous on purpose so the
* Dexie hooks can call it inline without async overhead. */
getKey(): CryptoKey | null;
/** True iff a key is currently available. Cheaper to check than
* `getKey() !== null` if the caller doesn't need the key itself. */
isUnlocked(): boolean;
/** Subscribe to lock/unlock transitions. Returns a dispose function.
* Listeners fire only on STATE CHANGES, not on every getKey call. */
onChange(listener: (unlocked: boolean) => void): () => void;
}
// ─── NullKeyProvider — default ─────────────────────────────────
/**
* Always-locked provider. Encryption call sites silently skip when this
* is active, so the app keeps running with plaintext data even though
* the encryption code paths are technically wired up. This is the safe
* default during Phase 1 (no tables flipped to `enabled: true` yet).
*/
class NullKeyProvider implements KeyProvider {
getKey(): null {
return null;
}
isUnlocked(): boolean {
return false;
}
onChange(): () => void {
return () => {};
}
}
// ─── MemoryKeyProvider — Phase 3 production path ───────────────
/**
* Holds a CryptoKey in process memory. The key never touches localStorage,
* IndexedDB, or any other persistent surface only sessionStorage gets a
* one-bit "is unlocked" sentinel for the UI to read on hard reload (so it
* knows whether to immediately show the locked overlay or wait for the
* vault fetch).
*/
export class MemoryKeyProvider implements KeyProvider {
private key: CryptoKey | null = null;
private listeners = new Set<(unlocked: boolean) => void>();
/** Set or clear the active key. `null` locks the vault. */
setKey(key: CryptoKey | null): void {
const wasUnlocked = this.key !== null;
this.key = key;
const nowUnlocked = key !== null;
if (typeof window !== 'undefined') {
if (nowUnlocked) sessionStorage.setItem('mana-vault-unlocked', '1');
else sessionStorage.removeItem('mana-vault-unlocked');
}
if (wasUnlocked !== nowUnlocked) {
for (const fn of this.listeners) {
try {
fn(nowUnlocked);
} catch (err) {
// Listeners are UI subscribers — never let one bad listener
// break the lock/unlock cycle.
console.error('[mana-crypto] vault listener threw:', err);
}
}
}
}
getKey(): CryptoKey | null {
return this.key;
}
isUnlocked(): boolean {
return this.key !== null;
}
onChange(listener: (unlocked: boolean) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
}
// ─── Module-level active provider ──────────────────────────────
let _activeProvider: KeyProvider = new NullKeyProvider();
/** Replace the active provider. Called once at app boot in Phase 3. */
export function setKeyProvider(provider: KeyProvider): void {
_activeProvider = provider;
}
/** Returns the currently-installed provider. */
export function getKeyProvider(): KeyProvider {
return _activeProvider;
}
/** Convenience: returns the active key or `null` if locked. */
export function getActiveKey(): CryptoKey | null {
return _activeProvider.getKey();
}
/** Convenience: synchronous lock check. */
export function isVaultUnlocked(): boolean {
return _activeProvider.isUnlocked();
}

View file

@ -0,0 +1,166 @@
/**
* Encryption registry single source of truth for which fields on which
* tables get encrypted.
*
* Strict allowlist semantics: anything not listed here stays plaintext.
* Adding a new module = adding an entry here. Forgetting to add a field
* means it ships in plaintext, which is the safer failure mode than the
* inverse (a typo'd field name silently failing to decrypt).
*
* Why a central registry instead of per-module config?
* - One pull request to audit ahead of a release: "what is encrypted?"
* - The Dexie hook in database.ts iterates this map once at startup
* instead of looking up per-module config files at write time
* - Keeps the encryption surface visible to security review without
* hunting through 27 module directories
*
* Phasing:
* `enabled: false` is the safe default for Phase 1. The actual flip
* to `true` happens in Phase 3, table by table, after the server-side
* vault is wired up and the key provider is no longer NullKeyProvider.
* This means Phase 1 can land on main without changing app behaviour.
*
* Field selection rules:
* - Encrypt: user-typed text, transcripts, PII, free-form notes,
* anything that would embarrass or harm the user if it leaked
* - Plaintext: IDs, foreign keys, timestamps, status flags, sort keys,
* enum discriminators, anything indexed for queries
* - When in doubt about a free-form field, encrypt it
* - When in doubt about a structural field, leave it plaintext (the
* query layer needs it)
*/
export interface EncryptionConfig {
/** Field names that get encrypted on write, decrypted on read. */
readonly fields: readonly string[];
/** Phase 1 hard-default: false. Flipped table by table in Phase 3. */
readonly enabled: boolean;
}
export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// ─── Chat ────────────────────────────────────────────────
messages: { enabled: false, fields: ['messageText'] },
conversations: { enabled: false, fields: ['title'] },
chatTemplates: {
enabled: false,
fields: ['name', 'description', 'systemPrompt', 'initialQuestion'],
},
// ─── Notes ───────────────────────────────────────────────
notes: { enabled: false, fields: ['title', 'body', 'content'] },
// ─── Dreams ──────────────────────────────────────────────
dreams: { enabled: false, fields: ['title', 'content', 'notes'] },
dreamSymbols: { enabled: false, fields: ['name', 'meaning'] },
// ─── Memoro ──────────────────────────────────────────────
memos: { enabled: false, fields: ['title', 'intro', 'transcript'] },
memories: { enabled: false, fields: ['title', 'content'] },
// ─── Contacts ────────────────────────────────────────────
contacts: {
enabled: false,
fields: [
'firstName',
'lastName',
'email',
'phone',
'mobile',
'birthday',
'street',
'city',
'postalCode',
'country',
'notes',
'website',
'linkedin',
'twitter',
'instagram',
'github',
],
},
// ─── Tasks ───────────────────────────────────────────────
tasks: { enabled: false, fields: ['title', 'description', 'subtasks', 'metadata'] },
// ─── Calendar ────────────────────────────────────────────
events: { enabled: false, fields: ['title', 'description', 'location'] },
// ─── Cycles ──────────────────────────────────────────────
cycles: { enabled: false, fields: ['notes'] },
cycleDayLogs: { enabled: false, fields: ['notes', 'symptoms', 'mood'] },
// ─── NutriPhi ────────────────────────────────────────────
meals: { enabled: false, fields: ['description', 'notes', 'aiAnalysis'] },
// ─── Planta ──────────────────────────────────────────────
plants: { enabled: false, fields: ['name', 'notes', 'careNotes'] },
// ─── Cards ───────────────────────────────────────────────
cards: { enabled: false, fields: ['front', 'back', 'notes'] },
cardDecks: { enabled: false, fields: ['title', 'description'] },
// ─── Presi ───────────────────────────────────────────────
presiDecks: { enabled: false, fields: ['title', 'description'] },
slides: { enabled: false, fields: ['content', 'notes'] },
// ─── Context ─────────────────────────────────────────────
documents: { enabled: false, fields: ['title', 'content', 'body'] },
// ─── Storage ─────────────────────────────────────────────
files: { enabled: false, fields: ['name', 'originalName', 'notes'] },
// ─── Picture ─────────────────────────────────────────────
images: { enabled: false, fields: ['prompt', 'negativePrompt', 'revisedPrompt', 'notes'] },
// ─── Music ───────────────────────────────────────────────
songs: { enabled: false, fields: ['title', 'artist', 'album', 'lyrics', 'notes'] },
mukkePlaylists: { enabled: false, fields: ['name', 'description'] },
// ─── Questions ───────────────────────────────────────────
questions: { enabled: false, fields: ['title', 'body', 'notes'] },
answers: { enabled: false, fields: ['body'] },
// ─── Events (social gatherings) ──────────────────────────
socialEvents: { enabled: false, fields: ['title', 'description', 'notes'] },
eventGuests: { enabled: false, fields: ['name', 'email', 'phone', 'notes'] },
// ─── Finance ─────────────────────────────────────────────
transactions: { enabled: false, fields: ['description', 'notes', 'merchant'] },
// ─── uLoad ───────────────────────────────────────────────
links: { enabled: false, fields: ['title', 'description', 'targetUrl'] },
manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] },
// ─── Inventar ────────────────────────────────────────────
invItems: { enabled: false, fields: ['name', 'description', 'notes'] },
};
/**
* Returns the field allowlist for `tableName`, or `null` if the table is
* either not registered, currently disabled, or has an empty field list.
* Hot-path helper used by the Dexie hooks must stay synchronous and
* allocation-free for the common (non-encrypted) case.
*/
export function getEncryptedFields(tableName: string): readonly string[] | null {
const config = ENCRYPTION_REGISTRY[tableName];
if (!config || !config.enabled || config.fields.length === 0) return null;
return config.fields;
}
/** True if at least one table is currently flipped to encrypted. Used by
* the Phase 3 boot path to decide whether to fetch the master key at
* all no point asking the server for a key when nothing uses it. */
export function hasAnyEncryption(): boolean {
for (const config of Object.values(ENCRYPTION_REGISTRY)) {
if (config.enabled) return true;
}
return false;
}
/** All table names that have an encryption entry, regardless of whether
* they're currently enabled. Used by the rollout audit and the
* DATA_LAYER_AUDIT.md doc to confirm coverage. */
export function getRegisteredTables(): string[] {
return Object.keys(ENCRYPTION_REGISTRY);
}