bookworm-smart-assistant/scripts/patches/patch-p1-2-jsonl-hmac-lib.js

297 lines
9.2 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* patch-p1-2-jsonl-hmac-lib.js
*
* P1.2 step1: 创建 hooks/lib/jsonl-hmac.js
*
* 提供 jsonl 完整性链 APIHMAC + seq 单调
* 不修改 safe-append.js tamper protection作为独立可选模块
*
* 链结构: 每行 line HMAC = SHA256(.hmac-key, prevHmac + lineRawWithoutHmacField)
* baseline 存档: <jsonl>.hmac-baseline.json { sig, lastHmac, lineCount, builtAt }
*
* 使用模式:
* 1) baseline 模式: computeChain(jsonlPath) 返回链 + 末态
* 2) verify 模式: verifyChain(jsonlPath, baselinePath) 返回 ok/drift
* 3) seq 校验: assertSeqMonotonic(jsonlPath, seqField='seq')
*/
'use strict';
const fs = require('fs');
const path = require('path');
const TARGET = path.join(__dirname, '..', '..', 'hooks', 'lib', 'jsonl-hmac.js');
const SENTINEL = 'P1-JSONL-HMAC-V1';
const CONTENT = `'use strict';
/**
* jsonl-hmac.js JSONL 完整性链 (${SENTINEL})
*
* evolution-log.jsonl / route-feedback.jsonl 离线投毒
* 红队识别风险: 攻击者可写 ~/.claude/ HMAC 链导致投毒后下个进程消费时被引导
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = path.join(__dirname, '..', '..');
const HMAC_KEY_FILE = path.join(ROOT, '.hmac-key');
let _cachedKey = null;
function getKey() {
if (_cachedKey) return _cachedKey;
try {
_cachedKey = fs.readFileSync(HMAC_KEY_FILE, 'utf8').trim();
if (_cachedKey.length < 32) {
throw new Error('.hmac-key too short (' + _cachedKey.length + ' chars)');
}
} catch (e) {
return null;
}
return _cachedKey;
}
/**
* 计算单行 HMAC含前一行 HMAC 形成链
*/
function hmacLine(prevHmac, line) {
const key = getKey();
if (!key) return null;
return crypto.createHmac('sha256', key).update(prevHmac + '\\n' + line).digest('hex');
}
/**
* 扫描 jsonl 文件计算 HMAC
* @returns { ok, lastHmac, lineCount, sig, error?, badLines? }
*/
function computeChain(jsonlPath) {
if (!fs.existsSync(jsonlPath)) {
return { ok: false, error: 'file not found', lineCount: 0 };
}
const key = getKey();
if (!key) return { ok: false, error: 'hmac-key unavailable' };
let prev = '';
let lineCount = 0;
const badLines = [];
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
try {
JSON.parse(line);
prev = hmacLine(prev, line);
lineCount++;
} catch (_) {
badLines.push(i + 1);
}
}
// 整体签名 = 最后一行 HMAC (即末态)
return {
ok: badLines.length === 0,
lastHmac: prev,
lineCount: lineCount,
sig: prev,
badLines: badLines.length ? badLines : undefined,
};
}
/**
* 把当前 jsonl 文件的链状态写入 baseline 文件
*/
function writeBaseline(jsonlPath, baselinePath) {
const result = computeChain(jsonlPath);
if (!result.ok && result.error !== 'file not found') {
return { ok: false, error: result.error || 'compute failed' };
}
const baseline = {
schema: 'bookworm-jsonl-hmac-baseline-v1',
target: path.relative(ROOT, jsonlPath),
sig: result.sig,
lineCount: result.lineCount,
builtAt: new Date().toISOString(),
hmacKeyFingerprint: hmacKeyFingerprint(),
};
const dir = path.dirname(baselinePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = baselinePath + '.tmp.' + process.pid;
fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2));
fs.renameSync(tmp, baselinePath);
return { ok: true, baseline: baseline };
}
/**
* 校验当前 jsonl baseline
* @returns { ok, drift, expected, actual, baselineLineCount, currentLineCount }
*/
function verifyChain(jsonlPath, baselinePath) {
if (!fs.existsSync(baselinePath)) {
return { ok: false, drift: 'no-baseline', expected: null, actual: null };
}
let baseline;
try {
baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
} catch (e) {
return { ok: false, drift: 'baseline-malformed', error: e.message };
}
const current = computeChain(jsonlPath);
if (!current.ok && current.error) {
return { ok: false, drift: 'compute-error', error: current.error };
}
// 仅当 currentLineCount >= baselineLineCount 才视为追加增长(合法)
// 此时 baseline.sig 应该出现在 current 链的某一中间状态
// 简化:如果 current.lineCount >= baseline.lineCount 且 baseline 末态可在 current 复现 → ok
if (current.lineCount < baseline.lineCount) {
return {
ok: false,
drift: 'truncated',
baselineLineCount: baseline.lineCount,
currentLineCount: current.lineCount,
};
}
// 重算到 baseline.lineCount 的末态
const partial = computeChainPartial(jsonlPath, baseline.lineCount);
if (partial.lastHmac !== baseline.sig) {
return {
ok: false,
drift: 'tampered',
expected: baseline.sig,
actual: partial.lastHmac,
atLine: baseline.lineCount,
};
}
return {
ok: true,
drift: null,
baselineLineCount: baseline.lineCount,
currentLineCount: current.lineCount,
appended: current.lineCount - baseline.lineCount,
currentSig: current.sig,
};
}
function computeChainPartial(jsonlPath, maxLines) {
let prev = '';
let n = 0;
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\\n');
for (let i = 0; i < lines.length && n < maxLines; i++) {
const line = lines[i];
if (!line) continue;
prev = hmacLine(prev, line);
n++;
}
return { lastHmac: prev, lineCount: n };
}
/**
* seq 单调性检查如果 jsonl seq 字段
*/
function assertSeqMonotonic(jsonlPath, seqField) {
if (!seqField) seqField = 'seq';
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\\n');
let lastSeq = -Infinity;
const violations = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
try {
const obj = JSON.parse(line);
if (typeof obj[seqField] === 'number') {
if (obj[seqField] < lastSeq) {
violations.push({ lineNo: i + 1, seq: obj[seqField], prevSeq: lastSeq });
}
lastSeq = obj[seqField];
}
} catch (_) {}
}
return { ok: violations.length === 0, violations: violations };
}
function hmacKeyFingerprint() {
const key = getKey();
if (!key) return null;
return crypto.createHash('sha256').update(key).digest('hex').slice(0, 12);
}
module.exports = {
computeChain,
writeBaseline,
verifyChain,
assertSeqMonotonic,
hmacKeyFingerprint,
__sentinel: '${SENTINEL}',
};
`;
function main() {
const dir = path.dirname(TARGET);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (fs.existsSync(TARGET)) {
const cur = fs.readFileSync(TARGET, 'utf8');
if (cur.includes(SENTINEL)) {
process.stdout.write('[SKIP] already deployed\n');
process.exit(0);
}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.copyFileSync(TARGET, TARGET + '.bak.' + ts);
process.stdout.write('[BACKUP] ' + TARGET + '.bak.' + ts + '\n');
}
const tmpPath = TARGET + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, CONTENT);
try {
delete require.cache[require.resolve(tmpPath)];
const mod = require(tmpPath);
// 自检 1: hmac-key 可加载
const fp = mod.hmacKeyFingerprint();
if (!fp) throw new Error('hmac-key unavailable');
// 自检 2: computeChain 对一个临时 jsonl 工作
const ROOT = path.join(__dirname, '..', '..');
const testFile = path.join(ROOT, 'debug', '.hmac-self-test.jsonl');
fs.mkdirSync(path.dirname(testFile), { recursive: true });
fs.writeFileSync(testFile, '{"a":1}\n{"a":2}\n{"a":3}\n');
const chain = mod.computeChain(testFile);
if (!chain.ok) throw new Error('computeChain failed: ' + JSON.stringify(chain));
if (chain.lineCount !== 3) throw new Error('expected 3 lines got ' + chain.lineCount);
// 自检 3: writeBaseline + verifyChain (无篡改) → ok
const baselinePath = testFile + '.hmac-baseline.json';
const wr = mod.writeBaseline(testFile, baselinePath);
if (!wr.ok) throw new Error('writeBaseline: ' + wr.error);
const v1 = mod.verifyChain(testFile, baselinePath);
if (!v1.ok) throw new Error('clean verify failed: ' + JSON.stringify(v1));
// 自检 4: 篡改后 verify → drift=tampered
fs.writeFileSync(testFile, '{"a":1}\n{"a":99}\n{"a":3}\n');
const v2 = mod.verifyChain(testFile, baselinePath);
if (v2.ok || v2.drift !== 'tampered') throw new Error('tamper not detected: ' + JSON.stringify(v2));
// 自检 5: 截断后 verify → drift=truncated
fs.writeFileSync(testFile, '{"a":1}\n');
const v3 = mod.verifyChain(testFile, baselinePath);
if (v3.ok || v3.drift !== 'truncated') throw new Error('truncate not detected');
// 清理
try { fs.unlinkSync(testFile); fs.unlinkSync(baselinePath); } catch (_) {}
fs.renameSync(tmpPath, TARGET);
process.stdout.write('[OK] hooks/lib/jsonl-hmac.js deployed (5 self-tests pass)\n');
process.stdout.write(' hmac-key fingerprint: ' + fp + '\n');
} catch (e) {
fs.unlinkSync(tmpPath);
process.stderr.write('[ERROR] self-test failed: ' + e.message + '\n');
process.exit(1);
}
}
if (require.main === module) main();