feat(audit): SHA-256 hash chain over the central AuditLog (ScadaBridge#41)

Audit roadmap T1 (alog.md §11.4): one chain per UTC calendar month of
OccurredAtUtc. Each chained row carries ChainSeq (gap-free within its month)
and RowHash = SHA-256(prev.RowHash || canonical(row)); the v1 canonical form
(AuditChainHasher) is pinned by a known-answer vector computed independently.

- Migration AddAuditLogHashChain: nullable ChainSeq/RowHash on dbo.AuditLog
  (metadata-only add), AuditLogChainHead (per-month chain end + partition-purge
  checkpoint anchor), AuditLogChainTombstone, two partition-aligned filtered
  indexes, scadabridge_audit_chainer role with a column-level UPDATE grant.
  Production script docs/plans/sql/AddAuditLogHashChain.sql.
- AuditLogHashChain chains unchained rows oldest month first under an
  exclusive sp_getapplock (zero timeout, so a dual-active central pair still
  yields one chain), with an optimistic head check and a ChainSeq IS NULL
  row-count check that roll a pass back instead of leaving a gap. The ingest
  path is untouched.
- Purges keep the chain verifiable: the partition switch records the month's
  checkpoint anchor and drops its tombstones; the per-channel DELETE writes a
  tombstone per chained row in the same transaction (OUTPUT INTO). Both, and
  the SourceNode backfill, hold the chain lock; the backfill skips chained
  rows. The switch staging table gains the two new columns.
- AuditChainVerifier reports the first break (partition, ChainSeq, EventId,
  kind) for edited, deleted, reordered rows and tail truncation.
- AuditLogHashChainService (central, default OFF via AuditLog:HashChain).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:28:43 -05:00
parent 3cf97f6dda
commit fd4be80913
24 changed files with 4596 additions and 6 deletions
@@ -0,0 +1,244 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Storage-free tests for the AuditLog hash-chain hasher + verifier (ScadaBridge#41):
/// tamper detection for an edited, a deleted and a reordered row, tombstone and
/// partition-anchor bridging, and a pinned known-answer vector for the v1 canonical form.
/// </summary>
public class AuditChainVerifierTests
{
private static readonly DateTime Month = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>An untouched chain verifies intact and covers every row.</summary>
[Fact]
public void IntactChain_Verifies()
{
var (rows, head) = BuildChain(5);
var result = Verify(rows, head);
Assert.Equal(AuditChainStatus.Intact, result.Status);
Assert.Equal(5, result.RowsVerified);
Assert.Null(result.FirstBreak);
}
/// <summary>Editing any stored column of a chained row is a HashMismatch at that row.</summary>
[Fact]
public void EditedRow_IsHashMismatch_AtThatRow()
{
var (rows, head) = BuildChain(5);
rows[2] = rows[2] with { Target = "tampered" };
var result = Verify(rows, head);
Assert.Equal(AuditChainStatus.Broken, result.Status);
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
Assert.Equal(3, result.FirstBreak.ChainSeq);
Assert.Equal(rows[2].EventId, result.FirstBreak.EventId);
Assert.Equal("2026-05", result.FirstBreak.Partition);
Assert.Equal(2, result.RowsVerified);
}
/// <summary>A row deleted from the middle (no tombstone) is a MissingRow at its sequence number.</summary>
[Fact]
public void DeletedMiddleRow_IsMissingRow()
{
var (rows, head) = BuildChain(5);
var deleted = rows[1];
rows.RemoveAt(1);
var result = Verify(rows, head);
Assert.Equal(AuditChainStatus.Broken, result.Status);
Assert.Equal(AuditChainBreakKind.MissingRow, result.FirstBreak!.Kind);
Assert.Equal(deleted.ChainSeq, result.FirstBreak.ChainSeq);
Assert.Equal(rows[1].EventId, result.FirstBreak.EventId); // the next surviving row
}
/// <summary>Deleting the newest rows leaves a self-consistent prefix; the head catches it.</summary>
[Fact]
public void DeletedTailRows_IsHeadMismatch()
{
var (rows, head) = BuildChain(5);
rows.RemoveRange(3, 2);
var result = Verify(rows, head);
Assert.Equal(AuditChainStatus.Broken, result.Status);
Assert.Equal(AuditChainBreakKind.HeadMismatch, result.FirstBreak!.Kind);
Assert.Equal(4, result.FirstBreak.ChainSeq);
Assert.Null(result.FirstBreak.EventId);
}
/// <summary>Swapping two rows' sequence numbers is a HashMismatch at the first moved row.</summary>
[Fact]
public void ReorderedRows_IsHashMismatch()
{
var (rows, head) = BuildChain(5);
var a = rows[1];
var b = rows[3];
rows[1] = b with { ChainSeq = a.ChainSeq };
rows[3] = a with { ChainSeq = b.ChainSeq };
var result = Verify(rows, head);
Assert.Equal(AuditChainStatus.Broken, result.Status);
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
Assert.Equal(2, result.FirstBreak.ChainSeq);
Assert.Equal(b.EventId, result.FirstBreak.EventId);
}
/// <summary>A duplicated sequence number is reported, not silently skipped.</summary>
[Fact]
public void DuplicateSequence_IsReported()
{
var (rows, head) = BuildChain(3);
rows.Insert(2, rows[1] with { EventId = Guid.NewGuid() });
var result = Verify(rows, head);
Assert.Equal(AuditChainBreakKind.DuplicateSequence, result.FirstBreak!.Kind);
}
/// <summary>A sanctioned per-channel purge leaves a tombstone that bridges the gap.</summary>
[Fact]
public void TombstonedRow_BridgesTheGap()
{
var (rows, head) = BuildChain(5);
var purged = rows[2];
rows.RemoveAt(2);
var tombstones = new Dictionary<long, byte[]> { [purged.ChainSeq!.Value] = purged.RowHash! };
var result = Verify(rows, head, tombstones);
Assert.Equal(AuditChainStatus.Intact, result.Status);
Assert.Equal(4, result.RowsVerified);
Assert.Equal(1, result.TombstonesApplied);
}
/// <summary>A tombstone with the wrong hash does not hide a deletion.</summary>
[Fact]
public void ForgedTombstone_IsDetectedAtTheNextRow()
{
var (rows, head) = BuildChain(5);
var purged = rows[2];
rows.RemoveAt(2);
var tombstones = new Dictionary<long, byte[]> { [purged.ChainSeq!.Value] = new byte[32] };
var result = Verify(rows, head, tombstones);
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
Assert.Equal(4, result.FirstBreak.ChainSeq);
}
/// <summary>After a partition purge, verification resumes at the checkpoint anchor.</summary>
[Fact]
public void PartitionPurgeAnchor_VerifiesTheRest()
{
var (rows, head) = BuildChain(6);
var anchor = rows[3];
var survivors = rows.Skip(4).ToList();
var anchored = head with { PurgedThroughSeq = anchor.ChainSeq, PurgedThroughHash = anchor.RowHash };
var result = Verify(survivors, anchored);
Assert.Equal(AuditChainStatus.Intact, result.Status);
Assert.Equal(2, result.RowsVerified);
Assert.Equal(4, result.PurgedThroughSeq);
}
/// <summary>A month with neither rows nor head reports NotChained.</summary>
[Fact]
public void EmptyMonth_IsNotChained()
{
var result = Verify([], null, unchained: 7);
Assert.Equal(AuditChainStatus.NotChained, result.Status);
Assert.Equal(7, result.UnchainedRows);
}
/// <summary>
/// Pins the v1 canonical form: a change to the serialisation silently invalidates every
/// chain already in a database, so it must fail a test first. The expected value was
/// computed by an independent implementation of the documented v1 encoding (Python
/// hashlib/struct), not by this code.
/// </summary>
[Fact]
public void KnownAnswerVector_V1()
{
var row = new AuditChainRow(
Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"),
new DateTime(2026, 5, 20, 10, 30, 0, DateTimeKind.Utc).AddTicks(1234567),
"alice", "ApiOutbound.ApiCall", "Success", "ApiOutbound", "Erp", "central-a",
Guid.Parse("7c9e6679-7425-40de-944b-e07fc1f90ae7"), "{\"kind\":\"ApiCall\",\"status\":\"Delivered\"}");
var hash = AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row);
Assert.Equal("a3137c9961f35f931c7d84de6bced66ce1fe8bbb1dc500f9740b97efd9983ad4", Convert.ToHexStringLower(hash));
}
/// <summary>NULL and the empty string hash differently (presence byte), as do adjacent-field shifts.</summary>
[Fact]
public void Canonical_DistinguishesNullEmptyAndFieldBoundaries()
{
var baseRow = NewRow(1);
var nullTarget = baseRow with { Target = null };
var emptyTarget = baseRow with { Target = "" };
var shifted = baseRow with { Actor = "ab", Target = "c" };
var shifted2 = baseRow with { Actor = "a", Target = "bc" };
Assert.NotEqual(Hash(nullTarget), Hash(emptyTarget));
Assert.NotEqual(Hash(shifted), Hash(shifted2));
}
private static string Hash(AuditChainRow row)
=> Convert.ToHexString(AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row));
private static AuditChainVerificationResult Verify(
IEnumerable<AuditChainRow> rows,
AuditChainHeadState? head,
IReadOnlyDictionary<long, byte[]>? tombstones = null,
long unchained = 0)
{
var verifier = new AuditChainVerifier(Month, head, tombstones ?? new Dictionary<long, byte[]>());
foreach (var row in rows)
{
if (!verifier.Accept(row))
{
break;
}
}
return verifier.Complete(unchained);
}
private static (List<AuditChainRow> Rows, AuditChainHeadState Head) BuildChain(int count)
{
var rows = new List<AuditChainRow>();
var previous = AuditChainHasher.GenesisHash.ToArray();
for (var seq = 1; seq <= count; seq++)
{
var row = NewRow(seq);
var hash = AuditChainHasher.ComputeRowHash(previous, Month, seq, row);
rows.Add(row with { ChainSeq = seq, RowHash = hash });
previous = hash;
}
return (rows, new AuditChainHeadState(count, previous, null, null));
}
private static AuditChainRow NewRow(int i)
=> new(
Guid.NewGuid(),
Month.AddDays(i).AddTicks(i),
$"actor-{i}",
"ApiOutbound.ApiCall",
"Success",
"ApiOutbound",
$"target-{i}",
"central-a",
null,
$"{{\"kind\":\"ApiCall\",\"n\":{i}}}");
}