mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:01:09 +02:00
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:
parent
9e802b1e17
commit
1ba5948ce5
5 changed files with 816 additions and 0 deletions
285
apps/mana/apps/web/src/lib/data/crypto/aes.test.ts
Normal file
285
apps/mana/apps/web/src/lib/data/crypto/aes.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
186
apps/mana/apps/web/src/lib/data/crypto/aes.ts
Normal file
186
apps/mana/apps/web/src/lib/data/crypto/aes.ts
Normal 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);
|
||||||
|
}
|
||||||
45
apps/mana/apps/web/src/lib/data/crypto/index.ts
Normal file
45
apps/mana/apps/web/src/lib/data/crypto/index.ts
Normal 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';
|
||||||
134
apps/mana/apps/web/src/lib/data/crypto/key-provider.ts
Normal file
134
apps/mana/apps/web/src/lib/data/crypto/key-provider.ts
Normal 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();
|
||||||
|
}
|
||||||
166
apps/mana/apps/web/src/lib/data/crypto/registry.ts
Normal file
166
apps/mana/apps/web/src/lib/data/crypto/registry.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue