397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
6.3 KiB
JavaScript
121 lines
6.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* action-journal — Журнал S1 (несущий №1): append-only хеш-цепочка действий.
|
|
* chain_hash = sha256(prev_hash + canonicalJson(payload)). Правка записи задним
|
|
* числом ломает последующие хеши; голова цепи HMAC-подписана (оптовую перезапись
|
|
* нельзя без ключа). Идея цепи — из audit_chain_hash() (db/schema.sql:75).
|
|
* Лог-до-действия и «нет записи → нет действия» — ENFORCEMENT Машины 2 (верховный
|
|
* хук читает журнал); здесь — примитив записи + tamper-evidence + подпись головы.
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import fsDefault from 'node:fs';
|
|
import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
|
|
|
|
export const GENESIS_HASH = '0'.repeat(64);
|
|
|
|
/** chain_hash = sha256(prev_hash + canonicalJson(payload)). */
|
|
export function computeEntryHash(prevHash, payload) {
|
|
return createHash('sha256').update(String(prevHash) + canonicalJson(payload)).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Добавить запись к цепочке (чисто; персист — отдельно в journalAppend).
|
|
* @returns {{entries: object[], headSig: string|null}}
|
|
*/
|
|
export function appendEntry(entries, payload, { key, nowMs }) {
|
|
const prev = entries.length ? entries[entries.length - 1] : null;
|
|
const prevHash = prev ? prev.chain_hash : GENESIS_HASH;
|
|
const seq = (prev ? prev.seq : 0) + 1;
|
|
const ts = typeof nowMs === 'number' ? nowMs : Date.now();
|
|
const entry = {
|
|
seq,
|
|
ts,
|
|
payload,
|
|
prev_hash: prevHash,
|
|
// B2 (2026-06-05): seq+ts входят в хеш — подмена метаданных задним числом ломает цепь.
|
|
chain_hash: computeEntryHash(prevHash, { seq, ts, payload }),
|
|
};
|
|
const next = [...entries, entry];
|
|
const headSig = signHead(entry.seq, entry.chain_hash, key);
|
|
return { entries: next, headSig };
|
|
}
|
|
|
|
/** Подпись головы: HMAC над {seq, head_hash} (verifyReceipt-совместимая форма). */
|
|
export function signHead(seq, headHash, key) {
|
|
return signPayload({ seq, head_hash: headHash }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
|
|
}
|
|
|
|
/**
|
|
* Проверка цепочки: (1) каждый chain_hash пересчитывается из prev_hash+payload;
|
|
* (2) prev_hash каждой записи = chain_hash предыдущей; (3) подпись головы валидна.
|
|
* @returns {{ok: boolean, brokenAt: number|null}} brokenAt = seq первой битой записи (или null).
|
|
*/
|
|
export function verifyChain(entries, headSig, { key }) {
|
|
if (!Array.isArray(entries) || entries.length === 0) return { ok: false, brokenAt: null };
|
|
let prevHash = GENESIS_HASH;
|
|
for (const e of entries) {
|
|
// N2: структурно-битая запись (null / не-объект / массив) — fail-closed value,
|
|
// не исключение (внешняя порча .jsonl строкой `null` доходит сюда из loadJournal).
|
|
if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, brokenAt: null };
|
|
if (e.prev_hash !== prevHash) return { ok: false, brokenAt: e.seq ?? null };
|
|
if (computeEntryHash(prevHash, { seq: e.seq, ts: e.ts, payload: e.payload }) !== e.chain_hash) return { ok: false, brokenAt: e.seq ?? null };
|
|
prevHash = e.chain_hash;
|
|
}
|
|
const head = entries[entries.length - 1];
|
|
const headOk = verifyReceipt({ seq: head.seq, head_hash: head.chain_hash, sig: headSig }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
|
|
if (!headOk) return { ok: false, brokenAt: head.seq };
|
|
return { ok: true, brokenAt: null };
|
|
}
|
|
|
|
// N3 (2026-06-07 расширено до общего guard, аудит M1-M4): sessionId вклеивается в путь
|
|
// файла без проверки формы `..`/`/`/`\` → path traversal (запись/чтение вне каталога).
|
|
// Единый экспортируемый guard для ВСЕХ sibling-строителей пути машин (action-journal/
|
|
// judge-subrun-journal/plan-lock/enforce-supreme-gate) — закрывает класс разом, не точечно.
|
|
// throw (fail-closed; в supreme-gate ловится внешним try/catch → block). Источник sessionId —
|
|
// stdin-событие harness (R-28, UUID-подобный), guard — защита-в-глубину против дрейфа.
|
|
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
export function assertSafeSessionId(sessionId) {
|
|
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
|
|
throw new Error('invalid sessionId (path-injection guard)');
|
|
}
|
|
return sessionId;
|
|
}
|
|
function paths(runtimeDir, sessionId) {
|
|
assertSafeSessionId(sessionId);
|
|
const sep = runtimeDir.endsWith('/') ? '' : '/';
|
|
return {
|
|
jsonl: `${runtimeDir}${sep}action-journal-${sessionId}.jsonl`,
|
|
head: `${runtimeDir}${sep}action-journal-${sessionId}.head`,
|
|
};
|
|
}
|
|
|
|
/** Прочитать цепочку из JSONL + подпись головы. Нет файла → пусто. */
|
|
export function loadJournal({ sessionId, runtimeDir, fsImpl = fsDefault }) {
|
|
const { jsonl, head } = paths(runtimeDir, sessionId);
|
|
let entries = [];
|
|
try {
|
|
const raw = fsImpl.readFileSync(jsonl, 'utf8');
|
|
entries = String(raw).split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
} catch (e) {
|
|
if (e && e.code === 'ENOENT') return { entries: [], headSig: null };
|
|
throw e;
|
|
}
|
|
let headSig = null;
|
|
try { headSig = String(fsImpl.readFileSync(head, 'utf8')).trim() || null; } catch { headSig = null; }
|
|
return { entries, headSig };
|
|
}
|
|
|
|
/**
|
|
* Добавить действие в журнал сессии (персист через Node fs — НЕ Write-tool).
|
|
* @returns {{entry: object, headSig: string|null}}
|
|
*/
|
|
export function journalAppend({ payload, key, sessionId, runtimeDir, nowMs, fsImpl = fsDefault }) {
|
|
const { entries } = loadJournal({ sessionId, runtimeDir, fsImpl });
|
|
const { entries: next, headSig } = appendEntry(entries, payload, { key, nowMs });
|
|
const entry = next[next.length - 1];
|
|
const { jsonl, head } = paths(runtimeDir, sessionId);
|
|
fsImpl.appendFileSync(jsonl, JSON.stringify(entry) + '\n');
|
|
fsImpl.writeFileSync(head, String(headSig ?? ''));
|
|
return { entry, headSig };
|
|
}
|