mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
9809b06adf
commit
6882ffb626
8 changed files with 610 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
115
packages/shared-ai/src/missions/grant.test.ts
Normal file
115
packages/shared-ai/src/missions/grant.test.ts
Normal 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"`
|
||||
);
|
||||
});
|
||||
});
|
||||
160
packages/shared-ai/src/missions/grant.ts
Normal file
160
packages/shared-ai/src/missions/grant.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue