Skip to content
Open
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
91 changes: 91 additions & 0 deletions docs/audit-merkle-public-witness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Audit Log Public Witness (Merkle Root)

**Issue:** [#721](https://github.com/RevoraOrg/Revora-Backend/issues/721) β€” Audit-log integrity proofs: publish Merkle root to a public witness periodically
**Status:** Implemented

---

## Overview

`auditHashChain` provides **internal** tamper-evidence. This feature adds a
periodic job that:

1. Computes the **Merkle root of the day's** audit `row_hash` values.
2. Posts the root to a public timestamping witness (Stellar memo / mock / injectable Rekor).
3. Persists the receipt in `audit_witness_receipts` for later verification.
4. Emits `audit.witness.published` on success.

Failed publishes retry with bounded exponential backoff and raise an ALARM on
exhaustion. **Witness downtime never breaks local integrity verification.**

---

## Architecture

```
AuditIntegrityScheduler (nightly)
β”‚
β”œβ”€ verifyAuditLogIntegrity() ← local hash chain
β”‚
└─ AuditWitnessPublisher
β”‚
β”œβ”€ load day's row_hash values
β”œβ”€ computeMerkleRoot(leaves)
β”œβ”€ WitnessClient.publish(root) ← mock | stellar | custom
└─ INSERT audit_witness_receipts
```

---

## Components

| File | Role |
|------|------|
| `src/security/auditMerkle.ts` | Pure Merkle helpers (`computeMerkleRoot`, `utcDayBounds`) |
| `src/security/witnessClient.ts` | `WitnessClient` interface + `MockWitnessClient` + `StellarMemoWitnessClient` |
| `src/security/auditWitnessPublisher.ts` | Day-root + chain-head publish with retry/backoff |
| `src/security/auditIntegrityScheduler.ts` | Nightly job wires verification β†’ witness publish |
| `src/db/migrations/018_create_audit_witness_receipts.sql` | Receipt storage |

---

## Metrics

| Metric | Type | When |
|--------|------|------|
| `audit.witness.published` | counter | Root successfully published + receipt saved |
| `audit.witness.publish_errors` | counter | Retry budget exhausted (ALARM also logged) |

---

## Security assumptions

1. Only already-hashed `row_hash` values leave the system β€” no raw audit details
are posted to the public witness.
2. Stellar text memos are truncated to 28 bytes; the full root is stored in the
receipt for offline verification.
3. `STELLAR_SERVER_SECRET` (if used by an injected Horizon submitter) is never logged.
4. Publish failures are swallowed so local integrity checks always complete.

---

## Abuse / failure paths

| Scenario | Behaviour |
|----------|-----------|
| Empty day (no audit rows) | Skip publish; debug log |
| Root already published | Skip (idempotent) |
| Witness transient failure | Retry with exponential backoff |
| Witness downtime / exhausted retries | ALARM + `audit.witness.publish_errors`; local integrity unaffected |
| DB error loading day hashes | ALARM; local integrity unaffected |

---

## Testing

```bash
npx jest src/security/auditMerkle.test.ts src/security/auditWitnessPublisher.test.ts src/security/auditIntegrityScheduler.test.ts --forceExit
```

Covers Merkle construction (empty / single / odd leaf), day publish, retry,
exhaustion isolation, and Stellar dry-run receipts.
5 changes: 3 additions & 2 deletions src/security/auditIntegrityScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ export class AuditIntegrityScheduler {
headHash: result.headHash,
});

// Publish the day's Merkle root AND the chain head to the public witness.
// Errors are caught internally and never fail the scheduler / local integrity.
void this.witnessPublisher.publishDayRoot();
if (result.headHash) {
// Publish the root hash to the public witness in the background
// (Errors are caught internally and won't fail the scheduler)
void this.witnessPublisher.publishLatest(result.headHash);
}
} else {
Expand Down
61 changes: 61 additions & 0 deletions src/security/auditMerkle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Tests for auditMerkle.ts β€” day-scoped Merkle root helpers (issue #721).
*/

import { computeMerkleRoot, hashPair, utcDayBounds } from './auditMerkle';
import { createHash } from 'crypto';

describe('auditMerkle', () => {
describe('hashPair', () => {
it('is deterministic and order-sensitive', () => {
const a = hashPair('aa', 'bb');
const b = hashPair('aa', 'bb');
const c = hashPair('bb', 'aa');
expect(a).toBe(b);
expect(a).not.toBe(c);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
});

describe('computeMerkleRoot', () => {
it('returns null for an empty leaf list', () => {
expect(computeMerkleRoot([])).toBeNull();
});

it('returns the single leaf unchanged', () => {
const leaf = createHash('sha256').update('only').digest('hex');
expect(computeMerkleRoot([leaf])).toBe(leaf);
});

it('builds a two-leaf root', () => {
const left = createHash('sha256').update('L').digest('hex');
const right = createHash('sha256').update('R').digest('hex');
expect(computeMerkleRoot([left, right])).toBe(hashPair(left, right));
});

it('promotes an odd leaf unchanged', () => {
const a = createHash('sha256').update('a').digest('hex');
const b = createHash('sha256').update('b').digest('hex');
const c = createHash('sha256').update('c').digest('hex');
// Level1: hash(a,b), c β†’ root: hash(hash(a,b), c)
expect(computeMerkleRoot([a, b, c])).toBe(hashPair(hashPair(a, b), c));
});

it('is order-sensitive across the full list', () => {
const leaves = ['a', 'b', 'c', 'd'].map((x) =>
createHash('sha256').update(x).digest('hex'),
);
const root = computeMerkleRoot(leaves)!;
const reversed = computeMerkleRoot([...leaves].reverse())!;
expect(root).not.toBe(reversed);
});
});

describe('utcDayBounds', () => {
it('returns a half-open UTC day interval', () => {
const { start, end } = utcDayBounds(new Date('2026-07-31T15:30:00Z'));
expect(start.toISOString()).toBe('2026-07-31T00:00:00.000Z');
expect(end.toISOString()).toBe('2026-08-01T00:00:00.000Z');
});
});
});
54 changes: 54 additions & 0 deletions src/security/auditMerkle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* @file auditMerkle.ts
*
* @notice Pure helpers for building a Merkle root over audit log row hashes.
*
* @dev Used by `AuditWitnessPublisher` to compute the Merkle root of a single
* day's audit rows before publishing to a public witness (issue #721).
* Leaves are the existing per-row `row_hash` values from the hash chain;
* the tree is binary, left-to-right, with odd nodes promoted unchanged.
* An empty day yields `null` (nothing to witness).
*/

import { createHash } from 'crypto';

/** Hash two sibling nodes into their parent. */
export function hashPair(left: string, right: string): string {
return createHash('sha256').update(`${left}|${right}`).digest('hex');
}

/**
* Compute the Merkle root of an ordered list of leaf hashes.
*
* @param leaves Ordered leaf hashes (typically audit `row_hash` values for a day).
* @returns The root hash, or `null` when `leaves` is empty.
*/
export function computeMerkleRoot(leaves: string[]): string | null {
if (leaves.length === 0) return null;
if (leaves.length === 1) return leaves[0];

let level = [...leaves];
while (level.length > 1) {
const next: string[] = [];
for (let i = 0; i < level.length; i += 2) {
if (i + 1 < level.length) {
next.push(hashPair(level[i], level[i + 1]));
} else {
// Odd leaf: promote unchanged so the tree stays deterministic.
next.push(level[i]);
}
}
level = next;
}
return level[0];
}

/**
* UTC calendar day bounds `[start, end)` for `day`.
*/
export function utcDayBounds(day: Date): { start: Date; end: Date } {
const start = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()));
const end = new Date(start);
end.setUTCDate(end.getUTCDate() + 1);
return { start, end };
}
Loading
Loading