238 lines
8.4 KiB
JavaScript
238 lines
8.4 KiB
JavaScript
/**
|
|
* Proof Envelope - Cryptographic Evidence Trail
|
|
*
|
|
* Makes every run auditable and tamper-evident by producing a hash-chained,
|
|
* HMAC-signed envelope for each RunEvent. Each envelope captures:
|
|
*
|
|
* - SHA-256 content hash of the run event
|
|
* - Hash chain linking to the previous envelope (genesis = '0' x 64)
|
|
* - Individual tool call hashes
|
|
* - Memory lineage (reads/writes with value hashes)
|
|
* - HMAC-SHA256 signature over the entire envelope body
|
|
*
|
|
* @module @claude-flow/guidance/proof
|
|
*/
|
|
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
|
import { timingSafeEqual } from './crypto-utils.js';
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
const GENESIS_HASH = '0'.repeat(64);
|
|
const SERIALIZATION_VERSION = 1;
|
|
// ============================================================================
|
|
// ProofChain
|
|
// ============================================================================
|
|
/**
|
|
* A tamper-evident, hash-chained sequence of ProofEnvelopes.
|
|
*
|
|
* Each envelope links to the previous one via `previousHash`, forming
|
|
* a blockchain-like structure. Every envelope is HMAC-signed so any
|
|
* modification to the chain can be detected.
|
|
*/
|
|
export class ProofChain {
|
|
envelopes = [];
|
|
signingKey;
|
|
constructor(signingKey) {
|
|
if (!signingKey) {
|
|
throw new Error('ProofChain requires an explicit signingKey — hardcoded defaults are not secure');
|
|
}
|
|
this.signingKey = signingKey;
|
|
}
|
|
/**
|
|
* Append a new ProofEnvelope to the chain.
|
|
*
|
|
* @param runEvent - The RunEvent to wrap
|
|
* @param toolCalls - Tool call records from the run
|
|
* @param memoryOps - Memory operations from the run
|
|
* @param metadata - Optional metadata overrides
|
|
* @returns The newly created and signed ProofEnvelope
|
|
*/
|
|
append(runEvent, toolCalls = [], memoryOps = [], metadata) {
|
|
const previousHash = this.envelopes.length > 0
|
|
? this.envelopes[this.envelopes.length - 1].contentHash
|
|
: GENESIS_HASH;
|
|
const contentHash = this.computeContentHash(runEvent);
|
|
const toolCallHashes = {};
|
|
for (const call of toolCalls) {
|
|
toolCallHashes[call.callId] = this.computeToolCallHash(call);
|
|
}
|
|
const memoryLineage = memoryOps.map(op => ({
|
|
key: op.key,
|
|
namespace: op.namespace,
|
|
operation: op.operation,
|
|
hash: op.valueHash,
|
|
}));
|
|
const envelope = {
|
|
envelopeId: randomUUID(),
|
|
runEventId: runEvent.eventId,
|
|
timestamp: new Date().toISOString(),
|
|
contentHash,
|
|
previousHash,
|
|
toolCallHashes,
|
|
guidanceHash: runEvent.guidanceHash,
|
|
memoryLineage,
|
|
signature: '', // placeholder; signed below
|
|
metadata: {
|
|
agentId: metadata?.agentId ?? 'unknown',
|
|
sessionId: metadata?.sessionId ?? runEvent.sessionId ?? 'unknown',
|
|
parentEnvelopeId: metadata?.parentEnvelopeId,
|
|
},
|
|
};
|
|
envelope.signature = this.signEnvelope(envelope);
|
|
this.envelopes.push(envelope);
|
|
return envelope;
|
|
}
|
|
/**
|
|
* Verify a single envelope's HMAC signature and hash chain link.
|
|
*
|
|
* @returns true if the signature is valid and the previousHash is correct
|
|
*/
|
|
verify(envelope) {
|
|
// Verify HMAC signature
|
|
const expectedSignature = this.signEnvelope(envelope);
|
|
if (!timingSafeEqual(envelope.signature, expectedSignature)) {
|
|
return false;
|
|
}
|
|
// Verify hash chain linkage
|
|
const index = this.envelopes.findIndex(e => e.envelopeId === envelope.envelopeId);
|
|
if (index === -1) {
|
|
// Envelope not in this chain; verify signature only
|
|
return true;
|
|
}
|
|
if (index === 0) {
|
|
return envelope.previousHash === GENESIS_HASH;
|
|
}
|
|
return envelope.previousHash === this.envelopes[index - 1].contentHash;
|
|
}
|
|
/**
|
|
* Verify the entire chain from genesis to tip.
|
|
*
|
|
* Checks that every envelope:
|
|
* 1. Has a valid HMAC signature
|
|
* 2. Links correctly to the previous envelope's contentHash
|
|
*
|
|
* @returns true if the full chain is intact
|
|
*/
|
|
verifyChain() {
|
|
if (this.envelopes.length === 0) {
|
|
return true;
|
|
}
|
|
for (let i = 0; i < this.envelopes.length; i++) {
|
|
const envelope = this.envelopes[i];
|
|
// Verify signature (constant-time comparison)
|
|
const expectedSignature = this.signEnvelope(envelope);
|
|
if (!timingSafeEqual(envelope.signature, expectedSignature)) {
|
|
return false;
|
|
}
|
|
// Verify hash chain
|
|
if (i === 0) {
|
|
if (envelope.previousHash !== GENESIS_HASH) {
|
|
return false;
|
|
}
|
|
}
|
|
else {
|
|
if (envelope.previousHash !== this.envelopes[i - 1].contentHash) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Retrieve an envelope by its ID.
|
|
*/
|
|
getEnvelope(id) {
|
|
return this.envelopes.find(e => e.envelopeId === id);
|
|
}
|
|
/**
|
|
* Get the most recent envelope in the chain.
|
|
*/
|
|
getChainTip() {
|
|
return this.envelopes.length > 0
|
|
? this.envelopes[this.envelopes.length - 1]
|
|
: undefined;
|
|
}
|
|
/**
|
|
* Get the number of envelopes in the chain.
|
|
*/
|
|
getChainLength() {
|
|
return this.envelopes.length;
|
|
}
|
|
/**
|
|
* Export the chain as a serializable object.
|
|
*/
|
|
export() {
|
|
return {
|
|
envelopes: this.envelopes.map(e => ({ ...e })),
|
|
createdAt: new Date().toISOString(),
|
|
version: SERIALIZATION_VERSION,
|
|
};
|
|
}
|
|
/**
|
|
* Restore the chain from a previously exported object.
|
|
*
|
|
* Replaces the current chain contents entirely.
|
|
*/
|
|
import(data) {
|
|
if (data.version !== SERIALIZATION_VERSION) {
|
|
throw new Error(`Unsupported proof chain version: ${data.version} (expected ${SERIALIZATION_VERSION})`);
|
|
}
|
|
this.envelopes = data.envelopes.map(e => ({ ...e }));
|
|
}
|
|
// ===========================================================================
|
|
// Private helpers
|
|
// ===========================================================================
|
|
/**
|
|
* Compute the SHA-256 content hash of a RunEvent.
|
|
*/
|
|
computeContentHash(event) {
|
|
const payload = JSON.stringify(event, Object.keys(event).sort());
|
|
return createHash('sha256').update(payload).digest('hex');
|
|
}
|
|
/**
|
|
* Compute the SHA-256 hash of a single tool call.
|
|
*
|
|
* Hash = SHA-256(toolName + JSON(params) + JSON(result))
|
|
*/
|
|
computeToolCallHash(call) {
|
|
const payload = call.toolName
|
|
+ JSON.stringify(call.params)
|
|
+ JSON.stringify(call.result);
|
|
return createHash('sha256').update(payload).digest('hex');
|
|
}
|
|
/**
|
|
* Produce the HMAC-SHA256 signature for an envelope.
|
|
*
|
|
* The signature covers every field except `signature` itself.
|
|
*/
|
|
signEnvelope(envelope) {
|
|
const body = {
|
|
envelopeId: envelope.envelopeId,
|
|
runEventId: envelope.runEventId,
|
|
timestamp: envelope.timestamp,
|
|
contentHash: envelope.contentHash,
|
|
previousHash: envelope.previousHash,
|
|
toolCallHashes: envelope.toolCallHashes,
|
|
guidanceHash: envelope.guidanceHash,
|
|
memoryLineage: envelope.memoryLineage,
|
|
metadata: envelope.metadata,
|
|
};
|
|
const payload = JSON.stringify(body);
|
|
return createHmac('sha256', this.signingKey).update(payload).digest('hex');
|
|
}
|
|
}
|
|
// ============================================================================
|
|
// Factory
|
|
// ============================================================================
|
|
/**
|
|
* Create a new ProofChain instance.
|
|
*
|
|
* @param config - Configuration with a required `signingKey` for HMAC signing.
|
|
* Callers that previously relied on the optional signature must now provide
|
|
* an explicit key (see ADR-G026).
|
|
* @returns A fresh ProofChain
|
|
*/
|
|
export function createProofChain(config) {
|
|
return new ProofChain(config.signingKey);
|
|
}
|
|
//# sourceMappingURL=proof.js.map
|