Files
portal/tools/receipt-sign.mjs
T
Дмитрий 3795fcfe0a feat(m5): floor-signer + RECEIPT_DOMAINS.M5_GREEN + occurrence (Пакет 3, Блок 2)
- receipt-sign.mjs: +домен M5_GREEN (отдельный, не default; cross-domain тест)
- floor-signer.mjs (новый): signGreen/verifyGreen/acceptGreen — подпись по-критерийного
  GREEN в домене M5_GREEN, occurrence монотонный (анти-пере-зачёт одного green на 2
  критерия), подмена criterion_id/occurrence ломает HMAC → bad-signature.
- Δ4 честно: подписант = анти-казуальная утечка ключа + шов под HSM, НЕ защита ключа
  от мотивированного контроллера (node tools/X.mjs до Пакета 4); см. spec §7/§10.
- Чистые функции (ключ-аргумент), как receipt-sign.mjs; процесс-обёртка/резолв ключа —
  Пакет 4+; потребитель держит lastOccurrence — Пакет 5.
- TDD RED-GREEN; tools-only регрессия 2666 passed + 2 skip (+17).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:59:56 +03:00

54 lines
2.7 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',
});
/** Стабильная сериализация: ключи объектов сортируются рекурсивно, без пробелов. */
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);
}