297 lines
9.2 KiB
JavaScript
297 lines
9.2 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* patch-p1-2-jsonl-hmac-lib.js
|
|||
|
|
*
|
|||
|
|
* P1.2 step1: 创建 hooks/lib/jsonl-hmac.js
|
|||
|
|
*
|
|||
|
|
* 提供 jsonl 完整性链 API(HMAC 链 + 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();
|