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>
This commit is contained in:
Дмитрий
2026-06-07 11:59:56 +03:00
parent 5d96e89d5c
commit 8a754e9a19
4 changed files with 177 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
#!/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' };
}
+90
View File
@@ -0,0 +1,90 @@
// tools/floor-signer.test.mjs — Пакет 3 (Блок 2): подписант GREEN + occurrence.
import { describe, it, expect } from 'vitest';
import { signGreen, verifyGreen, acceptGreen } from './floor-signer.mjs';
const KEY = 'signer-secret-key-xyz';
describe('floor-signer: signGreen (подпись GREEN в домене M5_GREEN)', () => {
it('подписывает payload и возвращает запись с 64-hex sig', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
expect(rec.criterion_id).toBe('c1');
expect(rec.code_fingerprint).toBe('fp1');
expect(rec.occurrence).toBe(1);
expect(rec.sig).toMatch(/^[0-9a-f]{64}$/);
});
it('без ключа подписанта возвращает null (fail-closed)', () => {
expect(signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, null)).toBe(null);
expect(signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, '')).toBe(null);
});
it('подпись пригодна только в домене M5_GREEN (verifyGreen true)', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
expect(verifyGreen(rec, KEY)).toBe(true);
});
});
describe('floor-signer: verifyGreen (целостность + привязка к criterion_id)', () => {
it('подделка criterion_id после подписи аннулирует проверку', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
const forged = { ...rec, criterion_id: 'c2' };
expect(verifyGreen(forged, KEY)).toBe(false);
});
it('подделка occurrence после подписи аннулирует проверку', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
const forged = { ...rec, occurrence: 2 };
expect(verifyGreen(forged, KEY)).toBe(false);
});
it('чужой ключ не проходит проверку', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
expect(verifyGreen(rec, 'other-key')).toBe(false);
});
it('неподписанная запись (нет sig) не проходит (fail-closed)', () => {
expect(verifyGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY)).toBe(false);
});
});
describe('floor-signer: acceptGreen (occurrence монотонный, анти-пере-зачёт)', () => {
it('свежий GREEN с occurrence > lastOccurrence принимается', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 5 }, KEY);
const r = acceptGreen(rec, KEY, { lastOccurrence: 4 });
expect(r.accepted).toBe(true);
});
it('тот же GREEN с разным criterion_id без нового occurrence отклоняется (Пакет 3 критерий)', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 5 }, KEY);
const swapped = { ...rec, criterion_id: 'c2' };
const r = acceptGreen(swapped, KEY, { lastOccurrence: 4 });
expect(r.accepted).toBe(false);
expect(r.reason).toBe('bad-signature');
});
it('пере-зачёт того же occurrence отклоняется (occurrence <= lastOccurrence)', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 5 }, KEY);
const r = acceptGreen(rec, KEY, { lastOccurrence: 5 });
expect(r.accepted).toBe(false);
expect(r.reason).toBe('stale-occurrence');
});
it('occurrence ниже последнего отклоняется', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 3 }, KEY);
const r = acceptGreen(rec, KEY, { lastOccurrence: 5 });
expect(r.accepted).toBe(false);
expect(r.reason).toBe('stale-occurrence');
});
it('нецелый / неположительный occurrence отклоняется', () => {
const bad = [0, -1, 1.5, 'x', null];
for (const occ of bad) {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: occ }, KEY);
const r = acceptGreen(rec, KEY, { lastOccurrence: 0 });
expect(r.accepted).toBe(false);
expect(r.reason).toBe('bad-occurrence');
}
});
it('подделка GREEN без ключа подписанта не принимается', () => {
const forged = { criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 5, sig: 'a'.repeat(64) };
const r = acceptGreen(forged, KEY, { lastOccurrence: 0 });
expect(r.accepted).toBe(false);
expect(r.reason).toBe('bad-signature');
});
it('lastOccurrence по умолчанию 0 (первый GREEN occurrence=1 принимается)', () => {
const rec = signGreen({ criterion_id: 'c1', code_fingerprint: 'fp1', occurrence: 1 }, KEY);
const r = acceptGreen(rec, KEY);
expect(r.accepted).toBe(true);
});
});
Binary file not shown.
+22
View File
@@ -20,6 +20,28 @@ describe('доменное разделение подписи (R-31)', () => {
});
});
describe('receipt-sign: домен M5_GREEN (Пакет 3.1)', () => {
const KEY = 'test-secret-key-123';
it('RECEIPT_DOMAINS.M5_GREEN — отдельный строковый домен m5-green', () => {
expect(typeof RECEIPT_DOMAINS.M5_GREEN).toBe('string');
expect(RECEIPT_DOMAINS.M5_GREEN).toBe('m5-green');
});
it('M5_GREEN отличается от всех прочих доменов', () => {
expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.JOURNAL_HEAD);
expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.FROZEN_PLAN);
expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.FROZEN_ARTIFACT);
expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.APPROVAL);
expect(RECEIPT_DOMAINS.M5_GREEN).not.toBe(RECEIPT_DOMAINS.STEP_PTR);
});
it('подпись M5_GREEN НЕ принимается за другой домен (cross-domain)', () => {
const rec = { criterion_id: 'c1', occurrence: 1 };
const sig = signPayload(rec, KEY, RECEIPT_DOMAINS.M5_GREEN);
expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.M5_GREEN)).toBe(true);
expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.JOURNAL_HEAD)).toBe(false);
expect(verifyReceipt({ ...rec, sig }, KEY, RECEIPT_DOMAINS.FROZEN_PLAN)).toBe(false);
});
});
describe('canonicalJson', () => {
it('sorts object keys deterministically', () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');