From 3a039c3c8df1bfa98261177feecfb7a6d4317764 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:09:56 +0000 Subject: [PATCH] fast-time fork gate: the three gate summaries carry keys as 8 hex and an ellipsis, written through the redacting writer; CI on ca3-v4-node red since 11:26 UK on the no-secrets check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/plans/counter-asic-3-gate/fork-gate-*.json: keys.a, keys.b and every sinks.*.key shortened (the first 8 hex characters then "…"); hashes untouched. Produced by the helper, so the files are what the runner now writes. - infra/fast-time/fork-gate.mjs: writeSummary() from infra/fast-time/lib/redact-keys.mjs replaces the raw JSON write; a summary with a raw 64-hex key under a key-shaped field fails in the runner, with the line named, before the tree does. - infra/fast-time/lib/redact-keys.mjs and its gate line in tools/ci/pre-push.sh: identical to master 3fae3166, so the merge is clean. Co-Authored-By: Claude Fable 5.1 --- .../fork-gate-gate-off-expect-hold.json | 12 +- .../fork-gate-gate-off-expect-reorg.json | 12 +- .../fork-gate-gate-on-expect-hold.json | 12 +- infra/fast-time/fork-gate.mjs | 6 +- infra/fast-time/lib/redact-keys.mjs | 132 ++++++++++++++++++ tools/ci/pre-push.sh | 1 + 6 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 infra/fast-time/lib/redact-keys.mjs diff --git a/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-hold.json b/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-hold.json index b700910b7..b1e7ac39d 100644 --- a/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-hold.json +++ b/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-hold.json @@ -8,8 +8,8 @@ "split": 180, "watch": 150, "keys": { - "a": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc", - "b": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "a": "4c03930c…", + "b": "0e2c6fe2…" }, "share_at_fork": { "a": 89, @@ -22,14 +22,14 @@ "blocks": 271, "daa": 270, "blue": 271, - "key": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc" + "key": "4c03930c…" }, "b": { "hash": "589ca781c9acdc2131eb1705afe591e65745cf97460419441c15977167ea94d5", "blocks": 271, "daa": 270, "blue": 271, - "key": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc" + "key": "4c03930c…" } }, "split_sinks": { @@ -38,14 +38,14 @@ "blocks": 271, "daa": 270, "blue": 271, - "key": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc" + "key": "4c03930c…" }, "b": { "hash": "ee71afc1ae2854454107b5b480234f4aaa999d2ea3d1d2515d85d062c491accf", "blocks": 464, "daa": 463, "blue": 464, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" } }, "fork_depth_daa_at_heal": 193, diff --git a/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-reorg.json b/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-reorg.json index e60de6e02..299735b89 100644 --- a/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-reorg.json +++ b/docs/plans/counter-asic-3-gate/fork-gate-gate-off-expect-reorg.json @@ -8,8 +8,8 @@ "split": 180, "watch": 150, "keys": { - "a": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc", - "b": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "a": "4c03930c…", + "b": "0e2c6fe2…" }, "share_at_fork": { "a": 95, @@ -22,14 +22,14 @@ "blocks": 303, "daa": 302, "blue": 303, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" }, "b": { "hash": "79ffa99db2a3afe2f8b3c74bb617a7f3880f9d35f34bb499712a6afa8f364f53", "blocks": 303, "daa": 302, "blue": 303, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" } }, "split_sinks": { @@ -38,14 +38,14 @@ "blocks": 303, "daa": 302, "blue": 303, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" }, "b": { "hash": "f71e0ca56578afd28cd33da93209c6cbda780d4f5fdc3d1978f2bed562c63c7a", "blocks": 465, "daa": 464, "blue": 465, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" } }, "fork_depth_daa_at_heal": 162, diff --git a/docs/plans/counter-asic-3-gate/fork-gate-gate-on-expect-hold.json b/docs/plans/counter-asic-3-gate/fork-gate-gate-on-expect-hold.json index 29849c6e8..248cb4eef 100644 --- a/docs/plans/counter-asic-3-gate/fork-gate-gate-on-expect-hold.json +++ b/docs/plans/counter-asic-3-gate/fork-gate-gate-on-expect-hold.json @@ -8,8 +8,8 @@ "split": 180, "watch": 150, "keys": { - "a": "4c03930c1a0daa6d98265ff99fb6dfd95d3916ae701c634b76402a102b7249fc", - "b": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "a": "4c03930c…", + "b": "0e2c6fe2…" }, "share_at_fork": { "a": 87, @@ -22,14 +22,14 @@ "blocks": 274, "daa": 273, "blue": 274, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" }, "b": { "hash": "d39cec5aa17a08fa09858104a76adad0284af7258a2f4d9b870cdb77461999bf", "blocks": 274, "daa": 273, "blue": 274, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" } }, "split_sinks": { @@ -38,14 +38,14 @@ "blocks": 274, "daa": 273, "blue": 274, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" }, "b": { "hash": "779835e50b6166b7f04098b986053f1a408b78cd03bdba5d7ab26e03a5eec6e8", "blocks": 457, "daa": 456, "blue": 457, - "key": "0e2c6fe281de9bc86cd5562cbf497e7e9ed14a0d8eb4313fd787ed0aeb7e3478" + "key": "0e2c6fe2…" } }, "fork_depth_daa_at_heal": 183, diff --git a/infra/fast-time/fork-gate.mjs b/infra/fast-time/fork-gate.mjs index d779afe7d..b7760eced 100644 --- a/infra/fast-time/fork-gate.mjs +++ b/infra/fast-time/fork-gate.mjs @@ -22,6 +22,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { createServer, connect as netConnect } from 'node:net'; import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs'; import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs'; +import { writeSummary } from './lib/redact-keys.mjs'; const ROOT = new URL('../../', import.meta.url).pathname; const FILE = `${ROOT}infra/fast-time/override-60x.json`; @@ -228,8 +229,9 @@ const good = EXPECT === 'hold' ? checks.b_was_a_minority_at_the_fork && checks.fork_deeper_than_window && checks.b_chain_heavier_at_heal && checks.a_learned_b_blocks_after_heal && checks.a_held_its_chain && checks.refusal_line_on_a : checks.b_was_a_minority_at_the_fork && checks.fork_deeper_than_window && checks.b_chain_heavier_at_heal && checks.a_reorged_to_b; const summary = { pass: good, expect: EXPECT, case: CASE, gate: GATE, window: WINDOW, joint: JOINT, split: SPLIT, watch: WATCH, keys: { a: keyA, b: keyB }, share_at_fork: share, joint_sinks: joint, split_sinks: split, fork_depth_daa_at_heal: forkDepthAtHeal, reorg_at_s: reorgAt, refusal_lines: refusals.length, refusal_example: refusals[0]?.replace(/^.*?Fork choice/, 'Fork choice') ?? null, b_blocks_learned_by_a: bBlocksKnownToA, peer_connections_on_a: peers, checks, samples, node: IGNEUMD, miner: CPU_MINER }; -mkdirSync(OUT.replace(/\/[^/]+$/, ''), { recursive: true }); -writeFileSync(OUT, JSON.stringify(summary, null, 2)); +// keys (the two vote-key hashes, every *.key under the sinks) go out as 8 hex and an ellipsis; a raw key fails here, not in CI +// (the no-secrets class of 7 October 2026: three summaries under docs/plans/counter-asic-3-gate turned eight runs red) +writeSummary(OUT, summary); const fails = Object.entries(checks).filter(([k, v]) => (EXPECT === 'hold' ? !['a_reorged_to_b'].includes(k) : ['b_was_a_minority_at_the_fork', 'fork_deeper_than_window', 'b_chain_heavier_at_heal', 'a_reorged_to_b'].includes(k)) && !v).map(([k]) => k); log(`SUMMARY ${good ? 'PASS' : 'FAIL'} (expect ${EXPECT}, gate ${GATE}): B held ${share.share_b_bps} bps of the blue blocks at the fork; fork depth ${forkDepthAtHeal} DAA against window ${WINDOW}; B blue ${split.b.blue} vs A ${split.a.blue} at heal; A learned ${bBlocksKnownToA} blocks after the heal; A's sink ${reorgAt == null ? 'stayed A-built' : `became B-built at ${reorgAt} s`}; ${refusals.length} refusal lines on A${fails.length ? `; FAILED CHECK ${fails.join(', ')}` : ''}`); log(`summary: ${OUT}`); diff --git a/infra/fast-time/lib/redact-keys.mjs b/infra/fast-time/lib/redact-keys.mjs new file mode 100644 index 000000000..8b38c6514 --- /dev/null +++ b/infra/fast-time/lib/redact-keys.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// Harness summaries never carry a raw key. Every fast-time runner that writes a summary JSON writes it through +// writeSummary(): a 64-hex value under a key-shaped field (a name ending in key, keys, token, secret, password or +// passphrase, or anything nested under one) is shortened to its first 8 hex characters and an ellipsis, and the +// serialised text is then checked with the same rule as tools/ci/no-secrets-check.sh before it touches the disk. +// A summary that would fail the no-secrets gate is refused here, at the source, with the line named. +// +// The class (7 October 2026, 11:26 to 12:47 UK): fork-gate.mjs wrote the two miners' vote-key hashes into +// joint_sinks.*.key and split_sinks.*.key of three gate summaries under docs/plans/counter-asic-3-gate; eight CI runs +// on ca3-v4-node went red on "no secret file names and no 64-hex secrets in the tree" and no pushing lane saw it, +// because the feature-branch hook ran only the structural checks and the red watcher posted master and release-* only. +// +// import { writeSummary } from './lib/redact-keys.mjs'; writeSummary(OUT, summary); +// node infra/fast-time/lib/redact-keys.mjs --self-test a key field is shortened, a hash field is left alone, +// a raw key in the text fails the writer's own check +// node infra/fast-time/lib/redact-keys.mjs --check ... exit 1 if any file carries a raw key line (the hits named) +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// the content rule of tools/ci/no-secrets-check.sh, verbatim in spirit: a 64-hex value (0x optional) assigned to a name +// ending in token, key, secret, password or passphrase +export const RAW_KEY_LINE = /(token|key|secret|password|passphrase)["']?\s*[:=]\s*["']?(0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/i; +export const KEY_FIELD = /(key|keys|token|secret|password|passphrase)$/i; +const HEX64 = /^(0x)?[0-9a-fA-F]{64}$/; + +export function shortHex(v) { + if (typeof v !== 'string' || !HEX64.test(v)) return v; + const head = v.startsWith('0x') ? 10 : 8; + return v.slice(0, head) + '…'; +} + +// A copy of `value` with every 64-hex string under a key-shaped field shortened. Nothing else changes: hashes, digests +// and ids under other names stay whole. The input object is never mutated (the runner keeps comparing live keys). +export function redactKeys(value, underKeyField = false) { + if (Array.isArray(value)) return value.map((v) => redactKeys(v, underKeyField)); + if (value && typeof value === 'object') { + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = redactKeys(v, underKeyField || KEY_FIELD.test(k)); + return out; + } + return underKeyField ? shortHex(value) : value; +} + +// The line numbers (1-based) of `text` that the no-secrets gate would flag. +export function rawKeyLines(text) { + return text.split('\n').map((l, i) => (RAW_KEY_LINE.test(l) ? i + 1 : 0)).filter(Boolean); +} + +// The text a summary is written as: redacted, then checked. Throws when a raw key survives (a key inside a free-text +// field such as a quoted log line), so the runner fails before the tree does. +export function summaryText(summary) { + const text = JSON.stringify(redactKeys(summary), null, 2); + const lines = rawKeyLines(text); + if (lines.length) throw new Error(`summary carries a raw 64-hex key at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}: the no-secrets gate would refuse it; shorten it with redactKeys or drop the field`); + return text; +} + +export function writeSummary(file, summary) { + mkdirSync(dirname(file), { recursive: true }); + const text = summaryText(summary); + writeFileSync(file, text); + return text; +} + +const hex = (c) => c.repeat(64); + +function selfTest() { + const fails = []; + const summary = { + pass: true, keys: { a: hex('a'), b: hex('b') }, + joint_sinks: { a: { hash: hex('c'), blocks: 1, key: hex('a') }, b: { hash: hex('d'), blocks: 2, key: '0x' + hex('b') } }, + samples: [{ t: 1, a: { sink: hex('c').slice(0, 16), key: hex('a') } }], + vote_key: hex('e'), signingKey: hex('f'), a_token: hex('1'), digest: hex('2'), exec_restart_hash: hex('3'), id32: 'a'.repeat(32), + }; + const before = JSON.stringify(summary); + const out = redactKeys(summary); + if (JSON.stringify(summary) !== before) fails.push('redactKeys mutated its input'); + if (out.keys.a !== 'aaaaaaaa…' || out.keys.b !== 'bbbbbbbb…') fails.push(`keys.* not shortened: ${out.keys.a} ${out.keys.b}`); + if (out.joint_sinks.a.key !== 'aaaaaaaa…') fails.push(`joint_sinks.a.key not shortened: ${out.joint_sinks.a.key}`); + if (out.joint_sinks.b.key !== '0xbbbbbbbb…') fails.push(`a 0x key kept its prefix wrong: ${out.joint_sinks.b.key}`); + if (out.samples[0].a.key !== 'aaaaaaaa…') fails.push('a key inside an array element was not shortened'); + if (out.vote_key !== 'eeeeeeee…' || out.signingKey !== 'ffffffff…' || out.a_token !== '11111111…') fails.push('a name ending in key/Key/token was not shortened'); + if (out.joint_sinks.a.hash !== hex('c') || out.digest !== hex('2') || out.exec_restart_hash !== hex('3')) fails.push('a hash, digest or *_hash field was changed'); + if (out.id32 !== 'a'.repeat(32) || out.joint_sinks.a.blocks !== 1) fails.push('a non-key value was changed'); + let text; + try { text = summaryText(summary); } catch (e) { fails.push(`summaryText refused a redactable summary: ${e.message}`); } + if (text && /[0-9a-f]{64}/.test(text.replace(new RegExp(`"(hash|digest|exec_restart_hash)": "(0x)?[0-9a-f]{64}"`, 'g'), ''))) fails.push('a raw 64-hex key survived in the written text'); + if (text && rawKeyLines(text).length) fails.push('the written text would fail the no-secrets rule'); + // the writer's own check: a raw key line in the text fails, under each shape the gate catches + for (const t of [`{\n "key": "${hex('9')}"\n}`, `KEY=0x${hex('8')}`, `x-igneum-key: ${hex('7')}`, `const signingKey = "${hex('6')}";`]) { + if (!rawKeyLines(t).length) fails.push(`rawKeyLines missed: ${t.slice(0, 30)}`); + } + if (rawKeyLines(`{\n "hash": "${hex('5')}",\n "id": "${'4'.repeat(32)}"\n}`).length) fails.push('rawKeyLines flagged a hash or a 32-hex id'); + // a key hiding in free text under a field the redactor does not know: summaryText refuses it, and names the line + let refused = false; + try { summaryText({ ok: true, refusal_example: `Fork choice: refused block by key: ${hex('9')}` }); } catch (e) { refused = /line 3/.test(e.message); } + if (!refused) fails.push('summaryText did not refuse (or did not name the line of) a raw key inside a free-text field'); + // writeSummary writes the redacted text, and --check on a raw file fails + const dir = mkdtempSync(join(tmpdir(), 'redact-keys-')); + try { + const good = join(dir, 'nested', 'good.json'); writeSummary(good, summary); + if (!existsSync(good) || rawKeyLines(readFileSync(good, 'utf8')).length) fails.push('writeSummary wrote a raw key or nothing'); + if (JSON.parse(readFileSync(good, 'utf8')).keys.a !== 'aaaaaaaa…') fails.push('the written file does not parse back to the shortened key'); + const bad = join(dir, 'bad.json'); writeFileSync(bad, JSON.stringify({ keys: { a: hex('a') }, sink: { key: hex('a') } }, null, 2)); + if (checkFiles([good]).length) fails.push('--check flagged the redacted file'); + const hits = checkFiles([bad]); + if (hits.length !== 1 || !/bad\.json:\d+/.test(hits[0])) fails.push(`--check did not name the raw file and line: ${hits.join(' | ')}`); + if (hits.some((h) => /[0-9a-f]{64}/.test(h))) fails.push('--check printed a 64-hex value'); + } finally { rmSync(dir, { recursive: true, force: true }); } + if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); } + console.log('self-test passed: key-shaped fields shorten to 8 hex and an ellipsis, hashes stay whole, the input is not mutated, a raw key in the text fails the writer\'s own check with its line, --check names a raw file without printing the key'); +} + +export function checkFiles(files) { + const hits = []; + for (const f of files) { + if (!existsSync(f)) { hits.push(`${f}: missing`); continue; } + for (const n of rawKeyLines(readFileSync(f, 'utf8'))) hits.push(`${f}:${n}: a raw 64-hex key under a key-shaped field`); + } + return hits; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = process.argv.slice(2); + if (args[0] === '--self-test') selfTest(); + else if (args[0] === '--check') { + const hits = checkFiles(args.slice(1)); + if (hits.length) { for (const h of hits) console.error(h); process.exit(1); } + console.log(`redact-keys: ${args.length - 1} file(s), no raw key`); + } else { console.error('usage: redact-keys.mjs --self-test | --check ...'); process.exit(2); } +} diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 659e98396..63c44848c 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -93,6 +93,7 @@ tree_checks() { run "launch gates: every row with its check, the handoff text clean (self-test, then the tree)" bash -c 'node tools/ci/launch-gates-check.mjs --self-test && node tools/ci/launch-gates-check.mjs' run "income per tier: the public table equals its inputs, the schedule arithmetic" bash -c 'node tools/launch/income-tiers.mjs --check && node --test tools/launch/income-tiers.test.mjs' run "hash-origin report: a known-finished day and a known-failed day" node --test tools/observer/hash-origin.test.mjs + run "harness summaries never carry a raw 64-hex key (the writer's own redaction and check)" node infra/fast-time/lib/redact-keys.mjs --self-test } gated_refs() {