397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
66 lines
4.0 KiB
JavaScript
66 lines
4.0 KiB
JavaScript
#!/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' };
|
||
}
|