mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 22:09: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>
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
/**
|
|
* Round-trip test between the webapp's wrap format and mana-ai's unwrap.
|
|
*
|
|
* The webapp's wire format is documented in
|
|
* `apps/mana/apps/web/src/lib/data/crypto/aes.ts`. We re-implement the
|
|
* wrap side here (server-side we never wrap, but the test needs to
|
|
* produce the wire format somehow) and assert the unwrap result matches
|
|
* the original value.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'bun:test';
|
|
import { isEncrypted, unwrapValue, decryptRecordFields } from './decrypt-value';
|
|
|
|
const ENC_PREFIX = 'enc:1:';
|
|
|
|
async function webappWrap(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(12));
|
|
const ct = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv: toBufferSource(iv) },
|
|
key,
|
|
toBufferSource(plaintext)
|
|
);
|
|
return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct));
|
|
}
|
|
|
|
async function freshKey(): Promise<CryptoKey> {
|
|
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
}
|
|
|
|
describe('isEncrypted', () => {
|
|
it('recognises the prefix', () => {
|
|
expect(isEncrypted('enc:1:abc.def')).toBe(true);
|
|
expect(isEncrypted('plain string')).toBe(false);
|
|
expect(isEncrypted(null)).toBe(false);
|
|
expect(isEncrypted(undefined)).toBe(false);
|
|
expect(isEncrypted(42)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('unwrapValue', () => {
|
|
it('round-trips strings', async () => {
|
|
const key = await freshKey();
|
|
const blob = await webappWrap('hello world', key);
|
|
expect(await unwrapValue(blob, key)).toBe('hello world');
|
|
});
|
|
|
|
it('round-trips arrays and objects (same JSON envelope as webapp)', async () => {
|
|
const key = await freshKey();
|
|
const arr = ['a', 1, { nested: true }];
|
|
const blobArr = await webappWrap(arr, key);
|
|
expect(await unwrapValue(blobArr, key)).toEqual(arr);
|
|
|
|
const obj = { title: 'x', tags: ['y', 'z'] };
|
|
const blobObj = await webappWrap(obj, key);
|
|
expect(await unwrapValue(blobObj, key)).toEqual(obj);
|
|
});
|
|
|
|
it('passes through null, undefined, non-strings', async () => {
|
|
const key = await freshKey();
|
|
expect(await unwrapValue(null, key)).toBe(null);
|
|
expect(await unwrapValue(undefined, key)).toBe(undefined);
|
|
expect(await unwrapValue(42, key)).toBe(42);
|
|
expect(await unwrapValue('plain', key)).toBe('plain');
|
|
});
|
|
|
|
it('throws on tampered ciphertext', async () => {
|
|
const key = await freshKey();
|
|
const blob = (await webappWrap('secret', key)) as string;
|
|
// Flip a byte in the ciphertext part (after the dot).
|
|
const dot = blob.indexOf('.');
|
|
const tampered = blob.slice(0, dot + 1) + 'A' + blob.slice(dot + 2);
|
|
await expect(unwrapValue(tampered, key)).rejects.toThrow();
|
|
});
|
|
|
|
it('throws on malformed prefix', async () => {
|
|
const key = await freshKey();
|
|
await expect(unwrapValue('enc:1:nodot', key)).rejects.toThrow(/malformed/);
|
|
});
|
|
|
|
it('throws on wrong key', async () => {
|
|
const k1 = await freshKey();
|
|
const k2 = await freshKey();
|
|
const blob = await webappWrap('secret', k1);
|
|
await expect(unwrapValue(blob, k2)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('decryptRecordFields', () => {
|
|
it('decrypts only enc: fields and reports which', async () => {
|
|
const key = await freshKey();
|
|
const record = {
|
|
id: 'r1',
|
|
title: await webappWrap('Private Title', key),
|
|
content: await webappWrap({ markdown: 'secret' }, key),
|
|
createdAt: '2026-04-15',
|
|
userId: 'u1',
|
|
};
|
|
const { record: out, decryptedFields } = await decryptRecordFields(record, key);
|
|
expect(out.title).toBe('Private Title');
|
|
expect(out.content).toEqual({ markdown: 'secret' });
|
|
expect(out.id).toBe('r1');
|
|
expect(out.createdAt).toBe('2026-04-15');
|
|
expect(decryptedFields.sort()).toEqual(['content', 'title']);
|
|
});
|
|
|
|
it('is a no-op for records with no encrypted fields', async () => {
|
|
const key = await freshKey();
|
|
const record = { id: 'r1', value: 42, period: 'week' };
|
|
const { decryptedFields } = await decryptRecordFields(record, key);
|
|
expect(decryptedFields).toEqual([]);
|
|
});
|
|
});
|
|
|
|
function bytesToBase64(bytes: Uint8Array): string {
|
|
let bin = '';
|
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
|
|
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
|
const buf = new ArrayBuffer(bytes.length);
|
|
new Uint8Array(buf).set(bytes);
|
|
return buf;
|
|
}
|