52da02a76a
Owner decision on #107 (2026-11-13): enabling AuditLog:HashChain must not chain the historical backlog. main chained every unchained row oldest-first. - The first chaining pass on a pristine database (no head, no chained row) writes a chain-start anchor: AuditLogChainTombstone (cutoff month, ChainSeq 0), PurgedAtUtc = DB SYSUTCDATETIME(), RowHash = SHA-256("ScadaBridge.AuditLog.ChainCutoff.v1" || YYYY-MM || ticks). Written once under the exclusive chain lock, so both centrals agree whatever their clocks/config. No migration: existing table + grants. - Only rows with OccurredAtUtc >= cutoff are chained. The anchor hash is the cutoff month's genesis, binding the cutoff into the chain. - AuditLog:HashChain:ChainBacklog (default false) restores chain-everything, read only on the very first enablement. Legacy chains (heads, no anchor) keep chaining everything. - verify-chain splits unchained rows into UnchainedRows vs PreCutoffRows (+ ChainFromUtc); new breaks ChainedBeforeCutoff (row re-dated before the cutoff, or cutoff moved forward) and CutoffAnchorInvalid (anchor hash / month / multiplicity). Archive verify uses the anchor from its own tombstone file or the DB. - Partition switch-out keeps the anchor (deletes ChainSeq > 0 only); the head store / export / verify-heads tombstone count excludes it. - CLI verify-chain table prints Chain from + Pre-cutoff rows. Tests: AuditLogHashChainCutoffTests (7, MSSQL), 6 verifier unit tests, service/CLI/management wire tests; 9 mutations all killed. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
371 lines
14 KiB
C#
371 lines
14 KiB
C#
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);
|
|
}
|
|
|
|
// ── chain start / cutoff (ScadaBridge#139) ───────────────────────────────
|
|
|
|
private static readonly DateTime CutoffAt = Month.AddHours(12); // rows are dated Month + i days
|
|
|
|
/// <summary>A cutoff-started month links its first row to the anchor and verifies intact.</summary>
|
|
[Fact]
|
|
public void CutoffStartedChain_Verifies_FromTheAnchor()
|
|
{
|
|
var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt);
|
|
var (rows, head) = BuildChain(4, anchor);
|
|
|
|
var result = VerifyCutoff(rows, head, anchor);
|
|
|
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
|
Assert.Equal(4, result.RowsVerified);
|
|
Assert.Equal(0, result.TombstonesApplied); // the anchor is not a purge tombstone
|
|
Assert.Equal(CutoffAt, result.ChainFromUtc);
|
|
Assert.Equal(7, result.PreCutoffRows);
|
|
}
|
|
|
|
/// <summary>Without its anchor, a cutoff-started month fails at its first link.</summary>
|
|
[Fact]
|
|
public void CutoffStartedChain_WithoutItsAnchor_FailsAtTheFirstLink()
|
|
{
|
|
var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt);
|
|
var (rows, head) = BuildChain(4, anchor);
|
|
|
|
var result = Verify(rows, head);
|
|
|
|
Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind);
|
|
Assert.Equal(1, result.FirstBreak.ChainSeq);
|
|
}
|
|
|
|
/// <summary>A different cutoff is a different anchor: the anchor hash binds the instant.</summary>
|
|
[Fact]
|
|
public void CutoffAnchorHash_BindsTheInstant()
|
|
{
|
|
Assert.False(AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt).AsSpan()
|
|
.SequenceEqual(AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt.AddTicks(1))));
|
|
Assert.Equal(AuditChainHasher.HashLength, AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt).Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The eligibility attack: a chained row re-dated before the cutoff is a
|
|
/// ChainedBeforeCutoff break even when its hash were recomputed to match.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ChainedRowDatedBeforeTheCutoff_IsChainedBeforeCutoff()
|
|
{
|
|
var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt);
|
|
var (rows, _) = BuildChain(4, anchor);
|
|
|
|
// Re-date row 3 before the cutoff and re-hash the chain from it (an attacker who
|
|
// rewrites the links): only the cutoff rule can still see it.
|
|
rows[2] = rows[2] with { OccurredAtUtc = CutoffAt.AddMinutes(-1) };
|
|
var previous = rows[1].RowHash!;
|
|
for (var i = 2; i < rows.Count; i++)
|
|
{
|
|
var hash = AuditChainHasher.ComputeRowHash(previous, Month, i + 1, rows[i] with { ChainSeq = null, RowHash = null });
|
|
rows[i] = rows[i] with { RowHash = hash };
|
|
previous = hash;
|
|
}
|
|
|
|
var result = VerifyCutoff(rows, new AuditChainHeadState(4, previous, null, null), anchor);
|
|
|
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
|
Assert.Equal(AuditChainBreakKind.ChainedBeforeCutoff, result.FirstBreak!.Kind);
|
|
Assert.Equal(3, result.FirstBreak.ChainSeq);
|
|
Assert.Equal(rows[2].EventId, result.FirstBreak.EventId);
|
|
}
|
|
|
|
/// <summary>An anchor the caller found invalid fails the month even when every link verifies.</summary>
|
|
[Fact]
|
|
public void InvalidAnchor_IsCutoffAnchorInvalid()
|
|
{
|
|
var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt);
|
|
var (rows, head) = BuildChain(4, anchor);
|
|
var tombstones = new Dictionary<long, byte[]> { [AuditChainHasher.CutoffAnchorSeq] = anchor };
|
|
var verifier = new AuditChainVerifier(Month, head, tombstones, CutoffAt, "anchor altered");
|
|
foreach (var row in rows)
|
|
{
|
|
verifier.Accept(row);
|
|
}
|
|
|
|
var result = verifier.Complete(0);
|
|
|
|
Assert.Equal(AuditChainStatus.Broken, result.Status);
|
|
Assert.Equal(AuditChainBreakKind.CutoffAnchorInvalid, result.FirstBreak!.Kind);
|
|
Assert.Equal(AuditChainHasher.CutoffAnchorSeq, result.FirstBreak.ChainSeq);
|
|
}
|
|
|
|
/// <summary>
|
|
/// After a partition purge the checkpoint anchor, not the cutoff anchor, is where
|
|
/// verification resumes; the cutoff anchor in the same tombstone set is ignored.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PurgeCheckpoint_TakesPrecedenceOverTheCutoffAnchor()
|
|
{
|
|
var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt);
|
|
var (rows, _) = BuildChain(5, anchor);
|
|
var head = new AuditChainHeadState(5, rows[4].RowHash!, 3, rows[2].RowHash!);
|
|
|
|
var result = VerifyCutoff(rows.Skip(3), head, anchor);
|
|
|
|
Assert.Equal(AuditChainStatus.Intact, result.Status);
|
|
Assert.Equal(2, result.RowsVerified);
|
|
}
|
|
|
|
private static AuditChainVerificationResult VerifyCutoff(IEnumerable<AuditChainRow> rows, AuditChainHeadState head, byte[] anchor)
|
|
{
|
|
var tombstones = new Dictionary<long, byte[]> { [AuditChainHasher.CutoffAnchorSeq] = anchor };
|
|
var verifier = new AuditChainVerifier(Month, head, tombstones, CutoffAt);
|
|
foreach (var row in rows)
|
|
{
|
|
if (!verifier.Accept(row))
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return verifier.Complete(0, preCutoffRows: 7);
|
|
}
|
|
|
|
private static (List<AuditChainRow> Rows, AuditChainHeadState Head) BuildChain(int count)
|
|
=> BuildChain(count, AuditChainHasher.GenesisHash.ToArray());
|
|
|
|
private static (List<AuditChainRow> Rows, AuditChainHeadState Head) BuildChain(int count, byte[] genesis)
|
|
{
|
|
var rows = new List<AuditChainRow>();
|
|
var previous = genesis;
|
|
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}}}");
|
|
}
|