fix(witness): support pinned signing keys and label legacy signatures (#4326)

* fix(witness): support pinned signing keys and label legacy signatures

The default witness seed is derived from the public git commit, so anyone can re-sign a modified manifest; verify.mjs still called the result cryptographically valid. Add RUFLO_WITNESS_SEED for external-key signing and RUFLO_WITNESS_PUBLIC_KEY for pinned verification (fail closed on external-key manifests without a pin), tag legacy manifests as self-attested, and stop describing the default as authorship proof.

* fix(witness): make --public-key win over the env pin

The env-var precedence audit requires a CLI flag for RUFLO_WITNESS_PUBLIC_KEY. Add --public-key, document that it takes precedence, and cover both precedence directions in the auth test.

* fix(ci): exclude witness-auth test from vitest ratchet (uses node:test)

plugins/ruflo-core/scripts/witness/__tests__/witness-auth.test.mjs imports
from node:test, not vitest, matching the same miscategorization pattern as
pod-tick.test.mjs and probe-mod-guards.test.mjs. vitest's glob picks it up
but can't parse node:test's API, so the ratchet reports "No test suite
found" even though `node --test` passes all 3 cases cleanly.

Co-Authored-By: RuFlo <ruv@ruv.net>

---------

Co-authored-by: r <r@x>
Co-authored-by: RuFlo <ruv@ruv.net>
This commit is contained in:
authored and GitHub committed 2026-10-20 10:59:26 -04:00
1 parent 3d5742bcc5
commit 558f194506
7 files changed
+235 -36

No files matched your search

+5 -3
View File
@@ -1,6 +1,6 @@
---
name: witness-curator
description: Maintains the cryptographically-signed witness manifest. Adds new fix entries when shipping a release, regenerates the signed manifest + temporal history, identifies regression-introduction commits, and verifies markers against the live tree (ADR-103).
description: Maintains the tamper-evident witness manifest. Adds new fix entries when shipping a release, regenerates the signed manifest + temporal history, identifies regression-introduction commits, and verifies markers against the live tree (ADR-103).
model: sonnet
---
@@ -20,8 +20,10 @@ You are invoked when:
## How the system works
The manifest at `verification.md.json` lists `{ id, desc, file, sha256, marker, markerVerified }` per fix.
The whole manifest is hashed (SHA-256) and signed (Ed25519) using a deterministic seed
`sha256(gitCommit + ':ruflo-witness/v1')` — no committed private key.
The whole manifest is hashed (SHA-256) and signed (Ed25519). By default the
seed is derived from the public commit (`sha256(gitCommit + ':ruflo-witness/v1')`),
which proves integrity but not authorship. Sign with `RUFLO_WITNESS_SEED` and
pin `RUFLO_WITNESS_PUBLIC_KEY` in `verify.mjs` when authorship matters.
`verification-history.jsonl` is an append-only log of each regen's snapshot.
That's what lets you bisect: walk back through entries to find the last commit
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: witness
description: Manage and verify a cryptographically-signed fix manifest with temporal history (ADR-103)
description: Manage and verify a tamper-evident fix manifest with temporal history (ADR-103)
argument-hint: "init|regen|verify|history|regressions [--manifest <path>] [--history <path>]"
---
@@ -0,0 +1,129 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { regenerate } from '../lib.mjs';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = resolve(HERE, '../../../../..');
const REPO_V3 = resolve(HERE, '../../../../../v3');
const ED_ROOTS = [process.env.WITNESS_ED25519_ROOT, REPO_ROOT, REPO_V3].filter(Boolean);
const VERIFY = resolve(HERE, '../verify.mjs');
function makeRepo() {
const root = mkdtempSync(join(tmpdir(), 'witness-auth-'));
writeFileSync(join(root, 'README.md'), 'fixture\n');
execFileSync('git', ['init', '-q'], { cwd: root });
execFileSync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: root });
execFileSync('git', ['config', 'user.name', 'Witness Test'], { cwd: root });
execFileSync('git', ['add', 'README.md'], { cwd: root });
execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: root });
return root;
}
function runVerify(root, manifestPath, env = {}, extraArgs = []) {
return execFileSync(process.execPath, [VERIFY, '--manifest', manifestPath, '--root', root, '--json', ...extraArgs], {
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
function runVerifyFailing(root, manifestPath, env = {}, extraArgs = []) {
try {
runVerify(root, manifestPath, env, extraArgs);
assert.fail('verify.mjs was expected to exit non-zero');
} catch (error) {
assert.equal(error.status, 1);
return JSON.parse(error.stdout);
}
}
test('external signing key verifies only with the pinned public key', () => {
const root = makeRepo();
try {
const manifestPath = join(root, 'verification.md.json');
const seed = randomBytes(32);
const result = regenerate({
repoRoot: root,
manifestPath,
ed25519Roots: ED_ROOTS,
signingSeed: seed,
});
writeFileSync(manifestPath, JSON.stringify(result.witness, null, 2));
const publicKey = result.witness.integrity.publicKey;
const pinned = JSON.parse(runVerify(root, manifestPath, { RUFLO_WITNESS_PUBLIC_KEY: publicKey }));
assert.equal(pinned.ok, true);
assert.equal(pinned.signature.authenticity, 'external-key');
assert.equal(pinned.signature.pinnedKeyConfigured, true);
assert.equal(pinned.signature.pinnedKeyMatches, true);
assert.equal(pinned.signature.publicKeyReproducible, true);
const unpinned = runVerifyFailing(root, manifestPath);
assert.equal(unpinned.ok, false);
assert.equal(unpinned.signature.authenticity, 'external-key');
assert.equal(unpinned.signature.publicKeyReproducible, false);
const wrongKey = randomBytes(32).toString('hex');
// --public-key takes precedence over a conflicting env var.
const flagWins = JSON.parse(runVerify(
root,
manifestPath,
{ RUFLO_WITNESS_PUBLIC_KEY: wrongKey },
['--public-key', publicKey]
));
assert.equal(flagWins.ok, true);
assert.equal(flagWins.signature.pinnedKeyMatches, true);
const flagLoses = runVerifyFailing(
root,
manifestPath,
{ RUFLO_WITNESS_PUBLIC_KEY: publicKey },
['--public-key', wrongKey]
);
assert.equal(flagLoses.ok, false);
assert.equal(flagLoses.signature.pinnedKeyMatches, false);
const wrong = runVerifyFailing(root, manifestPath, {
RUFLO_WITNESS_PUBLIC_KEY: wrongKey,
});
assert.equal(wrong.ok, false);
assert.equal(wrong.signature.pinnedKeyMatches, false);
assert.equal(wrong.signature.publicKeyReproducible, false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('legacy commit-derived seed is reported as self-attested', () => {
const root = makeRepo();
try {
const manifestPath = join(root, 'verification.md.json');
const result = regenerate({ repoRoot: root, manifestPath, ed25519Roots: ED_ROOTS });
writeFileSync(manifestPath, JSON.stringify(result.witness, null, 2));
const legacy = JSON.parse(runVerify(root, manifestPath));
assert.equal(legacy.ok, true);
assert.equal(legacy.signature.authenticity, 'self-attested');
assert.equal(legacy.signature.pinnedKeyConfigured, false);
assert.equal(legacy.signature.publicKeyReproducible, true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('invalid RUFLO_WITNESS_SEED is rejected instead of silently falling back', () => {
const root = makeRepo();
try {
assert.throws(
() => regenerate({ repoRoot: root, manifestPath: join(root, 'verification.md.json'), ed25519Roots: ED_ROOTS, signingSeed: 'abcd' }),
/32 bytes/
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+31 -4
View File
@@ -1,6 +1,6 @@
/**
* Witness library — shared logic for regenerating, verifying, and
* querying cryptographically-signed fix manifests with temporal
* querying tamper-evident fix manifests with temporal
* history (ADR-103).
*
* Pure functions + small I/O helpers; no project-specific assumptions.
@@ -111,6 +111,27 @@ export function refreshFix(repoRoot, fix) {
return { id: fix.id, desc: fix.desc, file: fix.file, sha256, marker: fix.marker, markerVerified };
}
/**
* Resolve the Ed25519 seed used to sign a manifest.
*
* The historical default is sha256(gitCommit + ':ruflo-witness/v1'). That
* seed is public (the commit is public), so the resulting signature is an
* integrity checksum, not proof of authorship. Deployments that need
* authenticity can provide a 32-byte seed through RUFLO_WITNESS_SEED (or
* opts.signingSeed); verify.mjs can then pin the matching public key.
*/
export function resolveSigningSeed(explicitSeed) {
const raw = explicitSeed ?? process.env.RUFLO_WITNESS_SEED;
if (raw === undefined || raw === null || raw === '') {
return { seed: null, source: "sha256(gitCommit + ':ruflo-witness/v1')" };
}
const seed = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'hex');
if (seed.length !== 32) {
throw new Error('RUFLO_WITNESS_SEED must be exactly 32 bytes (64 hex characters)');
}
return { seed, source: 'env:RUFLO_WITNESS_SEED' };
}
// ─── regenerate ───────────────────────────────────────────────────
/**
* Regenerate a signed witness manifest.
@@ -124,7 +145,7 @@ export function refreshFix(repoRoot, fix) {
* @returns {{witness: object, summary: string}}
*/
export function regenerate(opts) {
const { repoRoot, manifestPath, newFixes = [], releases = {}, ed25519Roots = [] } = opts;
const { repoRoot, manifestPath, newFixes = [], releases = {}, ed25519Roots = [], signingSeed } = opts;
const ed = loadEd25519(ed25519Roots.length ? ed25519Roots : [repoRoot, join(repoRoot, 'v3')]);
const existing = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : null;
@@ -156,7 +177,8 @@ export function regenerate(opts) {
const manifestCanonical = JSON.stringify(manifest);
const manifestHash = createHash('sha256').update(manifestCanonical).digest('hex');
const seed = createHash('sha256').update(gitCommit + ':ruflo-witness/v1').digest();
const signing = resolveSigningSeed(signingSeed);
const seed = signing.seed ?? createHash('sha256').update(gitCommit + ':ruflo-witness/v1').digest();
const publicKey = ed.getPublicKey(seed);
const signature = ed.sign(Buffer.from(manifestHash, 'hex'), seed);
@@ -168,7 +190,11 @@ export function regenerate(opts) {
signatureAlgo: 'ed25519',
publicKey: Buffer.from(publicKey).toString('hex'),
signature: Buffer.from(signature).toString('hex'),
seedDerivation: "sha256(gitCommit + ':ruflo-witness/v1')",
// self-attested = the public commit deterministically derives the seed,
// so anyone can reproduce the signature. external-key = signed with a
// caller-held seed; verify.mjs should pin the matching public key.
authenticity: signing.seed ? 'external-key' : 'self-attested',
seedDerivation: signing.source,
},
};
@@ -180,6 +206,7 @@ export function regenerate(opts) {
`total fixes: ${merged.length} (was ${oldFixes.length})\n` +
`verified: ${verifiedCount}\n` +
`missing: ${missingCount}\n` +
`authenticity: ${witness.integrity.authenticity}\n` +
`new entries: ${newIds.join(', ') || '(none)'}\n` +
`releases: ${JSON.stringify(releases)}`;
+58 -24
View File
@@ -5,7 +5,7 @@
* Project-agnostic — works without ruflo CLI being installed.
*
* Usage:
* node verify.mjs --manifest <path> [--root <path>] [--source-only] [--json] [--strict]
* node verify.mjs --manifest <path> [--root <path>] [--source-only] [--json] [--strict] [--public-key <hex>]
*
* Exit codes:
* 0 — signature valid + all fixes pass or drift (marker present);
@@ -30,7 +30,7 @@ import { fileSha256, fileContains } from './lib.mjs';
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log('Usage: node verify.mjs --manifest <path> [--root <path>] [--source-only] [--json] [--strict]');
console.log('Usage: node verify.mjs --manifest <path> [--root <path>] [--source-only] [--json] [--strict] [--public-key <hex>]');
process.exit(0);
}
if (!args.manifest) { console.error('--manifest <path> required'); process.exit(2); }
@@ -46,7 +46,7 @@ const strict = !!args.strict;
const witness = JSON.parse(readFileSync(manifestPath, 'utf8'));
// ─── signature ────────────────────────────────────────────────────
const sig = verifySignature(witness);
const sig = verifySignature(witness, { publicKey: args['public-key'] });
// ─── per-fix marker check ─────────────────────────────────────────
const allFixes = witness.manifest.fixes;
@@ -135,6 +135,16 @@ if (asJson) {
console.log(` hash matches: ${sig.manifestHashOk ? 'yes' : 'NO'}`);
console.log(` public key reproducible: ${sig.publicKeyReproducible ? 'yes' : 'NO'}`);
console.log(` Ed25519 signature valid: ${sig.signatureValid ? 'yes' : 'NO'}`);
console.log(` authenticity: ${sig.authenticity}${sig.pinnedKeyConfigured ? ' (pinned key)' : ''}`);
if (sig.authenticity === 'self-attested' && !sig.pinnedKeyConfigured) {
console.log(' WARNING: the signing seed is derived from the public git commit, so this');
console.log(' signature proves integrity, not authorship. Set');
console.log(' --public-key or RUFLO_WITNESS_PUBLIC_KEY to pin a release key.');
}
if (sig.authenticity === 'external-key' && !sig.pinnedKeyConfigured) {
console.log(' WARNING: manifest was signed with an external key; verification fails closed');
console.log(' until --public-key or RUFLO_WITNESS_PUBLIC_KEY pins the matching public key.');
}
console.log('');
console.log(`Summary: pass=${summary.pass} drift=${summary.drift} regressed=${summary.regressed} missing=${summary.missing} skipped-generated=${summary.skippedGenerated}`);
if (summary.regressed > 0) {
@@ -154,30 +164,44 @@ if (asJson) {
process.exit(ok ? 0 : 1);
// ─── ed25519 helpers ─────────────────────────────────────────────
function verifySignature(witness) {
function verifySignature(witness, options = {}) {
try {
const recomputed = createHash('sha256').update(JSON.stringify(witness.manifest)).digest('hex');
const manifestHashOk = recomputed === witness.integrity.manifestHash;
const seed = createHash('sha256')
.update(witness.manifest.gitCommit + ':ruflo-witness/v1')
.digest();
const authenticity = witness.integrity.authenticity ?? 'self-attested';
const declaredPublicKey = String(witness.integrity.publicKey ?? '').toLowerCase();
// --public-key takes precedence over the RUFLO_WITNESS_PUBLIC_KEY env var.
const pinnedKey = String(options.publicKey ?? process.env.RUFLO_WITNESS_PUBLIC_KEY ?? '').trim().toLowerCase();
const pinnedKeyConfigured = pinnedKey.length > 0;
const pinnedKeyMatches = pinnedKeyConfigured ? declaredPublicKey === pinnedKey : undefined;
// RFC 8410 DER wrappers let Node/OpenSSL derive and verify Ed25519 keys
// without a package install. The witness format remains byte-compatible
// with manifests produced by @noble/ed25519.
const privateKey = createPrivateKey({
key: Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'),
seed,
]),
format: 'der',
type: 'pkcs8',
});
const reproducedKey = createPublicKey(privateKey)
.export({ format: 'der', type: 'spki' })
.subarray(-32);
const publicKeyReproducible =
reproducedKey.toString('hex') === witness.integrity.publicKey;
// For a legacy self-attested manifest, the public key is reproducible from
// the commit. For an external-key manifest, only a caller-supplied pinned
// key can establish authenticity; without one verification fails closed.
let publicKeyReproducible = false;
if (pinnedKeyConfigured) {
publicKeyReproducible = pinnedKeyMatches === true;
} else if (authenticity === 'self-attested') {
const seed = createHash('sha256')
.update(witness.manifest.gitCommit + ':ruflo-witness/v1')
.digest();
// RFC 8410 DER wrappers let Node/OpenSSL derive and verify Ed25519 keys
// without a package install. The witness format remains byte-compatible
// with manifests produced by @noble/ed25519.
const privateKey = createPrivateKey({
key: Buffer.concat([
Buffer.from('302e020100300506032b657004220420', 'hex'),
seed,
]),
format: 'der',
type: 'pkcs8',
});
const reproducedKey = createPublicKey(privateKey)
.export({ format: 'der', type: 'spki' })
.subarray(-32);
publicKeyReproducible = reproducedKey.toString('hex') === declaredPublicKey;
}
const declaredKey = createPublicKey({
key: Buffer.concat([
@@ -193,12 +217,22 @@ function verifySignature(witness) {
declaredKey,
Buffer.from(witness.integrity.signature, 'hex'),
);
return { manifestHashOk, publicKeyReproducible, signatureValid, verifier: 'node:crypto' };
return {
manifestHashOk,
publicKeyReproducible,
signatureValid,
authenticity,
pinnedKeyConfigured,
pinnedKeyMatches,
verifier: 'node:crypto',
};
} catch (error) {
return {
manifestHashOk: false,
publicKeyReproducible: false,
signatureValid: false,
authenticity: witness?.integrity?.authenticity ?? 'self-attested',
pinnedKeyConfigured: Boolean(options.publicKey ?? process.env.RUFLO_WITNESS_PUBLIC_KEY),
verifier: 'node:crypto',
reason: 'signature-verification-error',
error: error instanceof Error ? error.message : String(error),
+10 -4
View File
@@ -1,16 +1,18 @@
---
name: witness
description: Sign, verify, and track fix-marker regressions over time using a deterministic Ed25519 witness manifest. Works in any project — clone the toolkit, run init, register fixes, regen on each release.
description: Sign, verify, and track fix-marker regressions over time using an Ed25519 witness manifest. The default seed is public-commit-derived (integrity only); set RUFLO_WITNESS_SEED/RUFLO_WITNESS_PUBLIC_KEY for authorship. Works in any project — clone the toolkit, run init, register fixes, regen on each release.
argument-hint: "init|regen|verify|history [...]"
allowed-tools: Bash(node *), Read, Write, Edit
---
# Witness — cryptographic fix-regression tracking
# Witness — tamper-evident fix-regression tracking
The witness toolkit lets you ship every release with a *signed* manifest
that lists every documented fix in your codebase along with a sha256 +
marker substring. Anyone with the same git commit can re-derive the
public key and verify the signature without a committed private key.
marker substring. By default the signing seed is derived from the public
git commit, so anyone can reproduce the signature: this is an integrity
checksum, not proof of authorship. For authenticity, sign with a 32-byte
`RUFLO_WITNESS_SEED` and verify with a pinned `RUFLO_WITNESS_PUBLIC_KEY`.
A temporal history (JSONL) tracks how the fix population evolves across
releases — so when a regression appears, you can pinpoint *the commit
@@ -54,6 +56,10 @@ node plugins/ruflo-core/scripts/witness/verify.mjs \
# For a CI hash tripwire, also fail when a file changes but its marker remains.
node plugins/ruflo-core/scripts/witness/verify.mjs \
--manifest verification.md.json --source-only --strict
# Authenticity: pin a release public key (the flag wins over the env var).
node plugins/ruflo-core/scripts/witness/verify.mjs \
--manifest verification.md.json --public-key <64-hex-public-key>
```
Without `--strict`, marker-preserving SHA drift is reported but exits 0.
+1
View File
@@ -104,3 +104,4 @@ plugins/ruflo-core/scripts/command-recording-switch.test.cjs
tests/regression/oct12-coverage.test.cjs
plugins/ruflo-adr/scripts/__tests__/adr-status-forms-4126.test.mjs
scripts/__tests__/probe-mod-guards.test.mjs
plugins/ruflo-core/scripts/witness/__tests__/witness-auth.test.mjs