From 8a754e9a19bba94bb7c911edc6f089ccf2d2c219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 7 Jun 2026 11:59:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(m5):=20floor-signer=20+=20RECEIPT=5FDOMAIN?= =?UTF-8?q?S.M5=5FGREEN=20+=20occurrence=20(=D0=9F=D0=B0=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=203,=20=D0=91=D0=BB=D0=BE=D0=BA=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- tools/floor-signer.mjs | 65 ++++++++++++++++++++++++++ tools/floor-signer.test.mjs | 90 ++++++++++++++++++++++++++++++++++++ tools/receipt-sign.mjs | Bin 2770 -> 2794 bytes tools/receipt-sign.test.mjs | 22 +++++++++ 4 files changed, 177 insertions(+) create mode 100644 tools/floor-signer.mjs create mode 100644 tools/floor-signer.test.mjs diff --git a/tools/floor-signer.mjs b/tools/floor-signer.mjs new file mode 100644 index 00000000..687d9a16 --- /dev/null +++ b/tools/floor-signer.mjs @@ -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' }; +} diff --git a/tools/floor-signer.test.mjs b/tools/floor-signer.test.mjs new file mode 100644 index 00000000..b708220b --- /dev/null +++ b/tools/floor-signer.test.mjs @@ -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); + }); +}); diff --git a/tools/receipt-sign.mjs b/tools/receipt-sign.mjs index 996183ea39f2e4349f33a9bb6e687a1b76972e6b..c9adb149dd567aa7a0d49256327b1c00d7ca8348 100644 GIT binary patch delta 34 pcmca4`bu { }); }); +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}');