mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 05:39:39 +02:00
Phase 2 of Mission Key-Grant. The tick loop now honours a mission's
grant by unwrapping the MDK and passing it + the record allowlist into
the resolvers. Encrypted modules (notes, tasks, calendar, journal,
kontext) resolve server-side instead of returning null.
- crypto/decrypt-value.ts: mirror of webapp AES-GCM wire format
(enc:1:<iv>.<ct>) — read-only, server never wraps
- db/resolvers/encrypted.ts: factory + 5 concrete resolvers. Scope-
violation bumps a metric + writes a structured audit row, decrypt
failures same. Zero-decrypt (no grant, or record absent) = silent
null, no audit noise.
- db/audit.ts: best-effort append to mana_ai.decrypt_audit; write
failures never cascade into tick failures.
- cron/tick.ts: buildResolverContext unwraps grant per mission; MDK
reference only lives for the scope of planOneMission.
- ResolverContext plumbed through resolveServerInputs; existing goals
resolver unchanged semantically.
- Metrics: mana_ai_decrypts_total{table}, mana_ai_grant_skips_total
{reason}, mana_ai_grant_scope_violations_total{table} (alert > 0).
Missions without a grant still run exactly as before — plaintext
resolvers fire, encrypted ones short-circuit to null. No behaviour
regression for existing users.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
/**
|
|
* AES-GCM unwrap — server-side mirror of the webapp's `aes.ts#unwrapValue`.
|
|
*
|
|
* Same wire format:
|
|
* `enc:1:${base64(iv)}.${base64(ct)}`
|
|
*
|
|
* The webapp's pipeline JSON-stringifies every value before encryption, so
|
|
* we JSON.parse after decryption and the type round-trips. Non-prefixed
|
|
* values pass through unchanged, matching the webapp's "safe to apply
|
|
* unconditionally" semantics.
|
|
*
|
|
* Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed
|
|
* blobs, wrong key. The resolver catches and converts into audit rows.
|
|
*
|
|
* The companion `wrapValue` is intentionally NOT implemented here —
|
|
* mana-ai never writes encrypted fields; it only reads them. Keeping the
|
|
* surface read-only makes it obvious in review that this service cannot
|
|
* corrupt user data cryptographically.
|
|
*/
|
|
|
|
const ENC_PREFIX = 'enc:1:';
|
|
const IV_LENGTH = 12;
|
|
|
|
export function isEncrypted(value: unknown): boolean {
|
|
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Decrypts a wire-format blob back to its original JSON value. For non-
|
|
* encrypted inputs (null, non-strings, strings without the prefix) it
|
|
* returns the value as-is. That way a resolver can apply it over every
|
|
* field of a mixed-encryption record without a per-field check.
|
|
*/
|
|
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-ai/crypto: malformed encrypted blob (missing iv/ct separator)');
|
|
}
|
|
|
|
const iv = base64ToBytes(body.slice(0, dotIndex));
|
|
const ct = base64ToBytes(body.slice(dotIndex + 1));
|
|
|
|
if (iv.length !== IV_LENGTH) {
|
|
throw new Error(`mana-ai/crypto: expected ${IV_LENGTH}-byte IV, got ${iv.length}`);
|
|
}
|
|
|
|
const plaintext = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: toBufferSource(iv) },
|
|
key,
|
|
toBufferSource(ct)
|
|
);
|
|
|
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
}
|
|
|
|
/**
|
|
* Decrypts every enc:1:-prefixed string field on a record in place. Non-
|
|
* prefixed fields are left alone. Returns the same record reference for
|
|
* caller convenience.
|
|
*
|
|
* Counts how many fields were decrypted so the caller can decide whether
|
|
* to write an audit row (zero-decrypt records don't need one — no secret
|
|
* was touched). Failures bubble up; the caller is responsible for the
|
|
* try/catch and audit bookkeeping.
|
|
*/
|
|
export async function decryptRecordFields(
|
|
record: Record<string, unknown>,
|
|
key: CryptoKey
|
|
): Promise<{ record: Record<string, unknown>; decryptedFields: string[] }> {
|
|
const decryptedFields: string[] = [];
|
|
for (const [k, v] of Object.entries(record)) {
|
|
if (!isEncrypted(v)) continue;
|
|
record[k] = await unwrapValue(v, key);
|
|
decryptedFields.push(k);
|
|
}
|
|
return { record, decryptedFields };
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|
const buf = new ArrayBuffer(bytes.length);
|
|
new Uint8Array(buf).set(bytes);
|
|
return buf;
|
|
}
|