chainproof
v0.2.2
Published
Hash-chained, Ed25519-signed append-only logs for TypeScript. Tamper-evident audit trails as a reusable primitive.
Maintainers
Readme
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:
- Hash linking — each entry commits to the previous one, so deletions and reordering are detectable
- Signatures — each entry is cryptographically signed, so forgery requires the private key
- 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 chainproofQuick 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
prevHashno 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 entryVerification
// 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_FAILEDCryptographic 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
- Append-only — entries are never modified or deleted
- Externally verifiable — anyone with the public key can verify (no shared secret)
- Deterministic — canonical JSON serialization ensures consistent hashing
- Minimal — one runtime dependency (
@valencets/resultkit) - Generic —
ChainLog<T>works with any serializable payload type
Requirements
- Node.js >= 22
- TypeScript >= 5.9
- ESM only (
"type": "module")
License
MIT
