managarten/packages/shared-crypto/src/aes.ts
Till JS 16c8818338 feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.

New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
  with zod generics, scope ('user-space' | 'admin') and policyHint
  ('read' | 'write' | 'destructive'). sync-client helpers speak the
  mana-sync push/pull protocol directly so RLS and field-level LWW are
  preserved. MasterKeyClient fetches per-user MKs via the existing
  mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
  ZK-aware, already audited) — no new service-key endpoint built.
  ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
  app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
  browser produce byte-for-byte identical wire format
  (enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
  shared-crypto — 5 existing importers unchanged, svelte-check stays
  green.

New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
  JWKS auth against mana-auth, per-user session isolation (session-id
  belongs to the user who opened it — cross-user access returns 403),
  admin-scoped tools filtered out before registration. MasterKeyClient
  cached per process with a 5-minute TTL.

11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
  (encrypted — field lists match
  apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)

Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
  tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated

Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.

Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
  service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
  apps/api/src/mcp/server.ts get merged into it in M4 (three current
  parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
  Agent SDK + MCP client (D5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:18:35 +02:00

220 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AES-GCM-256 wrap/unwrap primitives — runtime-agnostic.
*
* Pure crypto layer with no state, no Dexie dependency, no module registry.
* Web app + mana-mcp tool handlers + any future agent-side consumer share
* this exact wire format so round-tripping between writers/readers is safe.
*
* 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);
}
// ─── Batch helpers (new in M1.5) ──────────────────────────────
//
// Tool handlers work record-at-a-time and need an explicit field list
// (the web app uses a registry lookup, but we keep shared-crypto registry-
// free to avoid a cyclic import with the module type definitions). The
// caller passes the list — typically derived from the authoritative
// registry in apps/mana/apps/web/src/lib/data/crypto/registry.ts.
/** Shallow-copy `record` with the named fields wrap-encrypted. Nullish fields stay nullish. */
export async function encryptRecordFields<T extends Record<string, unknown>>(
record: T,
fields: readonly (keyof T & string)[],
key: CryptoKey
): Promise<T> {
const out = { ...record };
for (const field of fields) {
out[field] = (await wrapValue(out[field], key)) as T[typeof field];
}
return out;
}
/** Shallow-copy `record` with the named fields decrypted. Pass-through for non-encrypted values. */
export async function decryptRecordFields<T extends Record<string, unknown>>(
record: T,
fields: readonly (keyof T & string)[],
key: CryptoKey
): Promise<T> {
const out = { ...record };
for (const field of fields) {
out[field] = (await unwrapValue(out[field], key)) as T[typeof field];
}
return out;
}