diff --git a/tools/verify-receipt.mjs b/tools/verify-receipt.mjs new file mode 100644 index 00000000..dc6c1137 --- /dev/null +++ b/tools/verify-receipt.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/** + * verify-receipt (G1, Level A) — чистое ядро подписанной verify-расписки (домен VERIFY_PASS). + * Зеркало floor-signer.mjs. Подлинность — подпись подписанта; свежесть — совпадение + * code_fingerprint с ТЕКУЩИМ staged-diff (считает consumer/producer, инъекция currentFingerprint). + * occurrence — структурный positive-int (анти-двойной-зачёт = Level B). Δ4: ключ не защищён от + * мотивированного контроллера (см. план G1 §Forge); подпись даёт out-of-env + шов под HSM. + */ +import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; + +export function signVerifyReceipt({ code_fingerprint, occurrence }, key) { + if (!key) return null; + const body = { code_fingerprint, occurrence }; + const sig = signPayload(body, key, RECEIPT_DOMAINS.VERIFY_PASS); + if (!sig) return null; + return { ...body, sig }; +} + +export function verifyReceiptSig(record, key) { + return verifyReceipt(record, key, RECEIPT_DOMAINS.VERIFY_PASS); +} + +/** + * Принять расписку: подпись валидна И occurrence — целое >0 И (если задан currentFingerprint) + * code_fingerprint совпадает с ним. Любое нарушение → {accepted:false, reason}. + */ +export function acceptVerifyReceipt(record, key, { currentFingerprint = null } = {}) { + if (!verifyReceiptSig(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 (currentFingerprint !== null && record.code_fingerprint !== currentFingerprint) { + return { accepted: false, reason: 'stale-fingerprint' }; + } + return { accepted: true, reason: 'ok' }; +} diff --git a/tools/verify-receipt.test.mjs b/tools/verify-receipt.test.mjs new file mode 100644 index 00000000..75d96899 --- /dev/null +++ b/tools/verify-receipt.test.mjs @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { signVerifyReceipt, verifyReceiptSig, acceptVerifyReceipt } from './verify-receipt.mjs'; + +const KEY = 'signer-key'; +const FP = 'a'.repeat(64); + +describe('signVerifyReceipt', () => { + it('подписывает {code_fingerprint, occurrence}; без ключа → null', () => { + expect(signVerifyReceipt({ code_fingerprint: FP, occurrence: 1 }, '')).toBe(null); + const r = signVerifyReceipt({ code_fingerprint: FP, occurrence: 1 }, KEY); + expect(r.code_fingerprint).toBe(FP); + expect(r.occurrence).toBe(1); + expect(typeof r.sig).toBe('string'); + expect(verifyReceiptSig(r, KEY)).toBe(true); + }); +}); + +describe('acceptVerifyReceipt', () => { + const good = () => signVerifyReceipt({ code_fingerprint: FP, occurrence: 1 }, KEY); + it('валидная подпись + occurrence>0 + fingerprint совпал → accepted', () => { + expect(acceptVerifyReceipt(good(), KEY, { currentFingerprint: FP })).toEqual({ accepted: true, reason: 'ok' }); + }); + it('подмена fingerprint после подписи → bad-signature', () => { + const r = { ...good(), code_fingerprint: 'b'.repeat(64) }; + expect(acceptVerifyReceipt(r, KEY, { currentFingerprint: 'b'.repeat(64) }).accepted).toBe(false); + }); + it('staged-diff изменился (current ≠ receipt) → stale-fingerprint', () => { + expect(acceptVerifyReceipt(good(), KEY, { currentFingerprint: 'c'.repeat(64) })) + .toEqual({ accepted: false, reason: 'stale-fingerprint' }); + }); + it('occurrence не целое/≤0 → bad-occurrence', () => { + const r = signVerifyReceipt({ code_fingerprint: FP, occurrence: 0 }, KEY); + expect(acceptVerifyReceipt(r, KEY, { currentFingerprint: FP }).reason).toBe('bad-occurrence'); + }); + it('без ключа → bad-signature (fail-closed)', () => { + expect(acceptVerifyReceipt(good(), '', { currentFingerprint: FP }).accepted).toBe(false); + }); + it('currentFingerprint не задан (null) → проверка свежести пропущена', () => { + expect(acceptVerifyReceipt(good(), KEY).accepted).toBe(true); + }); +});