mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 03:59:40 +02:00
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>
220 lines
8.3 KiB
TypeScript
220 lines
8.3 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|