#!/usr/bin/env node /** * floor-signer (Машина 5, Блок 2) — подписант по-критерийного GREEN. * * ЧЕСТНАЯ ЦЕННОСТЬ (Δ4, факт-аудит кода spec §10): * Подписант НЕ делает HMAC-ключ нечитаемым для мотивированного контроллера. * Факт: ключ в OS-keychain читается любым процессом того же пользователя * (receipt-key-config.mjs), а `node <файл>` в whitelist гейта (до сужения в * Пакете 4) → контроллер может написать свой `tools/X.mjs` и прочесть ключ. * Поэтому подписант даёт ДВЕ реальные вещи, не больше: * (а) убирает ключ из логов/env (анти-казуальная утечка); * (б) ШОВ ПОД HSM/YubiKey (v4.2) — туда встанет железо, и подделка станет * реально невозможной (см. spec §7, ~0.5% неустранимо без железа). * Блок 2 НЕ заявляет «защиту ключа от контроллера». * * occurrence-счётчик — анти-пере-зачёт: один зелёный прогон нельзя пере-зачесть * на два критерия. Монотонность проверяется acceptGreen относительно * lastOccurrence (чистая функция; состояние держит потребитель). Подделка * criterion_id/occurrence/fingerprint после подписи аннулирует sig (HMAC привязан * к полному payload в домене M5_GREEN). * * Чистые функции (ключ — аргумент), как receipt-sign.mjs. «Только подписант * держит ключ» — операционное свойство процесса-обёртки (Пакет 4+), не этого * модуля. */ import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; /** * Подписать по-критерийный GREEN в домене M5_GREEN. * @param {{criterion_id:string, code_fingerprint:string, occurrence:number}} payload * @param {string} key — ключ подписанта; без ключа → null (fail-closed). * @returns {{criterion_id, code_fingerprint, occurrence, sig}|null} */ export function signGreen(payload, key) { if (!key) return null; const body = { criterion_id: payload.criterion_id, code_fingerprint: payload.code_fingerprint, occurrence: payload.occurrence, }; const sig = signPayload(body, key, RECEIPT_DOMAINS.M5_GREEN); if (!sig) return null; return { ...body, sig }; } /** Проверить подпись GREEN в домене M5_GREEN. Без sig/ключа → false (fail-closed). */ export function verifyGreen(record, key) { return verifyReceipt(record, key, RECEIPT_DOMAINS.M5_GREEN); } /** * Принять GREEN: подпись валидна И occurrence строго больше последнего принятого. * lastOccurrence — состояние потребителя (по умолчанию 0). Защищает от: * - подделки (подмена criterion_id/occurrence ломает sig) → 'bad-signature'; * - нецелого/неположительного occurrence → 'bad-occurrence'; * - пере-зачёта одного green (occurrence <= lastOccurrence) → 'stale-occurrence'. * @returns {{accepted:boolean, reason:string}} */ export function acceptGreen(record, key, { lastOccurrence = 0 } = {}) { if (!verifyGreen(record, key)) return { accepted: false, reason: 'bad-signature' }; const occ = record.occurrence; if (!Number.isInteger(occ) || occ <= 0) return { accepted: false, reason: 'bad-occurrence' }; if (occ <= lastOccurrence) return { accepted: false, reason: 'stale-occurrence' }; return { accepted: true, reason: 'ok' }; }