Skip to content

forrestblade/chainproof

Repository files navigation

chainproof

Hash-chained, Ed25519-signed append-only logs for TypeScript. Tamper-evident audit trails as a reusable primitive.

Why chainproof?

Any system that needs a provable, tamper-evident history of events — audit logs, receipt chains, compliance trails, change tracking — needs three things:

  1. Hash linking — each entry commits to the previous one, so deletions and reordering are detectable
  2. Signatures — each entry is cryptographically signed, so forgery requires the private key
  3. Verification — anyone with the public key can verify the entire chain without trusting the writer

chainproof gives you all three in a single library. No database required — just append entries and verify.

Install

npm install chainproof

Quick Start

import { ChainLog, generateKeyPair } from 'chainproof'

// Generate Ed25519 key pair
const keyPair = generateKeyPair().unwrap()

// Create a chain and append entries
const chain = new ChainLog(keyPair)

chain.append({ tool: 'Edit', file: 'server.ts', user: 'alice' })
chain.append({ tool: 'Bash', command: 'pnpm test', user: 'alice' })
chain.append({ tool: 'Deploy', target: 'production', user: 'bob' })

// Verify the entire chain
const result = chain.verifyIntegrity()
console.log(result)
// { valid: true, chainLength: 3, lastValidSeq: 2, message: 'Chain verified: 3 entries' }

// Tamper with an entry — verification catches it
const tampered = chain.entries.map((e, i) =>
  i === 0 ? { ...e, data: { tool: 'FORGED', file: 'evil.ts', user: 'alice' } } : e
)
const check = ChainLog.verify(tampered, keyPair.publicKey)
console.log(check.valid)   // false
console.log(check.message) // 'Seq 0: invalid signature'

How It Works

Each entry in the chain contains:

Field Description
id UUIDv7 (time-ordered, RFC 9562)
seq Monotonic sequence number
timestamp Unix timestamp
data Your payload (generic T)
dataHash SHA-256 of canonical JSON of data
prevHash SHA-256 of previous entry's canonical bytes
signature Ed25519 signature (base64)

Genesis: The first entry links to SHA-256("chainproof:genesis").

Canonical form: Entries are serialized with sorted keys and the signature field excluded before hashing and signing. This ensures deterministic verification regardless of JSON key ordering.

Tamper detection:

  • Modify any field → signature verification fails
  • Delete an entry → next entry's prevHash no longer matches
  • Reorder entries → hash chain breaks
  • Forge an entry → requires the private key

Usage

Chain Operations

import { ChainLog, generateKeyPair } from 'chainproof'

const keyPair = generateKeyPair().unwrap()
const chain = new ChainLog<{ action: string; user: string }>(keyPair)

// Append returns Result<ChainEntry<T>, ChainError>
const entry = chain.append({ action: 'create', user: 'alice' })
if (entry.isOk()) {
  console.log(entry.value.id)        // '019d1263-...'
  console.log(entry.value.seq)       // 0
  console.log(entry.value.signature) // 'base64...'
}

// Access entries
console.log(chain.length)    // 1
console.log(chain.entries)   // readonly ChainEntry<T>[]
console.log(chain.lastHash)  // SHA-256 of last entry

Verification

// Verify using the chain's own key pair
const result = chain.verifyIntegrity()

// Or verify externally with just the public key
const result = ChainLog.verify(entries, publicKey)

// Result shape
// { valid: boolean, chainLength: number, lastValidSeq: number, message: string }

JSONL Serialization

// Serialize to JSONL (one JSON entry per line)
const jsonl = chain.toJsonl()

// Restore from JSONL
const restored = ChainLog.fromJsonl(jsonl, keyPair)
if (restored.isOk()) {
  console.log(restored.value.length) // same as original
}

File Storage

import { appendToFile, readFromFile, saveChainToFile, loadChainFromFile } from 'chainproof'

// Append entries one at a time (ideal for live logging)
const entry = chain.append({ action: 'deploy' }).unwrap()
await appendToFile('/var/log/audit.jsonl', entry)

// Read entries from file
const entries = await readFromFile('/var/log/audit.jsonl')

// Save/load entire chain
await saveChainToFile('/var/log/audit.jsonl', chain)
const loaded = await loadChainFromFile('/var/log/audit.jsonl', keyPair)

Key Management

import {
  generateKeyPair,
  saveKeyPair,
  loadKeyPair,
  exportPublicKey,
  importPublicKey
} from 'chainproof'

// Generate and save keys (private key gets 0o600 permissions)
const keyPair = generateKeyPair().unwrap()
await saveKeyPair(keyPair, '/etc/myapp/keys')
// Creates: /etc/myapp/keys/chain.key (private, 0o600)
//          /etc/myapp/keys/chain.pub (public, 0o644)

// Load keys from disk
const loaded = await loadKeyPair('/etc/myapp/keys')

// Export public key for external verifiers
const pem = exportPublicKey(keyPair.publicKey).unwrap()
// Share this PEM — anyone can verify the chain without the private key

// Import a public key for verification
const pubKey = importPublicKey(pem).unwrap()
const result = ChainLog.verify(entries, pubKey)

Low-Level API

import { sha256, sign, verify, canonicalJson, createEntry, entryHash, genesisHash, uuid7 } from 'chainproof'

// SHA-256 hashing
sha256('hello') // '2cf24dba...'

// Canonical JSON (sorted keys, deterministic)
canonicalJson({ z: 1, a: 2 }) // '{"a":2,"z":1}'

// Ed25519 sign/verify
const sig = sign(keyPair.privateKey, 'data').unwrap()
const valid = verify(keyPair.publicKey, 'data', sig).unwrap() // true

// UUIDv7 (time-ordered)
uuid7() // '019d1263-7a2b-7c58-...'

// Manual entry creation
const entry = createEntry({ action: 'test' }, 0, genesisHash(), keyPair)
const hash = entryHash(entry.unwrap())

Error Handling

All fallible operations return Result<T, ChainError> or ResultAsync<T, ChainError> from @valencets/resultkit.

interface ChainError {
  readonly code: ChainErrorCode
  readonly message: string
}

// Error codes: INVALID_KEY, SIGN_FAILED, VERIFY_FAILED, CHAIN_BROKEN, IO_FAILED, PARSE_FAILED

Cryptographic Primitives

Primitive Purpose Implementation
SHA-256 Hash chain links, data hashing node:crypto (built-in)
Ed25519 Entry signing and verification node:crypto (built-in)
UUIDv7 Time-ordered entry IDs Custom (RFC 9562)
Base64 Signature encoding Built-in

No native addons. No npm crypto dependencies. Just node:crypto.

Design Principles

  1. Append-only — entries are never modified or deleted
  2. Externally verifiable — anyone with the public key can verify (no shared secret)
  3. Deterministic — canonical JSON serialization ensures consistent hashing
  4. Minimal — one runtime dependency (@valencets/resultkit)
  5. GenericChainLog<T> works with any serializable payload type

Requirements

  • Node.js >= 22
  • TypeScript >= 5.9
  • ESM only ("type": "module")

License

MIT

About

Hash-chained, Ed25519-signed append-only logs for TypeScript. Tamper-evident audit trails as a reusable primitive.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors