import { createHash } from "node:crypto"; import type { ConsentPreview } from "@courtmitra/contracts"; /** * Canonical consent hashing (arch §30.4, CLAUDE.md #5). * * The hash binds a recorded consent grant to an EXACT outgoing payload. On * execution the ActionExecutor recomputes this from the real payload; a * mismatch blocks the send and forces re-approval. This prevents a mild * approved message from being silently replaced by a harsher one. * * Rules (copied verbatim from the spec): * 1. Normalize body line endings to "\n". * 2. Sort all object keys recursively. * 3. Sort attachments by id. * 4. EXCLUDE volatile fields (createdAt, UI labels, temp URLs). * 5. Include body, destination, attachment sha256s, public flag, piiDisclosed, risks. */ const HASH_PREFIX = "sha256:"; /** Fields that are part of the signed payload. Anything else is volatile. */ type CanonicalConsent = { actionType: string; destination: string; public: boolean; body: string; attachments: { id: string; sha256: string; redacted: boolean }[]; piiDisclosed: string[]; risks: string[]; }; function normalizeBody(body: string): string { return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } /** Recursively sort object keys so JSON.stringify is order-independent. */ function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value && typeof value === "object") { const sorted: Record = {}; for (const key of Object.keys(value as Record).sort()) { sorted[key] = sortKeysDeep((value as Record)[key]); } return sorted; } return value; } /** Build the canonical, volatile-free object that gets hashed. */ export function canonicalizeConsentPreview(preview: ConsentPreview): CanonicalConsent { return { actionType: preview.actionType, destination: preview.destination, public: preview.public, body: normalizeBody(preview.body), attachments: [...preview.attachments] .map((a) => ({ id: a.id, sha256: a.sha256, redacted: a.redacted })) .sort((a, b) => a.id.localeCompare(b.id)), piiDisclosed: [...preview.piiDisclosed].sort(), risks: [...preview.risks].sort(), }; } /** Canonical JSON string — stable across key ordering and line endings. */ export function canonicalConsentJson(preview: ConsentPreview): string { return JSON.stringify(sortKeysDeep(canonicalizeConsentPreview(preview))); } /** Compute the `action_preview_hash` for a consent preview. */ export function computeConsentHash(preview: ConsentPreview): string { const json = canonicalConsentJson(preview); return HASH_PREFIX + createHash("sha256").update(json, "utf8").digest("hex"); } /** * Gate used by the ActionExecutor before any side effect: the hash of the EXACT * outgoing payload must equal the hash recorded on the consent grant. */ export function consentHashMatches(approvedHash: string, outgoing: ConsentPreview): boolean { return approvedHash === computeConsentHash(outgoing); }