feat(shared-ai): Mission Key-Grant contract + plan for encrypted server-side runs

Foundation for Phase 2+ of the Mission Key-Grant flow: lets mana-ai
execute missions that depend on encrypted inputs (notes/tasks/events/
journal/kontext) without needing an open browser tab. Opt-in per
mission, Zero-Knowledge users excluded.

- Canonical HKDF-SHA256 derivation (scope-bound via tables + recordIds
  in the HKDF info string → scope changes invalidate the grant
  cryptographically, not just via a runtime check)
- Mission.grant field on the shared Mission type
- Golden snapshot + drift-guard test so webapp wrap path and mana-auth
  wrap endpoint can't silently diverge
- Ideas backlog at docs/future/AI_AGENTS_IDEAS.md
- Full rollout plan at docs/plans/ai-mission-key-grant.md
- COMPANION_BRAIN_ARCHITECTURE.md §21 captures the flow + privacy
  guarantees + non-goals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:41:35 +02:00
parent 9809b06adf
commit 6882ffb626
8 changed files with 610 additions and 0 deletions

View file

@ -17,6 +17,15 @@ export type {
MissionIteration,
MissionState,
PlanStep,
GrantDerivation,
GrantDerivationVersion,
MissionGrant,
} from './missions';
export {
GRANT_DERIVATION_VERSION,
canonicalInfoString,
deriveMissionDataKey,
deriveMissionDataKeyRaw,
} from './missions';
export type {

View file

@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
GRANT_DERIVATION_VERSION,
canonicalInfoString,
deriveMissionDataKeyRaw,
type GrantDerivation,
} from './grant';
const fixedMasterKey = new Uint8Array(32).map((_, i) => i + 1); // 01..20
function derivation(overrides: Partial<GrantDerivation> = {}): GrantDerivation {
return {
version: GRANT_DERIVATION_VERSION,
missionId: 'mission-abc',
tables: ['notes', 'tasks'],
recordIds: ['notes:n1', 'tasks:t1'],
...overrides,
};
}
describe('canonicalInfoString', () => {
it('is order-independent in tables and recordIds', () => {
const a = canonicalInfoString(derivation({ tables: ['notes', 'tasks'] }));
const b = canonicalInfoString(derivation({ tables: ['tasks', 'notes'] }));
expect(a).toBe(b);
const c = canonicalInfoString(derivation({ recordIds: ['notes:n1', 'tasks:t1'] }));
const d = canonicalInfoString(derivation({ recordIds: ['tasks:t1', 'notes:n1'] }));
expect(c).toBe(d);
});
it('pins the exact string format', () => {
expect(canonicalInfoString(derivation())).toBe(
'mana-ai-mission-grant:v1:tables=notes,tasks:ids=notes:n1,tasks:t1'
);
});
});
describe('deriveMissionDataKeyRaw', () => {
it('returns 32 bytes', async () => {
const mdk = await deriveMissionDataKeyRaw(fixedMasterKey, derivation());
expect(mdk.length).toBe(32);
});
it('is deterministic for the same inputs', async () => {
const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation());
const b = await deriveMissionDataKeyRaw(fixedMasterKey, derivation());
expect(Array.from(a)).toEqual(Array.from(b));
});
it('differs when the mission id changes', async () => {
const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ missionId: 'm1' }));
const b = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ missionId: 'm2' }));
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it('differs when the scope changes', async () => {
const a = await deriveMissionDataKeyRaw(fixedMasterKey, derivation({ tables: ['notes'] }));
const b = await deriveMissionDataKeyRaw(
fixedMasterKey,
derivation({ tables: ['notes', 'tasks'] })
);
expect(Array.from(a)).not.toEqual(Array.from(b));
const c = await deriveMissionDataKeyRaw(
fixedMasterKey,
derivation({ recordIds: ['notes:n1'] })
);
const d = await deriveMissionDataKeyRaw(
fixedMasterKey,
derivation({ recordIds: ['notes:n1', 'notes:n2'] })
);
expect(Array.from(c)).not.toEqual(Array.from(d));
});
it('is order-independent in scope', async () => {
const a = await deriveMissionDataKeyRaw(
fixedMasterKey,
derivation({ tables: ['notes', 'tasks'] })
);
const b = await deriveMissionDataKeyRaw(
fixedMasterKey,
derivation({ tables: ['tasks', 'notes'] })
);
expect(Array.from(a)).toEqual(Array.from(b));
});
it('rejects unsupported derivation versions', async () => {
await expect(
deriveMissionDataKeyRaw(fixedMasterKey, derivation({ version: 'v0' as never }))
).rejects.toThrow(/unsupported derivation version/);
});
it('rejects wrong-length master keys', async () => {
await expect(deriveMissionDataKeyRaw(new Uint8Array(16), derivation())).rejects.toThrow(
/expected 32-byte master key/
);
});
it('is stable across runs (golden)', async () => {
// If this test ever needs updating, the derivation policy has
// changed — bump GRANT_DERIVATION_VERSION and keep the old
// branch available for in-flight grants.
const mdk = await deriveMissionDataKeyRaw(fixedMasterKey, derivation());
const hex = Array.from(mdk)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// Golden — recorded from the Web Crypto reference implementation
// on first green run. If this string changes, the key-derivation
// policy has changed in a non-backwards-compatible way.
expect(hex).toMatchInlineSnapshot(
`"7538df66c51d3ddb667c0135feb4ac7c2800ba372babc7e61e9423d6a9a65628"`
);
});
});

View file

@ -0,0 +1,160 @@
/**
* Mission Key-Grant canonical derivation + wire format.
*
* A Grant lets the server-side `mana-ai` runner execute Missions that
* depend on encrypted inputs (notes, tasks, events, journal, kontext).
* The webapp derives a Mission Data Key (MDK) from the user master key,
* wraps it with the `mana-ai` RSA public key, and attaches the blob to
* the Mission. At tick time `mana-ai` unwraps, decrypts only the
* allowlisted records, and forgets the key after the tick.
*
* This file is the SOURCE OF TRUTH for how the MDK is derived. The
* webapp (wrap path) and mana-auth (server-side wrap for the grant
* endpoint) both import `deriveMissionDataKey` from here; a drift-guard
* test keeps them honest. Bumping DERIVATION_VERSION is the supported
* path for changing the derivation policy without rotating the user
* master key.
*
* Why tables + recordIds in the HKDF info?
* Binding the scope into the key means scope escalation is a *crypto*
* failure, not a policy check the server could forget. Adding a new
* record to a Mission produces a different MDK existing grant
* stops working UI prompts for re-consent. This is stricter than
* a runtime allowlist check; we keep the runtime check too as belt
* + braces.
*/
/** Bump this when the derivation policy changes (e.g. new info fields,
* new hash). Existing grants with an older version remain decryptable
* as long as the code path for that version is kept; once dropped,
* users re-consent on next edit. */
export const GRANT_DERIVATION_VERSION = 'v1' as const;
export type GrantDerivationVersion = typeof GRANT_DERIVATION_VERSION;
/** The deterministic inputs to the HKDF. These + the user master key
* fully determine the MDK; any change produces a different key. */
export interface GrantDerivation {
readonly version: GrantDerivationVersion;
readonly missionId: string;
/** Encrypted table names this grant covers, e.g. `['notes','tasks']`. */
readonly tables: readonly string[];
/** Allowlisted record IDs across the referenced tables. Format:
* `"<table>:<id>"` so IDs are qualified and can't collide across
* tables (e.g. `"notes:abc"` vs `"tasks:abc"` are distinct). */
readonly recordIds: readonly string[];
}
/** What gets stored on `Mission.grant`. `wrappedKey` is the RSA-OAEP
* output (base64) of the 32-byte MDK. Nothing sensitive here but
* also nothing that the Mission owner shouldn't see. */
export interface MissionGrant {
readonly wrappedKey: string;
readonly derivation: GrantDerivation;
/** ISO timestamp of when the grant was minted. */
readonly issuedAt: string;
/** ISO timestamp after which the grant is no longer honoured. The
* server rejects missions with `expiresAt < now()` and surfaces a
* `grant-missing` state so the webapp can prompt for re-consent. */
readonly expiresAt: string;
}
/**
* Canonical HKDF-SHA256 derivation of the Mission Data Key.
*
* Both the webapp (Web Crypto in the browser) and mana-auth (Web Crypto
* in Bun) must produce byte-identical output for the same inputs, or
* the server cannot decrypt what the grant protects. The drift-guard
* test in `grant.test.ts` asserts this with a fixed master key.
*
* Returns a 32-byte AES-GCM-256 key as a non-extractable CryptoKey.
* Callers that need to wrap the raw bytes (the webapp, before RSA-OAEP)
* should use `deriveMissionDataKeyRaw` instead; callers that will only
* use the key for decryption (mana-ai after unwrap) should use this one.
*/
export async function deriveMissionDataKey(
masterKey: Uint8Array,
derivation: GrantDerivation
): Promise<CryptoKey> {
const bytes = await deriveMissionDataKeyRaw(masterKey, derivation);
try {
return await crypto.subtle.importKey(
'raw',
toBufferSource(bytes),
{ name: 'AES-GCM', length: 256 },
/* extractable */ false,
['decrypt']
);
} finally {
bytes.fill(0);
}
}
/** Raw 32-byte form of the MDK. Caller is responsible for memzero-ing
* after use. Only the webapp's wrap path needs this; everyone else
* should prefer the CryptoKey variant. */
export async function deriveMissionDataKeyRaw(
masterKey: Uint8Array,
derivation: GrantDerivation
): Promise<Uint8Array> {
if (masterKey.length !== 32) {
throw new Error(`shared-ai/grant: expected 32-byte master key, got ${masterKey.length}`);
}
if (derivation.version !== GRANT_DERIVATION_VERSION) {
throw new Error(
`shared-ai/grant: unsupported derivation version ${derivation.version}, expected ${GRANT_DERIVATION_VERSION}`
);
}
const ikm = await crypto.subtle.importKey(
'raw',
toBufferSource(masterKey),
'HKDF',
/* extractable */ false,
['deriveBits']
);
// Salt: missionId UTF-8 bytes. Deliberately NOT the master key —
// salt + IKM collapse in HKDF-Extract, but using the missionId
// gives every mission its own PRK space at extract time and keeps
// the info field free for the scope binding.
const salt = new TextEncoder().encode(derivation.missionId);
const info = new TextEncoder().encode(canonicalInfoString(derivation));
const bits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: toBufferSource(salt),
info: toBufferSource(info),
},
ikm,
256 // 32 bytes
);
return new Uint8Array(bits);
}
/**
* Canonical serialisation of the scope into the HKDF info string.
* Sorted + joined to make the output order-independent: `[notes,tasks]`
* and `[tasks,notes]` derive the same key. Exported so tests can pin
* the exact string format.
*/
export function canonicalInfoString(derivation: GrantDerivation): string {
const tables = [...derivation.tables].sort().join(',');
const ids = [...derivation.recordIds].sort().join(',');
return `mana-ai-mission-grant:${derivation.version}:tables=${tables}:ids=${ids}`;
}
// ─── Internals ────────────────────────────────────────────────
/** TS 5.7 compat Uint8Array<ArrayBufferLike> isn't assignable to
* BufferSource in every context. Copying into a fresh ArrayBuffer
* sidesteps the issue and matches what mana-auth/kek.ts already does. */
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}

View file

@ -6,3 +6,11 @@ export type {
MissionState,
PlanStep,
} from './types';
export type { GrantDerivation, GrantDerivationVersion, MissionGrant } from './grant';
export {
GRANT_DERIVATION_VERSION,
canonicalInfoString,
deriveMissionDataKey,
deriveMissionDataKeyRaw,
} from './grant';

View file

@ -75,4 +75,12 @@ export interface Mission {
iterations: readonly MissionIteration[];
userId?: string;
deletedAt?: string;
/**
* Key-Grant for server-side execution on encrypted inputs. When set,
* `mana-ai` can decrypt the referenced records without the user's
* browser tab being open. Absent or expired server-side Runner
* skips the mission (state='grant-missing'), foreground Runner is
* unaffected. See `./grant.ts` and `docs/plans/ai-mission-key-grant.md`.
*/
grant?: import('./grant').MissionGrant;
}