feat(audit): hash-chain cutoff - chain only rows written from first enablement, no backlog (#139)

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
This commit is contained in:
Joseph Doherty
2026-11-13 03:14:03 -05:00
parent 712c5117ee
commit 52da02a76a
21 changed files with 1208 additions and 49 deletions
@@ -214,10 +214,136 @@ public class AuditChainVerifierTests
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 = AuditChainHasher.GenesisHash.ToArray();
var previous = genesis;
for (var seq = 1; seq <= count; seq++)
{
var row = NewRow(seq);