mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
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:
7 files changed
+235
-36
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: witness-curator
|
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
|
model: sonnet
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,8 +20,10 @@ You are invoked when:
|
|||||||
## How the system works
|
## How the system works
|
||||||
|
|
||||||
The manifest at `verification.md.json` lists `{ id, desc, file, sha256, marker, markerVerified }` per fix.
|
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
|
The whole manifest is hashed (SHA-256) and signed (Ed25519). By default the
|
||||||
`sha256(gitCommit + ':ruflo-witness/v1')` — no committed private key.
|
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.
|
`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
|
That's what lets you bisect: walk back through entries to find the last commit
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: witness
|
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>]"
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Witness library — shared logic for regenerating, verifying, and
|
* 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).
|
* history (ADR-103).
|
||||||
*
|
*
|
||||||
* Pure functions + small I/O helpers; no project-specific assumptions.
|
* 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 };
|
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 ───────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* Regenerate a signed witness manifest.
|
* Regenerate a signed witness manifest.
|
||||||
@@ -124,7 +145,7 @@ export function refreshFix(repoRoot, fix) {
|
|||||||
* @returns {{witness: object, summary: string}}
|
* @returns {{witness: object, summary: string}}
|
||||||
*/
|
*/
|
||||||
export function regenerate(opts) {
|
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 ed = loadEd25519(ed25519Roots.length ? ed25519Roots : [repoRoot, join(repoRoot, 'v3')]);
|
||||||
|
|
||||||
const existing = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : null;
|
const existing = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : null;
|
||||||
@@ -156,7 +177,8 @@ export function regenerate(opts) {
|
|||||||
|
|
||||||
const manifestCanonical = JSON.stringify(manifest);
|
const manifestCanonical = JSON.stringify(manifest);
|
||||||
const manifestHash = createHash('sha256').update(manifestCanonical).digest('hex');
|
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 publicKey = ed.getPublicKey(seed);
|
||||||
const signature = ed.sign(Buffer.from(manifestHash, 'hex'), seed);
|
const signature = ed.sign(Buffer.from(manifestHash, 'hex'), seed);
|
||||||
|
|
||||||
@@ -168,7 +190,11 @@ export function regenerate(opts) {
|
|||||||
signatureAlgo: 'ed25519',
|
signatureAlgo: 'ed25519',
|
||||||
publicKey: Buffer.from(publicKey).toString('hex'),
|
publicKey: Buffer.from(publicKey).toString('hex'),
|
||||||
signature: Buffer.from(signature).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` +
|
`total fixes: ${merged.length} (was ${oldFixes.length})\n` +
|
||||||
`verified: ${verifiedCount}\n` +
|
`verified: ${verifiedCount}\n` +
|
||||||
`missing: ${missingCount}\n` +
|
`missing: ${missingCount}\n` +
|
||||||
|
`authenticity: ${witness.integrity.authenticity}\n` +
|
||||||
`new entries: ${newIds.join(', ') || '(none)'}\n` +
|
`new entries: ${newIds.join(', ') || '(none)'}\n` +
|
||||||
`releases: ${JSON.stringify(releases)}`;
|
`releases: ${JSON.stringify(releases)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Project-agnostic — works without ruflo CLI being installed.
|
* Project-agnostic — works without ruflo CLI being installed.
|
||||||
*
|
*
|
||||||
* Usage:
|
* 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:
|
* Exit codes:
|
||||||
* 0 — signature valid + all fixes pass or drift (marker present);
|
* 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));
|
const args = parseArgs(process.argv.slice(2));
|
||||||
if (args.help) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (!args.manifest) { console.error('--manifest <path> required'); process.exit(2); }
|
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'));
|
const witness = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||||
|
|
||||||
// ─── signature ────────────────────────────────────────────────────
|
// ─── signature ────────────────────────────────────────────────────
|
||||||
const sig = verifySignature(witness);
|
const sig = verifySignature(witness, { publicKey: args['public-key'] });
|
||||||
|
|
||||||
// ─── per-fix marker check ─────────────────────────────────────────
|
// ─── per-fix marker check ─────────────────────────────────────────
|
||||||
const allFixes = witness.manifest.fixes;
|
const allFixes = witness.manifest.fixes;
|
||||||
@@ -135,6 +135,16 @@ if (asJson) {
|
|||||||
console.log(` hash matches: ${sig.manifestHashOk ? 'yes' : 'NO'}`);
|
console.log(` hash matches: ${sig.manifestHashOk ? 'yes' : 'NO'}`);
|
||||||
console.log(` public key reproducible: ${sig.publicKeyReproducible ? 'yes' : 'NO'}`);
|
console.log(` public key reproducible: ${sig.publicKeyReproducible ? 'yes' : 'NO'}`);
|
||||||
console.log(` Ed25519 signature valid: ${sig.signatureValid ? '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('');
|
||||||
console.log(`Summary: pass=${summary.pass} drift=${summary.drift} regressed=${summary.regressed} missing=${summary.missing} skipped-generated=${summary.skippedGenerated}`);
|
console.log(`Summary: pass=${summary.pass} drift=${summary.drift} regressed=${summary.regressed} missing=${summary.missing} skipped-generated=${summary.skippedGenerated}`);
|
||||||
if (summary.regressed > 0) {
|
if (summary.regressed > 0) {
|
||||||
@@ -154,30 +164,44 @@ if (asJson) {
|
|||||||
process.exit(ok ? 0 : 1);
|
process.exit(ok ? 0 : 1);
|
||||||
|
|
||||||
// ─── ed25519 helpers ─────────────────────────────────────────────
|
// ─── ed25519 helpers ─────────────────────────────────────────────
|
||||||
function verifySignature(witness) {
|
function verifySignature(witness, options = {}) {
|
||||||
try {
|
try {
|
||||||
const recomputed = createHash('sha256').update(JSON.stringify(witness.manifest)).digest('hex');
|
const recomputed = createHash('sha256').update(JSON.stringify(witness.manifest)).digest('hex');
|
||||||
const manifestHashOk = recomputed === witness.integrity.manifestHash;
|
const manifestHashOk = recomputed === witness.integrity.manifestHash;
|
||||||
const seed = createHash('sha256')
|
const authenticity = witness.integrity.authenticity ?? 'self-attested';
|
||||||
.update(witness.manifest.gitCommit + ':ruflo-witness/v1')
|
const declaredPublicKey = String(witness.integrity.publicKey ?? '').toLowerCase();
|
||||||
.digest();
|
// --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
|
// For a legacy self-attested manifest, the public key is reproducible from
|
||||||
// without a package install. The witness format remains byte-compatible
|
// the commit. For an external-key manifest, only a caller-supplied pinned
|
||||||
// with manifests produced by @noble/ed25519.
|
// key can establish authenticity; without one verification fails closed.
|
||||||
const privateKey = createPrivateKey({
|
let publicKeyReproducible = false;
|
||||||
key: Buffer.concat([
|
if (pinnedKeyConfigured) {
|
||||||
Buffer.from('302e020100300506032b657004220420', 'hex'),
|
publicKeyReproducible = pinnedKeyMatches === true;
|
||||||
seed,
|
} else if (authenticity === 'self-attested') {
|
||||||
]),
|
const seed = createHash('sha256')
|
||||||
format: 'der',
|
.update(witness.manifest.gitCommit + ':ruflo-witness/v1')
|
||||||
type: 'pkcs8',
|
.digest();
|
||||||
});
|
|
||||||
const reproducedKey = createPublicKey(privateKey)
|
// RFC 8410 DER wrappers let Node/OpenSSL derive and verify Ed25519 keys
|
||||||
.export({ format: 'der', type: 'spki' })
|
// without a package install. The witness format remains byte-compatible
|
||||||
.subarray(-32);
|
// with manifests produced by @noble/ed25519.
|
||||||
const publicKeyReproducible =
|
const privateKey = createPrivateKey({
|
||||||
reproducedKey.toString('hex') === witness.integrity.publicKey;
|
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({
|
const declaredKey = createPublicKey({
|
||||||
key: Buffer.concat([
|
key: Buffer.concat([
|
||||||
@@ -193,12 +217,22 @@ function verifySignature(witness) {
|
|||||||
declaredKey,
|
declaredKey,
|
||||||
Buffer.from(witness.integrity.signature, 'hex'),
|
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) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
manifestHashOk: false,
|
manifestHashOk: false,
|
||||||
publicKeyReproducible: false,
|
publicKeyReproducible: false,
|
||||||
signatureValid: false,
|
signatureValid: false,
|
||||||
|
authenticity: witness?.integrity?.authenticity ?? 'self-attested',
|
||||||
|
pinnedKeyConfigured: Boolean(options.publicKey ?? process.env.RUFLO_WITNESS_PUBLIC_KEY),
|
||||||
verifier: 'node:crypto',
|
verifier: 'node:crypto',
|
||||||
reason: 'signature-verification-error',
|
reason: 'signature-verification-error',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: witness
|
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 [...]"
|
argument-hint: "init|regen|verify|history [...]"
|
||||||
allowed-tools: Bash(node *), Read, Write, Edit
|
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
|
The witness toolkit lets you ship every release with a *signed* manifest
|
||||||
that lists every documented fix in your codebase along with a sha256 +
|
that lists every documented fix in your codebase along with a sha256 +
|
||||||
marker substring. Anyone with the same git commit can re-derive the
|
marker substring. By default the signing seed is derived from the public
|
||||||
public key and verify the signature without a committed private key.
|
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
|
A temporal history (JSONL) tracks how the fix population evolves across
|
||||||
releases — so when a regression appears, you can pinpoint *the commit
|
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.
|
# For a CI hash tripwire, also fail when a file changes but its marker remains.
|
||||||
node plugins/ruflo-core/scripts/witness/verify.mjs \
|
node plugins/ruflo-core/scripts/witness/verify.mjs \
|
||||||
--manifest verification.md.json --source-only --strict
|
--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.
|
Without `--strict`, marker-preserving SHA drift is reported but exits 0.
|
||||||
|
|||||||
@@ -104,3 +104,4 @@ plugins/ruflo-core/scripts/command-recording-switch.test.cjs
|
|||||||
tests/regression/oct12-coverage.test.cjs
|
tests/regression/oct12-coverage.test.cjs
|
||||||
plugins/ruflo-adr/scripts/__tests__/adr-status-forms-4126.test.mjs
|
plugins/ruflo-adr/scripts/__tests__/adr-status-forms-4126.test.mjs
|
||||||
scripts/__tests__/probe-mod-guards.test.mjs
|
scripts/__tests__/probe-mod-guards.test.mjs
|
||||||
|
plugins/ruflo-core/scripts/witness/__tests__/witness-auth.test.mjs
|
||||||
Reference in new issue
Block a user