Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ KYC_PREVIOUS_KEY_VERSIONS=
# Optional JSON keyring for rotation. Values shown are placeholders; never commit real keys.
KYC_ENCRYPTION_KEYS_JSON=

# Tamper-evident audit checkpoints
AUDIT_CHECKPOINT_PRIVATE_KEY=replace_with_local_audit_checkpoint_hmac_key
AUDIT_CHECKPOINT_PREVIOUS_KEYS=
CRITICAL_AUDIT_ACTIONS=

# Privy
NEXT_PUBLIC_PRIVY_APP_ID=replace_with_privy_app_id
PRIVY_APP_ID=replace_with_privy_app_id_for_server_validation
Expand Down
63 changes: 63 additions & 0 deletions docs/tamper-evident-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Tamper-Evident Audit Logs

ChainMove records sensitive administrative and financial actions in an append-only hash chain. The default partition strategy is monthly (`YYYY-MM`) so verification and exports can be scoped without loading the full lifetime log. Each non-legacy event includes actor context, action, target, result, request ID, sanitized metadata, timestamp, sequence, previous hash, canonical data, and event hash.

## Critical Events

The compatibility writer in `lib/security/audit-log.ts` mirrors existing audit writes into the tamper-evident log. Actions containing `kyc`, `wallet`, `repayment`, `payout`, `loan`, `asset`, `investment`, `admin`, `user.role`, `notification.broadcast`, or `email.send` are treated as critical by default. Configure `CRITICAL_AUDIT_ACTIONS` as a comma-separated allowlist of critical action fragments when deployments need a stricter or narrower policy.

Critical audit failures throw `CRITICAL_AUDIT_FAILURE` and must block the business action. Failed business actions should be logged with `status: "failure"` whenever the attempt is security-relevant.

## Privacy Rules

Audit metadata is sanitized before hashing. Do not include passwords, tokens, private keys, session IDs, raw KYC documents, raw gateway payloads, unnecessary PII, or full bank/card data. Use stable identifiers, object IDs, short reason codes, amounts, currencies, status transitions, and redacted summaries instead.

Filtered exports may use `--redact-pii`. Redacted exports include `sourceEventHash` for traceability and recompute an export-local hash chain so offline verification proves the exported content has not changed.

## Checkpoints

`createCheckpoint(partition)` signs the ordered event-hash root for events not yet checkpointed. Contributor mode uses the local HMAC signer configured by `AUDIT_CHECKPOINT_PRIVATE_KEY`; production should replace the signer adapter with KMS, HSM, or external transparency storage.

Set `AUDIT_CHECKPOINT_PREVIOUS_KEYS` to a comma-separated list of retired HMAC keys during rotation. Verification accepts the active key plus previous keys. Keep retired keys until every checkpoint signed by them is outside retention and backup restore windows.

Recommended rotation:

1. Add the current key to `AUDIT_CHECKPOINT_PREVIOUS_KEYS`.
2. Deploy a new `AUDIT_CHECKPOINT_PRIVATE_KEY`.
3. Create a checkpoint and verify it with `npm run audit:verify -- --partition=YYYY-MM --checkpoints`.
4. Retire old keys only after retention and legal-hold windows expire.

## Migration

Run `npm run audit:migrate` once per environment to copy existing mutable audit logs into a `legacy-YYYY-MM` partition. Legacy events are clearly marked with `isLegacy: true`; they remain accessible but do not fabricate pre-migration proof. Do not delete the old collection unless legal, retention, and backup requirements explicitly permit it.

## Export And Verification

Export a partition:

```bash
npm run audit:export -- --partition=2026-07 --output=audit-export.json --checkpoints
```

Verify the live database:

```bash
npm run audit:verify -- --partition=2026-07 --checkpoints
```

Verify an export offline from the project checkout:

```bash
npm install
npm run audit:verify -- --file=audit-export.json
```

Verification reports the first broken chain link, sequence gaps, malformed events, hash mismatches, and checkpoint signature/root failures.

## Retention And Access

Retain tamper-evident audit logs and checkpoints for at least seven years for financial and KYC evidence unless a stricter legal hold applies. Limit read access to security, compliance, and authorized administrators. Direct database update/delete permissions for audit collections must not be granted to application operators; emergency database access must be ticketed and followed by full partition verification.

## Incident Procedure

If verification fails, stop automated deletion or archival for the affected partition, preserve database and export snapshots, record the first failing sequence and checkpoint, compare against externalized checkpoint roots where available, and open a security incident. Rebuild trust from the last valid checkpoint; do not repair audit history in place.
229 changes: 229 additions & 0 deletions lib/security/audit-checkpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import crypto from "crypto"
import dbConnect from "@/lib/dbConnect"
import AuditCheckpoint from "@/models/AuditCheckpoint"
import TamperEvidentAuditLog from "@/models/TamperEvidentAuditLog"
import { computeRootHash } from "./audit-hash"

