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:
@@ -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' };
|
||||
}
|
||||
@@ -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.
@@ -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}');
|
||||
|
||||
Reference in New Issue
Block a user