#!/usr/bin/env node /** * receipt-sign — подпись/проверка расписок и хеш-первооснова Журнала. * canonicalJson: стабильная сериализация (сорт ключей, без пробелов) — иначе * HMAC/хеш зависят от порядка ключей. signPayload/verifyReceipt — HMAC-SHA256. */ import { createHmac, timingSafeEqual } from 'node:crypto'; /** * R-31 — домены подписи: каждый тип записи подписывается со своей меткой, чтобы * подпись одного типа (напр. голова журнала) НЕ принималась за подпись другого * (напр. замороженный план). Метка примешивается в HMAC до содержания. */ export const RECEIPT_DOMAINS = Object.freeze({ JOURNAL_HEAD: 'journal-head', FROZEN_PLAN: 'frozen-plan', FROZEN_ARTIFACT: 'frozen-artifact', APPROVAL: 'approval', STEP_PTR: 'step-ptr', M5_GREEN: 'm5-green', VERIFY_PASS: 'verify-pass', FLOOR_ESCAPE: 'floor-escape', JUDGE_GO: 'judge-go', MENTOR_GO: 'mentor-go', }); /** Стабильная сериализация: ключи объектов сортируются рекурсивно, без пробелов. */ export function canonicalJson(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'; const keys = Object.keys(value).sort(); return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'; } /** HMAC-SHA256(hex) над `domain\0 + canonicalJson(payload)`. Без ключа → null. * domain='' по умолчанию — обратная совместимость. */ export function signPayload(payload, key, domain = '') { if (!key) return null; return createHmac('sha256', String(key)).update(String(domain) + '' + canonicalJson(payload)).digest('hex'); } /** * Проверка расписки: record несёт поле `sig`; пересчитываем подпись над * record-без-sig (в том же домене) и сверяем постоянным по времени сравнением. * Неподписанная (нет sig) или без ключа → false (fail-closed). */ export function verifyReceipt(record, key, domain = '') { if (!key || !record || typeof record !== 'object') return false; const { sig, ...payload } = record; if (typeof sig !== 'string' || !/^[0-9a-f]{64}$/.test(sig)) return false; const expected = signPayload(payload, key, domain); if (!expected) return false; const a = Buffer.from(sig, 'hex'); const b = Buffer.from(expected, 'hex'); if (a.length !== b.length) return false; return timingSafeEqual(a, b); }