From 2791a672701f14d7e07e0a88de7b34d6be868e13 Mon Sep 17 00:00:00 2001 From: askalf <263217947+askalf@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:12:32 -0400 Subject: [PATCH] chore: track current tool releases; showcase redstamp 0.5.1 audit truncation-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the three pinned tools to their current vetted master heads: - @askalf/redstamp -> v0.5.1 (security release: audit tail-truncation detection, writeRoots confinement, constant-time daemon token) - @askalf/truecopy -> latest master - @askalf/strongroom -> latest master redstamp 0.5.1 gives its hash-chained audit the same tail-truncation protection strongroom already has via its HMAC tip: a valid chain PREFIX still verifies, so lopping off the newest verdicts slipped a bare verify(); pinning the head in a checkpoint (verifyAuditFile(path, { head, count })) catches it. The composed gate already holds { head, count } in memory as it records, so the fourth-guarantee demo/test now shows this end-to-end. - test/audit.test.mjs: +1 test — tail-truncation caught against the gate's checkpoint while the bare chain waves the prefix through. - demo/audit-demo.mjs: adds the truncation beat (edit-detection unchanged). - README: the "fourth guarantee" section now covers tail-truncation, no longer crediting it to strongroom alone. No tool-surface change: dump-tools --check + truecopy gate both clean; 17/17 tests, all demos pass. --- README.md | 8 +++++++- demo/audit-demo.mjs | 25 +++++++++++++++++++++++-- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- test/audit.test.mjs | 23 +++++++++++++++++++++++ 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 446de3a..d1feb63 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,15 @@ The trail on disk — 10 chained entries, each sealing the one before: Attacker rewrites entry #4 (truecopy's block) to read 'pass'… verifyAuditFile() → {"ok":false,"at":4} ❌ BROKEN — tamper detected at entry #4 + +A sneakier tamper — the attacker DELETES the newest verdicts (a valid PREFIX +still verifies by the chain alone). redstamp 0.5.1 anchors the chain head, and +the gate holds {head, count} in memory as it records: + verifyAuditFile(trail) → {"ok":true,"entries":8} ⚠️ prefix still checks out + verifyAuditFile(trail, checkpoint) → {"ok":false,"at":8,"reason":"truncated"} ❌ TRUNCATION caught ``` -`npm run demo:audit` runs this live; `npm test` asserts it (intact chain verifies; an edit, and a mid-log deletion, both break it and pinpoint where). strongroom additionally seals its own secret-access log with an HMAC **tip**, so even truncating or re-rooting that log is detectable. +`npm run demo:audit` runs this live; `npm test` asserts it (intact chain verifies; an edit, a mid-log deletion, **and a tail-truncation** each break it and pinpoint where). Tail-truncation — deleting the most-recent entries — is the one tamper a bare hash chain can't see, because a valid prefix still verifies. **redstamp 0.5.1** closes it: it anchors the chain head in a checkpoint (`verifyAuditFile(path, { head, count })`), so a log rewound to hide the verdict that just stopped an attacker is caught — the same guarantee strongroom already gives its secret-access log with an HMAC **tip**. ## Related Own Your Stack tools diff --git a/demo/audit-demo.mjs b/demo/audit-demo.mjs index 91355e2..e655fb2 100644 --- a/demo/audit-demo.mjs +++ b/demo/audit-demo.mjs @@ -58,8 +58,25 @@ forgeEntry(trailPath, tamperAt, { decision: 'pass', verdict: 'clean' }); const broken = verifyAuditFile(trailPath); L(' verifyAuditFile() → ' + JSON.stringify(broken) + ' ❌ BROKEN — tamper detected at entry #' + broken.at); L('\n The edit is silent in the file but LOUD in the chain: you cannot rewrite history'); -L(' without breaking the seal. (strongroom protects its own secret-access log the same way,'); -L(' with an additional HMAC tip — strongroomAudit.verify(): ' + JSON.stringify(strongroomAudit.verify()) + ')'); +L(' without breaking the seal.'); + +// The other tamper a plain chain can't see: DELETING the newest verdicts. A valid +// PREFIX still verifies — so redstamp 0.5.1 pins the head. The gate keeps the chain +// head + length in memory as it records (a checkpoint an on-disk attacker can't +// reach); verifying against it catches a truncation the bare chain waves through. +const fresh = runTrilogy(); +fresh.audit.flush(fresh.trailPath); +const checkpoint = { head: fresh.audit.entries.at(-1).hash, count: fresh.audit.entries.length }; +const freshLines = fs.readFileSync(fresh.trailPath, 'utf8').split('\n').filter((l) => l.trim()); +fs.writeFileSync(fresh.trailPath, freshLines.slice(0, -2).join('\n') + '\n'); // lop off the newest two +const barePrefix = verifyAuditFile(fresh.trailPath); +const vsCheckpoint = verifyAuditFile(fresh.trailPath, checkpoint); +L('\nA sneakier tamper — the attacker DELETES the last two verdicts instead of editing one:'); +L(' verifyAuditFile() alone → ' + JSON.stringify(barePrefix) + ' ⚠️ a truncated prefix still checks out'); +L(' …against the gate\'s checkpoint → ' + JSON.stringify(vsCheckpoint) + ' ❌ TRUNCATION caught'); +L('\n redstamp 0.5.1 anchors the chain head, so truncating the newest entries is caught too —'); +L(' the same protection strongroom gives its secret-access log with an HMAC tip.'); +L(' strongroomAudit.verify(): ' + JSON.stringify(strongroomAudit.verify())); L('\nvet it · contain it · key it never holds · and PROVE every decision ✅\n'); @@ -72,3 +89,7 @@ if (!ok.ok || broken.ok || broken.at !== tamperAt) { console.error('demo failed: the audit trail did not verify intact then break at the tampered entry'); process.exit(1); } +if (!barePrefix.ok || vsCheckpoint.ok || vsCheckpoint.reason !== 'truncated') { + console.error('demo failed: tail-truncation was not caught against the checkpoint'); + process.exit(1); +} diff --git a/package-lock.json b/package-lock.json index e6d417a..a2151fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@askalf/redstamp": "git+https://github.com/askalf/redstamp.git#4022dcd48dc867ef82a8a4c2a6f32f7599b3f701", - "@askalf/strongroom": "git+https://github.com/askalf/strongroom.git#9718edcae54b9bdee9f725cc4d5edae4abc2bbf8", - "@askalf/truecopy": "git+https://github.com/askalf/truecopy.git#55582d9b89dfdb9bbe6eea8face7ecc85ac21e24", + "@askalf/redstamp": "git+https://github.com/askalf/redstamp.git#27c26851e175045a3e274042c96760cf52dce9da", + "@askalf/strongroom": "git+https://github.com/askalf/strongroom.git#43c9ca600ced5f9acc755590bc324e95764a5195", + "@askalf/truecopy": "git+https://github.com/askalf/truecopy.git#951460e003ab16ef86ea5f297c6523c09b4fba4e", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, @@ -23,9 +23,9 @@ } }, "node_modules/@askalf/redstamp": { - "version": "0.4.1", - "resolved": "git+ssh://git@github.com/askalf/redstamp.git#4022dcd48dc867ef82a8a4c2a6f32f7599b3f701", - "integrity": "sha512-vz/jk+O+MvBPamD4K4ondPajDVDKjncpdXfrWeMmKiCAMKx3oaJm4664Xacejesi4pnufYs7PGrIh54VDlNWkA==", + "version": "0.5.1", + "resolved": "git+ssh://git@github.com/askalf/redstamp.git#27c26851e175045a3e274042c96760cf52dce9da", + "integrity": "sha512-wyYu6nB+f7kByRxZrN4ErGoE+nk9IkVCjeMEG7pQQlLjet5Y6x+xQ+5PtoXjZimldtnO5b5/d6RSp/twlJzOow==", "license": "MIT", "bin": { "redstamp": "src/cli.mjs", @@ -42,9 +42,9 @@ } }, "node_modules/@askalf/strongroom": { - "version": "0.1.1", - "resolved": "git+ssh://git@github.com/askalf/strongroom.git#9718edcae54b9bdee9f725cc4d5edae4abc2bbf8", - "integrity": "sha512-UrccljtfwsUODPgLEV3I3QV0c3dGWy8HOdx5kqc7H9T+EUcp/fh9Xssk0x3ISORbO4ANE2ZwG7wLoCDd1Ztilg==", + "version": "0.2.1", + "resolved": "git+ssh://git@github.com/askalf/strongroom.git#43c9ca600ced5f9acc755590bc324e95764a5195", + "integrity": "sha512-V233EmQkoruosCMd9EE3wl/TAW9AJw1thqoWgrnmypUCi/s8UKtHV0lc1hzK1uGhrGPhdUwBlXBLGCfuc/LRPA==", "license": "MIT", "dependencies": { "@askalf/redstamp": "github:askalf/redstamp#3da60b9d720cb3f152f3db906673be0a2f8116b7" @@ -58,9 +58,9 @@ } }, "node_modules/@askalf/truecopy": { - "version": "0.6.2", - "resolved": "git+ssh://git@github.com/askalf/truecopy.git#55582d9b89dfdb9bbe6eea8face7ecc85ac21e24", - "integrity": "sha512-CcujpAzV6LPczGQ1cm1B4y37PDyuGpvUcKTH7K+GYrLGSUakNdbYRmc9d6kn2G/YDK5Nb2rM1mwDqBprz2vHAg==", + "version": "0.8.0", + "resolved": "git+ssh://git@github.com/askalf/truecopy.git#951460e003ab16ef86ea5f297c6523c09b4fba4e", + "integrity": "sha512-KelAhlNnq0ozldTpfuFgOLuJgx4aZ+dFlhufpkefFVnD5lcjjPlr5RjbQGLrrMRr8WCdBpD+2FAgTK9ggrwvYg==", "license": "MIT", "dependencies": { "@askalf/redstamp": "github:askalf/redstamp#3da60b9d720cb3f152f3db906673be0a2f8116b7" diff --git a/package.json b/package.json index 6153957..ef58b6c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "fuzz": "node fuzz/run.mjs" }, "dependencies": { - "@askalf/redstamp": "git+https://github.com/askalf/redstamp.git#4022dcd48dc867ef82a8a4c2a6f32f7599b3f701", - "@askalf/truecopy": "git+https://github.com/askalf/truecopy.git#55582d9b89dfdb9bbe6eea8face7ecc85ac21e24", - "@askalf/strongroom": "git+https://github.com/askalf/strongroom.git#9718edcae54b9bdee9f725cc4d5edae4abc2bbf8", + "@askalf/redstamp": "git+https://github.com/askalf/redstamp.git#27c26851e175045a3e274042c96760cf52dce9da", + "@askalf/truecopy": "git+https://github.com/askalf/truecopy.git#951460e003ab16ef86ea5f297c6523c09b4fba4e", + "@askalf/strongroom": "git+https://github.com/askalf/strongroom.git#43c9ca600ced5f9acc755590bc324e95764a5195", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.4.3" }, diff --git a/test/audit.test.mjs b/test/audit.test.mjs index 9300daa..a044eb4 100644 --- a/test/audit.test.mjs +++ b/test/audit.test.mjs @@ -68,6 +68,29 @@ test('deleting an entry (truncating the chain mid-log) is also caught', () => { assert.equal(v.ok, false, 'removing a link must break the chain'); }); +test('tail-truncation (dropping the NEWEST verdicts) is caught against the gate\'s checkpoint', () => { + // A plain hash chain catches an edit or a MIDDLE deletion, but a valid PREFIX + // still verifies — so lopping off the most-recent entries (an attacker hiding + // the verdict that just stopped them) slips a bare verify(). redstamp 0.5.1 + // pins the chain head: the gate holds { head, count } in memory as it records + // (a checkpoint an attacker who only rewrites the on-disk log cannot reach), + // and verifying against it catches the truncation. + const { audit, trailPath } = runTrilogy({ home: fs.mkdtempSync(path.join(os.tmpdir(), 'oys-audit-trunc-')) }); + audit.flush(trailPath); + const checkpoint = { head: audit.entries.at(-1).hash, count: audit.entries.length }; + + const lines = fs.readFileSync(trailPath, 'utf8').split('\n').filter((l) => l.trim()); + assert.ok(lines.length >= 3, 'precondition: several entries to truncate'); + fs.writeFileSync(trailPath, lines.slice(0, -2).join('\n') + '\n'); // drop the newest two + + // the chain alone still passes the surviving prefix — this is the gap the checkpoint closes + assert.equal(verifyAuditFile(trailPath).ok, true, 'a truncated prefix passes the bare chain'); + // against the trusted checkpoint, the truncation is caught and named + const caught = verifyAuditFile(trailPath, checkpoint); + assert.equal(caught.ok, false, 'tail-truncation must be caught against the checkpoint'); + assert.equal(caught.reason, 'truncated'); +}); + test('a fresh, untampered AuditLog roots at GENESIS and chains forward', () => { // smallest possible unit, independent of the trilogy scenario const a = new AuditLog();