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>
This commit is contained in:
Till JS 2026-04-23 13:18:35 +02:00
parent f719d1768f
commit 16c8818338
31 changed files with 2958 additions and 360 deletions

View file

@ -0,0 +1,220 @@
/**
* 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;
}

View file

@ -0,0 +1,12 @@
export {
ENC_PREFIX,
ENCRYPTION_VERSION,
decryptRecordFields,
encryptRecordFields,
exportMasterKey,
generateMasterKey,
importMasterKey,
isEncrypted,
unwrapValue,
wrapValue,
} from './aes.ts';