// Simple local signer for contributor mode
// In production, this should be replaced with a proper HSM or KMS integration
class LocalCheckpointSigner {
private privateKey: string
private previousKeys: string[]

constructor() {
// In production, this should be loaded from secure environment variable or key management service
this.privateKey = process.env.AUDIT_CHECKPOINT_PRIVATE_KEY || this.generateKeyPair()
this.previousKeys = (process.env.AUDIT_CHECKPOINT_PREVIOUS_KEYS || "")
.split(",")
.map((key) => key.trim())
.filter(Boolean)
}

private generateKeyPair(): string {
// Generate a simple signing key for development
return crypto.randomBytes(32).toString("hex")
}

sign(data: string): string {
const hmac = crypto.createHmac("sha256", this.privateKey)
hmac.update(data)
return hmac.digest("hex")
}

verify(data: string, signature: string): boolean {
return [this.privateKey, ...this.previousKeys].some((key) => {
const hmac = crypto.createHmac("sha256", key)
hmac.update(data)
const expectedSignature = hmac.digest("hex")
const actual = Buffer.from(signature, "hex")
const expected = Buffer.from(expectedSignature, "hex")
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
})
}

getKeyIdentifier(): string {
// Return a hash of the public portion or key ID
return crypto.createHash("sha256").update(this.privateKey).digest("hex").slice(0, 16)
}
}

const localSigner = new LocalCheckpointSigner()

export interface CheckpointConfig {
partition: string
eventsPerCheckpoint?: number
autoCheckpoint?: boolean
}

/**
* Create a checkpoint for a partition
*/
export async function createCheckpoint(partition: string): Promise<{
success: boolean
checkpoint?: {
checkpointNumber: number
eventCount: number
rootHash: string
}
error?: string
}> {
try {
await dbConnect()

// Get the last checkpoint for this partition
const lastCheckpoint = await AuditCheckpoint.findOne({ partition }).sort({ checkpointNumber: -1 })
const nextCheckpointNumber = lastCheckpoint ? lastCheckpoint.checkpointNumber + 1 : 1
const startSequence = lastCheckpoint ? lastCheckpoint.endSequence + 1 : 0

// Get all events since the last checkpoint
const events = await TamperEvidentAuditLog.find({
partition,
sequence: { $gte: startSequence },
isLegacy: false,
}).sort({ sequence: 1 })

if (events.length === 0) {
return {
success: false,
error: "No new events to checkpoint",
}
}

const endSequence = events[events.length - 1].sequence
const startEventHash = events[0].eventHash
const endEventHash = events[events.length - 1].eventHash
const eventHashes = events.map((e) => e.eventHash)
const rootHash = computeRootHash(eventHashes)

// Sign the checkpoint
const signatureData = JSON.stringify({
partition,
checkpointNumber: nextCheckpointNumber,
startSequence,
endSequence,
rootHash,
})
const signature = localSigner.sign(signatureData)
const signedBy = localSigner.getKeyIdentifier()

// Create the checkpoint
const checkpoint = await AuditCheckpoint.create({
partition,
checkpointNumber: nextCheckpointNumber,
startSequence,
endSequence,
startEventHash,
endEventHash,
rootHash,
signature,
signedBy,
signedAt: new Date(),
eventCount: events.length,
})

return {
success: true,
checkpoint: {
checkpointNumber: checkpoint.checkpointNumber,
eventCount: checkpoint.eventCount,
rootHash: checkpoint.rootHash,
},
}
} catch (error) {
console.error("CHECKPOINT_CREATE_ERROR", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}

/**
* Verify a checkpoint signature
*/
export async function verifyCheckpointSignature(checkpointId: string): Promise<{
valid: boolean
error?: string
}> {
try {
await dbConnect()

const checkpoint = await AuditCheckpoint.findById(checkpointId)
if (!checkpoint) {
return { valid: false, error: "Checkpoint not found" }
}

const signatureData = JSON.stringify({
partition: checkpoint.partition,
checkpointNumber: checkpoint.checkpointNumber,
startSequence: checkpoint.startSequence,
endSequence: checkpoint.endSequence,
rootHash: checkpoint.rootHash,
})

const valid = localSigner.verify(signatureData, checkpoint.signature)

return { valid }
} catch (error) {
console.error("CHECKPOINT_VERIFY_ERROR", error)
return {
valid: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}

/**
* Get checkpoint statistics for a partition
*/
export async function getCheckpointStats(partition: string): Promise<{
totalCheckpoints: number
lastCheckpoint?: {
number: number
eventCount: number
signedAt: Date
}
totalEventsCheckpointed: number
}> {
await dbConnect()

const checkpoints = await AuditCheckpoint.find({ partition }).sort({ checkpointNumber: -1 })

const totalCheckpoints = checkpoints.length
const lastCheckpoint = checkpoints[0]
const totalEventsCheckpointed = checkpoints.reduce((sum, cp) => sum + cp.eventCount, 0)

return {
totalCheckpoints,
lastCheckpoint: lastCheckpoint
? {
number: lastCheckpoint.checkpointNumber,
eventCount: lastCheckpoint.eventCount,
signedAt: lastCheckpoint.signedAt,
}
: undefined,
totalEventsCheckpointed,
}
}

/**
* Auto-checkpoint if threshold is met
*/
export async function autoCheckpointIfNeeded(partition: string, threshold: number = 1000): Promise<void> {
await dbConnect()

const lastCheckpoint = await AuditCheckpoint.findOne({ partition }).sort({ checkpointNumber: -1 })
const startSequence = lastCheckpoint ? lastCheckpoint.endSequence + 1 : 0

const eventCount = await TamperEvidentAuditLog.countDocuments({
partition,
sequence: { $gte: startSequence },
isLegacy: false,
})

if (eventCount >= threshold) {
await createCheckpoint(partition)
}
}

export { localSigner }
Loading
Loading