Skip to content

Repository files navigation

Ledger

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.


Why Ledger?

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

Module Structure

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)

ledger-core

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

Hash canonical form

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.

Global hash chain

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 previousHashLedgerVerifier.verify() throws at index 0
  • sequenceNumber is 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 change
  • global_seq (the DB-assigned BIGINT IDENTITY column) is a display-only ordering key and is not included in any hash computation

ledger-jpa

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:

  1. @PostLoad — takes a reflective clone of the entity immediately after it is read from the database and parks it in a ThreadLocal<IdentityHashMap> keyed by object reference.
  2. @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());

ledger-jdbc

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 "").

ledger-spring

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.

Anti-Tamper: Signed Checkpoints

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.

How it works

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.

New types in ledger-core

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

Usage

// 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); // signatures

Append-only database enforcement (PostgreSQL)

JdbcAuditStore.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.


ledger-checkpoint-signer

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.


ledger-demo-app

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 AuthAuditService under the synthetic entity type UserSession — 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 via AuditStore.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

ledger-audit-ui

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_seq first), paginated at 50 rows per page
  • Chain Integrity Summary shows a single global chain: OK or TAMPERED at entry index N, re-evaluated on every page load
  • Tamper: corrupts a row's diffs field 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_hash pointers to the now-missing entry, breaking the chain
  • Reset: clears the corrupted diffs and recomputes previous_hash / hash for 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_log and audit_checkpoint
  • After tampering or deletion, reloading the demo app's Orders page triggers the integrity violation banner

Running the Demo (Podman)

Prerequisites

  • Podman (brew install podman), machine initialised (podman machine init && podman machine start)
  • Java 21 SDK, Maven 3.9+ (for building the JARs)

Start

# 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

Walkthrough

  1. Log in as alice at http://localhost:8080
  2. Place a few orders — every create/update/delete is audit-trailed and checkpointed
  3. My Orders shows a green integrity banner
  4. Open the Audit UI at http://localhost:8082 — entries appear most-recent-first, paginated 50 per page
  5. Click Tamper on any row to corrupt its diffs field directly in PostgreSQL, or Delete to remove the row entirely
  6. Reload My Orders in the demo app — the banner turns red with the tamper location
  7. The Audit UI's chain summary shows TAMPERED at entry index N for the global chain
  8. Click Reset on the corrupted row in the Audit UI to repair the chain forward from that point

Reset

podman compose down -v   # drops DB data and the signer key volume

ledger-biophorum

Adapter 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.

ledger-verify (planned)

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.


Quick Start

1. Add the dependencies

<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>

2. Annotate your entities

@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;
}

3. Configure at start-up (non-Spring)

DataSource ds = …;
JdbcAuditStore.initSchema(ds);                    // create table once
LedgerEntityListener.configure(new JdbcAuditStore(ds));

4. Verify integrity on demand

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);
}

5. Verify signed checkpoint signatures

// 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());
}

6. Export to BioPhorum format

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);

Build

# 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 test

Requires Java 21 and Maven 3.9+.


Design Principles

  • 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 everywhereAuditEntry and Diff are Java records. Once written, an entry is never modified.
  • Sensitive data never leaves the process unredacted@SensitiveData fields are compared by raw value (to detect whether they changed) but only the string [REDACTED] is written to the Diff.
  • Pluggable storageAuditStore is a plain interface. Bring your own JDBC, JPA repository, Elasticsearch index, or S3 bucket.
  • Pluggable identityActorResolver is a @FunctionalInterface. Wire it to Spring Security, Shiro, Quarkus SecurityIdentity, 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.

License

Apache License, Version 2.0. See LICENSE.

Copyright 2026 Jean-Baptiste Meyer

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages