Files
brain/tools/receipt-sign.mjs
T

58 lines
2.8 KiB
JavaScript

#!/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);
}