AuditChain.java
package com.rbctcsworld.ecommerce.audit;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HexFormat;
import java.util.List;
/**
* The hash chain that makes the audit trail tamper-evident.
*
* hash(row) = SHA-256 of the previous row's hash and every field of this row. Each part is written as
* "length:value" (null as "-1:"), so no two different rows can produce the same text ("ab"+"c" vs "a"+"bc").
* Changing a value, deleting a row or swapping two rows changes a hash that a later row depends on.
*/
public final class AuditChain {
public static final String GENESIS = "0".repeat(64);
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS");
private AuditChain() {
}
public static String hash(String prevHash, long seq, LocalDateTime occurredAt, String actor, String entityType,
long entityId, String action, String field, String oldValue, String newValue) {
StringBuilder s = new StringBuilder();
for (String part : new String[]{prevHash, Long.toString(seq), occurredAt.format(TIME), actor, entityType,
Long.toString(entityId), action, field, oldValue, newValue}) {
s.append(part == null ? -1 : part.length()).append(':').append(part == null ? "" : part).append('|');
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(s.toString().getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is part of every JVM", e);
}
}
static String hashOf(AuditRecord r) {
return hash(r.prevHash(), r.seq(), r.occurredAt(), r.actor(), r.entityType(), r.entityId(), r.action(),
r.field(), r.oldValue(), r.newValue());
}
/** Result of checking the whole trail. brokenAtSeq = the first row that does not fit (null when valid). */
public record Verification(boolean valid, int entries, Long brokenAtSeq, String reason) {
}
/**
* Checks rows in seq order: numbering without gaps, each row linked to the one before, each hash correct,
* and the last row is the one the head points to (otherwise the newest rows were removed).
*/
public static Verification verify(List<AuditRecord> rows, long headSeq, String headHash) {
Verification chain = verify(rows);
if (!chain.valid()) return chain;
String lastHash = rows.isEmpty() ? GENESIS : rows.get(rows.size() - 1).hash();
if (rows.size() != headSeq || !lastHash.equals(headHash)) {
return new Verification(false, rows.size(), (long) rows.size() + 1, "the newest rows are missing");
}
return chain;
}
/** Checks rows in seq order: numbering without gaps, each row linked to the one before, each hash correct. */
public static Verification verify(List<AuditRecord> rows) {
String expectedPrev = GENESIS;
long expectedSeq = 1;
for (AuditRecord r : rows) {
if (r.seq() != expectedSeq) {
return new Verification(false, rows.size(), r.seq(), "row " + expectedSeq + " is missing");
}
if (!r.prevHash().equals(expectedPrev)) {
return new Verification(false, rows.size(), r.seq(), "not linked to the row before");
}
if (!hashOf(r).equals(r.hash())) {
return new Verification(false, rows.size(), r.seq(), "content does not match its hash");
}
expectedPrev = r.hash();
expectedSeq++;
}
return new Verification(true, rows.size(), null, null);
}
}