A Java 21 library that brings tamper-evident, cryptographically linked audit logging to JPA and JDBC applications.
Every change to an @Audited entity is recorded as an AuditEntry whose SHA-256 hash includes the hash of the previous entry — forming a chain where silent, after-the-fact modification of any record is mathematically detectable.
Compliance frameworks (SOC 2, GDPR, HIPAA, ISO 27001) and pharmaceutical industry standards (BioPhorum Plug-and-play Audit Trail) require audit trails that prove no one — not even a DBA with direct database access — silently altered historical records. Traditional audit tables solve the what-changed problem but not the did-anyone-alter-the-audit-itself problem.
Ledger solves both:
| Guarantee | How |
|---|---|
| What changed, field by field | DiffEngine compares entity snapshots via reflection; @SensitiveData fields are replaced with [REDACTED] |
| Who changed it | Pluggable ActorResolver (default: ThreadLocal-based, integrates with any security context) |
| When it changed | Instant timestamp embedded in every entry |
| No silent tampering of past entries | SHA-256 hash chain — altering any entry invalidates every subsequent entry's previousHash |
| No silent deletion of entries | LedgerVerifier.gaps() detects missing sequence numbers |
| Total-rewrite attack protection | Ed25519-signed checkpoints anchored by a separate signing service — rewriting all rows still requires forging a signature whose private key lives on a different host |
| BioPhorum-compliant export | ledger-biophorum adapter expands each entry into per-field BioPhorum events with pharma-domain context |
ledger/ ← Maven parent (BOM, compiler, Surefire)
├── ledger-core ← Domain model, hash chain, diff engine, checkpoint signing — zero framework deps
├── ledger-jpa ← Hibernate EntityListener (no Spring dependency)
├── ledger-jdbc ← JDBC AuditStore + CheckpointStore, writes to PostgreSQL
├── ledger-spring ← Spring Boot autoconfiguration + Actuator endpoint
├── ledger-biophorum ← BioPhorum plug-and-play audit trail adapter
├── ledger-verify ← Standalone CLI and integrity-check API (planned)
├── ledger-checkpoint-signer ← Standalone Ed25519 signing service (runs in its own container)
├── ledger-demo-app ← Spring Boot e-commerce demo app showing the full audit trail
├── ledger-audit-ui ← Web UI for browsing the audit trail and simulating tampering
└── infra/ ← Podman Compose stack (Postgres + 3 app containers)
The heart of the library. No runtime dependency on any framework.
| Type | Role |
|---|---|
AuditEntry |
Immutable record: id, entityType, entityId, eventType, actor, timestamp, sequenceNumber, diffs, previousHash, hash |
Diff |
Immutable record: field, oldValue, newValue |
EventType |
Enum: CREATED, UPDATED, DELETED, LOGIN, LOGOUT, SESSION_EXPIRED |
@Audited |
Type-level annotation — declares which events to capture and an optional literal actor |
@SensitiveData |
Field-level annotation — value is replaced by [REDACTED] in every diff |
DiffEngine |
Compares two object snapshots via reflection; returns only the fields that changed |
HashChain |
static String compute(AuditEntry) — SHA-256 over all fields except hash itself |
AuditStore |
Interface: save, findByEntityId, findAll (global insertion order), findLastHash, countAll |
LedgerVerifier |
verify(List<AuditEntry>) — checks genesis (previousHash == null on first entry), then walks the chain; throws TamperDetectedException(index) on the first broken link; gaps(List<AuditEntry>) — returns missing sequence numbers |
TamperDetectedException |
Checked exception carrying the zero-based index of the broken entry |
SHA-256( id | entityType | entityId | eventType | actor | timestamp | sequenceNumber | diffs | previousHash )
Diffs are serialised as comma-separated field:oldValue->newValue triples.
The genesis entry — the very first entry ever written across all entities — has previousHash = null, represented as an empty string in the canonical form.
All audit entries across all entity types form a single global chain.
Each new entry's previousHash is set to the hash of the globally last inserted entry, regardless of entity type.
This means:
- Inserting a new order, logging in, or updating any entity all extend the same chain
- Deleting the genesis entry is detected because the new first entry will have a non-null
previousHash—LedgerVerifier.verify()throws at index 0 sequenceNumberis still a per-entity counter (0, 1, 2 … within each entity's own history) and remains part of the hash canonical form — it must not changeglobal_seq(the DB-assignedBIGINT IDENTITYcolumn) is a display-only ordering key and is not included in any hash computation
Hibernate entity listener — no Spring context required.
@Entity
@Audited // which events to capture
@EntityListeners(LedgerEntityListener.class) // wire the listener
public class Invoice { … }Before-snapshot mechanism for updates
@PreUpdate fires after the entity's fields are already set to their new values.
LedgerEntityListener solves this in two steps:
@PostLoad— takes a reflective clone of the entity immediately after it is read from the database and parks it in aThreadLocal<IdentityHashMap>keyed by object reference.@PreUpdate— retrieves the clone as the before snapshot, diffs it against the current entity, then drops the entry from the map.
If no snapshot exists (entity was constructed in the same session and never loaded from the database) the update is silently skipped — it was already covered by @PrePersist.
Actor resolution is delegated to ActorResolver (a @FunctionalInterface).
The default implementation, ThreadLocalActorResolver, reads a value you bind per-request:
// in a servlet filter or Spring HandlerInterceptor:
ThreadLocalActorResolver.set(SecurityContext.currentUser());
try {
chain.doFilter(…);
} finally {
ThreadLocalActorResolver.clear();
}One-time configuration at application start-up:
LedgerEntityListener.configure(myAuditStore);
// or with a custom resolver:
LedgerEntityListener.configure(myAuditStore, () -> SecurityContext.currentUser());Plain JDBC AuditStore — no ORM required, suitable for any application that manages its own DataSource.
DataSource ds = …; // any JDBC DataSource
JdbcAuditStore.initSchema(ds); // create audit_log table (once, at start-up)
AuditStore store = new JdbcAuditStore(ds);Schema
CREATE TABLE audit_log (
id VARCHAR(36) NOT NULL PRIMARY KEY,
global_seq BIGINT GENERATED BY DEFAULT AS IDENTITY,
entity_type VARCHAR(255) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
event_type VARCHAR(20) NOT NULL,
actor VARCHAR(255) NOT NULL,
timestamp VARCHAR(30) NOT NULL,
sequence_number INTEGER NOT NULL,
diffs TEXT,
previous_hash VARCHAR(64),
hash VARCHAR(64) NOT NULL
);global_seq is auto-assigned by the database on every INSERT in strict insertion order.
It is used for display ordering and for computing previousHash links in the global chain.
It is never included in the hash canonical form.
diffs are stored as a single TEXT column using a backslash-escape encoding — no JSON library required.
null field values are encoded as \0 (distinct from empty string "").
Spring Boot autoconfiguration — drop the jar on the classpath and everything wires itself.
ledger:
enabled: true # default; set to false to suppress all Ledger beans
management:
endpoint:
audit:
enabled: true # exposes GET /actuator/audit/{entityType}/{entityId}What is auto-configured:
| Bean | Condition | Purpose |
|---|---|---|
SpringSecurityActorResolver |
Spring Security on classpath | Reads actor from SecurityContextHolder |
ThreadLocalActorResolver |
No Spring Security | Falls back to per-thread binding |
LedgerConfigurer |
Always | Calls LedgerEntityListener.configure(…) once the context is ready |
LedgerAuditEndpoint |
Actuator present + endpoint enabled | GET /actuator/audit/{entityType}/{entityId} |
Any bean can be overridden by providing your own implementation — all beans are @ConditionalOnMissingBean.
A pure SHA-256 hash chain proves internal consistency but is vulnerable to a total-rewrite attack: an attacker with full database access can delete all rows, reinsert them with altered data, and recompute valid hashes from the genesis entry. The checkpoint layer addresses this by anchoring the chain to an Ed25519 signature whose private key lives on a separate host.
After every N audit operations a checkpoint is created:
Checkpoint = Sign(privateKey, entryCount | headHash | timestamp)
The signed CheckpointEntry is stored in audit_checkpoint.
Verification reads the stored entries, verifies the hash chain, then verifies every checkpoint signature against the signer's public key.
Rewriting history requires forging an Ed25519 signature — computationally infeasible without the private key.
| Type | Role |
|---|---|
CheckpointEntry |
Immutable record: id, entryCount, headHash, timestamp, signature, publicKeyHex |
CheckpointSigner |
Interface: sign(entryCount, headHash, timestamp) → CheckpointEntry |
Ed25519CheckpointSigner |
Impl using java.security.Signature("Ed25519") — no external crypto library needed |
CheckpointStore |
Interface: save(CheckpointEntry) and findAll() |
LedgerVerifier.verifyCheckpoints |
Verifies chain position + Ed25519 signature for each checkpoint |
LedgerException |
Unchecked wrapper for cryptographic failures |
// Generate a key pair once — store the private key on a separate host
KeyPair keyPair = Ed25519CheckpointSigner.generateKeyPair();
CheckpointSigner signer = Ed25519CheckpointSigner.of(keyPair);
// After every batch of writes:
List<AuditEntry> entries = auditStore.findByEntityId("Invoice", "inv-42");
AuditEntry head = entries.get(entries.size() - 1);
CheckpointEntry cp = signer.sign(entries.size(), head.hash(), Instant.now());
checkpointStore.save(cp);
// Verification (public key only — private key not needed):
PublicKey pub = keyPair.getPublic();
LedgerVerifier verifier = new LedgerVerifier();
verifier.verify(entries); // hash chain
verifier.verifyCheckpoints(entries, checkpointStore.findAll(), pub); // signaturesJdbcAuditStore.initSchema() creates the tables. For production, add these triggers to prevent UPDATE/DELETE at the engine level (see schema.sql for the full DDL):
-- Application user: INSERT only
GRANT INSERT ON audit_log, audit_checkpoint TO ledger_writer;
-- Engine-level immutability triggers (plpgsql)
CREATE TRIGGER trg_audit_log_no_update BEFORE UPDATE ON audit_log FOR EACH ROW EXECUTE FUNCTION audit_immutable();
CREATE TRIGGER trg_audit_log_no_delete BEFORE DELETE ON audit_log FOR EACH ROW EXECUTE FUNCTION audit_immutable();Key separation is mandatory. If the private key resides on the same host as
audit_log, a root-level attacker can sign a rewritten chain. The checkpoint signer must run as an independent, hardened service.
Standalone Spring Boot REST service that holds the Ed25519 private key and signs checkpoint requests. Intended to run in its own container with its key directory mounted from a dedicated volume.
| Endpoint | Description |
|---|---|
POST /checkpoint/sign |
Body: {entryCount, headHash, timestamp} → returns a CheckpointEntry JSON |
GET /checkpoint/public-key |
Returns {publicKeyHex} for use by the verification side |
On first startup the service generates a key pair and persists it under signer.key-dir (default /data). On subsequent startups the existing key is reloaded — delete the volume to force regeneration.
Spring Boot e-commerce application demonstrating the full audit trail in a realistic scenario.
Features:
- Login / logout with users defined in
users.yml(no database user table) - Product catalogue (configured in
application.yml) - Order lifecycle: PLACED → PROCESSING → SHIPPED or CANCELLED, plus hard delete
- Every JPA operation is automatically audit-trailed via
@Audited+LedgerEntityListener - Login / logout / session-expiry events are written directly to the audit store by
AuthAuditServiceunder the synthetic entity typeUserSession— they participate in the same global hash chain as order events - After each write the app signs a checkpoint via
ledger-checkpoint-signer - The My Orders page re-runs
LedgerVerifier.verify()over the full global chain on every load — shows a green banner when the trail is intact and a red "AUDIT TRAIL INTEGRITY VIOLATION DETECTED" banner with detail when not AuditVerificationService.verifyGlobal()loads every entry viaAuditStore.findAll()(global insertion order) and runs a single chain verification — any modification, deletion, or reordering across any entity is detected
Demo users (defined in ledger-demo-app/src/main/resources/users.yml):
| Username | Password |
|---|---|
| alice | alice123 |
| bob | bob123 |
| charlie | charlie123 |
Web-based audit trail browser that connects directly to the same PostgreSQL database.
Table columns
| Column | Description |
|---|---|
# |
global_seq — globally auto-incrementing integer, never restarts between entities |
| Entity | Entity type badge + abbreviated entity ID |
| Event | Colour-coded badge: green CREATED, blue UPDATED, red DELETED, cyan LOGIN, yellow LOGOUT, grey SESSION_EXPIRED |
| Actor | Who triggered the change |
| Timestamp | UTC, trimmed to the second |
| Field | Changed field name(s), or — for auth events |
| Old Value | Value before the change, or — |
| New Value | Value after the change, or ⚠ tampered if the row is corrupted |
| Hash / Prev Hash | First 12 characters of each SHA-256 hash (full value on hover via title attribute) |
| Actions | Tamper + Delete buttons side by side; Reset replaces Tamper on a corrupted row |
Features
- Entries are shown most-recent first (highest
global_seqfirst), paginated at 50 rows per page - Chain Integrity Summary shows a single
global chain: OKorTAMPERED at entry index N, re-evaluated on every page load - Tamper: corrupts a row's
diffsfield directly in the database — simulates a rogue DBA edit; the stored hash no longer matches the content - Delete: permanently removes a row; subsequent entries retain their
previous_hashpointers to the now-missing entry, breaking the chain - Reset: clears the corrupted
diffsand recomputesprevious_hash/hashfor every entry that follows in global insertion order, repairing the chain - Purge all: password-protected form (hidden under Database reset) that deletes all rows from
audit_logandaudit_checkpoint - After tampering or deletion, reloading the demo app's Orders page triggers the integrity violation banner
- Podman (
brew install podman), machine initialised (podman machine init && podman machine start) - Java 21 SDK, Maven 3.9+ (for building the JARs)
# Build all JARs first (from the repository root)
mvn clean package -DskipTests
# Launch the full stack
cd infra
podman compose up --build| URL | Service |
|---|---|
| http://localhost:8080 | Demo e-commerce app |
| http://localhost:8081 | Checkpoint signer (internal; no browser UI) |
| http://localhost:8082 | Audit trail browser |
- Log in as alice at http://localhost:8080
- Place a few orders — every create/update/delete is audit-trailed and checkpointed
- My Orders shows a green integrity banner
- Open the Audit UI at http://localhost:8082 — entries appear most-recent-first, paginated 50 per page
- Click Tamper on any row to corrupt its
diffsfield directly in PostgreSQL, or Delete to remove the row entirely - Reload My Orders in the demo app — the banner turns red with the tamper location
- The Audit UI's chain summary shows
TAMPERED at entry index Nfor the global chain - Click Reset on the corrupted row in the Audit UI to repair the chain forward from that point
podman compose down -v # drops DB data and the signer key volumeAdapter that converts AuditEntry records into BioPhorum Plug-and-play Audit Trail events without altering the underlying hash chain.
Why an adapter, not a format change?
The BioPhorum data model (OPC UA–based) defines one event per changed parameter (a single OldValue/NewValue pair), while Ledger records one entry per entity operation with a List<Diff>. Merging the two would destroy multi-field audit granularity and break the hash chain. The adapter expands each AuditEntry with N diffs into N BiophorumAuditEvent records, keeping the chain intact and the BioPhorum format correct.
BiophorumContext ctx = new BiophorumContext(
"Site-A", // location
"PO-2026-001", // productionOrder
"BATCH-42", // batchId
"Filling", // phase
"Step-3", // step
"kg", // unit
"Operator request",// reason
500, // severity (1–1000 per OPC UA)
"GMP", // criticality
"ERP-System" // agent
);
BiophorumConverter converter = new BiophorumConverter();
// one AuditEntry with 3 diffs → 3 BiophorumAuditEvents
List<BiophorumAuditEvent> events = converter.convert(auditEntry, ctx);
// full chain → flat ordered list of events
List<BiophorumAuditEvent> all = converter.convertAll(auditStore.findByEntityId("Batch", "42"), ctx);Each BiophorumAuditEvent carries both the standard BioPhorum fields (eventId, sourceName, time, message, severity, operator, oldValue, newValue, …) and two Ledger-specific traceability fields (ledgerEntryId, sequenceNumber) that link the event back to the hash chain.
Standalone command-line tool and embeddable API for verifying the integrity of a stored audit log, detecting tampering, and reporting gaps — independently of the application that wrote the entries.
<dependency>
<groupId>io.ledger</groupId>
<artifactId>ledger-core</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- if you use JPA/Hibernate -->
<dependency>
<groupId>io.ledger</groupId>
<artifactId>ledger-jpa</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- plain JDBC storage -->
<dependency>
<groupId>io.ledger</groupId>
<artifactId>ledger-jdbc</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- Spring Boot autoconfiguration (optional) -->
<dependency>
<groupId>io.ledger</groupId>
<artifactId>ledger-spring</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<!-- BioPhorum export (optional) -->
<dependency>
<groupId>io.ledger</groupId>
<artifactId>ledger-biophorum</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>@Entity
@Audited(events = {EventType.CREATED, EventType.UPDATED, EventType.DELETED})
@EntityListeners(LedgerEntityListener.class)
public class Invoice {
@Id
private String id;
private String customerName;
private BigDecimal amount;
@SensitiveData // never appears in diffs
private String vatNumber;
}DataSource ds = …;
JdbcAuditStore.initSchema(ds); // create table once
LedgerEntityListener.configure(new JdbcAuditStore(ds));LedgerVerifier verifier = new LedgerVerifier();
List<AuditEntry> history = auditStore.findByEntityId("Invoice", "inv-42");
try {
verifier.verify(history); // throws if any hash is broken
} catch (TamperDetectedException e) {
System.err.println("Tampered entry at index " + e.getIndex());
}
List<Integer> missing = verifier.gaps(history);
if (!missing.isEmpty()) {
System.err.println("Missing sequence numbers: " + missing);
}// Public key comes from the checkpoint-signer service; private key is never needed here
PublicKey pub = KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(HexFormat.of().parseHex(publicKeyHex)));
try {
verifier.verifyCheckpoints(history, checkpointStore.findAll(), pub);
} catch (TamperDetectedException e) {
System.err.println("Checkpoint signature invalid at index " + e.getIndex());
}BiophorumContext ctx = new BiophorumContext(
"Site-A", "PO-2026-001", "BATCH-42", "Filling", "Step-3",
"kg", "Operator request", 500, "GMP", "ERP-System");
List<BiophorumAuditEvent> events =
new BiophorumConverter().convertAll(history, ctx);# full build with tests
mvn clean install
# single module
mvn -pl ledger-core test
mvn -pl ledger-jdbc test
mvn -pl ledger-jpa test # unit + Hibernate integration tests
# Spring module (no integration profile needed)
mvn -pl ledger-spring testRequires Java 21 and Maven 3.9+.
- Zero framework coupling in ledger-core — the domain model and hash chain have no dependency on Hibernate, Spring, or any other framework. They can be tested and used in any Java 21 project.
- Immutability everywhere —
AuditEntryandDiffare Java records. Once written, an entry is never modified. - Sensitive data never leaves the process unredacted —
@SensitiveDatafields are compared by raw value (to detect whether they changed) but only the string[REDACTED]is written to theDiff. - Pluggable storage —
AuditStoreis a plain interface. Bring your own JDBC, JPA repository, Elasticsearch index, or S3 bucket. - Pluggable identity —
ActorResolveris a@FunctionalInterface. Wire it to Spring Security, Shiro, QuarkusSecurityIdentity, or any other principal source. - No Lombok, no code generation — Java 21 records and sealed types are expressive enough.
- BioPhorum compliance without format compromise — the adapter pattern preserves the hash chain's multi-diff granularity while producing valid BioPhorum per-parameter events at export time.
Apache License, Version 2.0. See LICENSE.
Copyright 2026 Jean-Baptiste Meyer