Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Maintenance/AuditChainHasher.cs
T
Joseph Doherty fd4be80913 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
2026-11-09 16:33:11 -05:00

156 lines
7.1 KiB
C#

using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// The stored column values of one <c>dbo.AuditLog</c> row as the hash chain sees them
/// (ScadaBridge#41). Strings are the raw column values exactly as SQL Server returns them
/// (e.g. <see cref="Outcome"/> / <see cref="Category"/> are the stored enum NAMES), so
/// the hash never depends on how a later build happens to map them to CLR enums.
/// </summary>
/// <param name="EventId">The row's EventId.</param>
/// <param name="OccurredAtUtc">The row's OccurredAtUtc (datetime2(7) — full tick precision).</param>
/// <param name="Actor">Actor column.</param>
/// <param name="Action">Action column.</param>
/// <param name="Outcome">Outcome column.</param>
/// <param name="Category">Category (channel) column.</param>
/// <param name="Target">Target column.</param>
/// <param name="SourceNode">SourceNode column.</param>
/// <param name="CorrelationId">CorrelationId column.</param>
/// <param name="DetailsJson">DetailsJson column.</param>
/// <param name="ChainSeq">Stored ChainSeq, or null when unchained.</param>
/// <param name="RowHash">Stored RowHash, or null when unchained.</param>
public sealed record AuditChainRow(
Guid EventId,
DateTime OccurredAtUtc,
string? Actor,
string Action,
string Outcome,
string Category,
string? Target,
string? SourceNode,
Guid? CorrelationId,
string? DetailsJson,
long? ChainSeq = null,
byte[]? RowHash = null);
/// <summary>
/// Canonical serialisation + SHA-256 link computation for the AuditLog hash chain
/// (alog.md §11.4, ScadaBridge#41): <c>RowHash = SHA-256(prev.RowHash || canonical(row))</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Canonical form (v1), all integers big-endian:</b> the ASCII domain tag
/// <c>"ScadaBridge.AuditLog.Chain.v1"</c>; the chain partition (<c>YYYY-MM</c>); the
/// 64-bit <c>ChainSeq</c>; then the ten writable columns in table order — EventId (as its
/// lowercase <c>D</c>-format string), OccurredAtUtc (64-bit ticks), Actor, Action, Outcome,
/// Category, Target, SourceNode, CorrelationId (<c>D</c>-format string), DetailsJson.
/// Every string-valued field is encoded as one presence byte (<c>0</c> = SQL NULL,
/// <c>1</c> = present) followed, when present, by a 32-bit byte length and its UTF-8 bytes —
/// so NULL and the empty string hash differently, and no field boundary is ambiguous.
/// </para>
/// <para>
/// The partition and sequence number are inside the hashed bytes so a row moved to a
/// different position, or into another month's chain, fails verification on its own,
/// independently of the previous-hash link. The computed columns (Kind, Status, …) are
/// derived from DetailsJson, so hashing DetailsJson covers them.
/// </para>
/// <para>
/// Changing anything here invalidates every chain already written. A new form needs a new
/// domain tag and a migration story; never edit v1 in place.
/// </para>
/// </remarks>
public static class AuditChainHasher
{
/// <summary>Length of a link hash in bytes.</summary>
public const int HashLength = 32;
private static readonly byte[] DomainTag = Encoding.ASCII.GetBytes("ScadaBridge.AuditLog.Chain.v1");
/// <summary>The previous-hash value the first row of every chain links to (32 zero bytes).</summary>
public static ReadOnlySpan<byte> GenesisHash => new byte[HashLength];
/// <summary>Returns the first instant (UTC) of the month containing <paramref name="occurredAtUtc"/>.</summary>
/// <param name="occurredAtUtc">A UTC timestamp.</param>
/// <returns>Day 1, 00:00:00 UTC of that month.</returns>
public static DateTime MonthOf(DateTime occurredAtUtc)
=> new(occurredAtUtc.Year, occurredAtUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Formats a chain partition as <c>YYYY-MM</c>.</summary>
/// <param name="monthStartUtc">The month's first instant.</param>
/// <returns>The partition label.</returns>
public static string PartitionLabel(DateTime monthStartUtc)
=> monthStartUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture);
/// <summary>Computes the link hash for <paramref name="row"/> at <paramref name="chainSeq"/>.</summary>
/// <param name="previousHash">The RowHash of the row at <c>chainSeq - 1</c>, or <see cref="GenesisHash"/>.</param>
/// <param name="monthStartUtc">The chain partition the row belongs to.</param>
/// <param name="chainSeq">The row's position in the chain.</param>
/// <param name="row">The row's stored column values.</param>
/// <returns>The 32-byte SHA-256 link hash.</returns>
public static byte[] ComputeRowHash(ReadOnlySpan<byte> previousHash, DateTime monthStartUtc, long chainSeq, AuditChainRow row)
{
ArgumentNullException.ThrowIfNull(row);
if (previousHash.Length != HashLength)
{
throw new ArgumentException($"Previous hash must be {HashLength} bytes.", nameof(previousHash));
}
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
hash.AppendData(previousHash);
hash.AppendData(Canonicalize(monthStartUtc, chainSeq, row));
return hash.GetHashAndReset();
}
/// <summary>Builds the canonical v1 byte form of <paramref name="row"/> at its chain position.</summary>
/// <param name="monthStartUtc">The chain partition.</param>
/// <param name="chainSeq">The row's position in the chain.</param>
/// <param name="row">The row's stored column values.</param>
/// <returns>The canonical bytes (excluding the previous hash).</returns>
public static byte[] Canonicalize(DateTime monthStartUtc, long chainSeq, AuditChainRow row)
{
ArgumentNullException.ThrowIfNull(row);
using var buffer = new MemoryStream(256 + (row.DetailsJson?.Length ?? 0) * 3);
buffer.Write(DomainTag);
WriteString(buffer, PartitionLabel(monthStartUtc));
WriteInt64(buffer, chainSeq);
WriteString(buffer, row.EventId.ToString("D"));
WriteInt64(buffer, row.OccurredAtUtc.Ticks);
WriteString(buffer, row.Actor);
WriteString(buffer, row.Action);
WriteString(buffer, row.Outcome);
WriteString(buffer, row.Category);
WriteString(buffer, row.Target);
WriteString(buffer, row.SourceNode);
WriteString(buffer, row.CorrelationId?.ToString("D"));
WriteString(buffer, row.DetailsJson);
return buffer.ToArray();
}
private static void WriteInt64(Stream stream, long value)
{
Span<byte> bytes = stackalloc byte[8];
BinaryPrimitives.WriteInt64BigEndian(bytes, value);
stream.Write(bytes);
}
private static void WriteString(Stream stream, string? value)
{
if (value is null)
{
stream.WriteByte(0);
return;
}
stream.WriteByte(1);
var bytes = Encoding.UTF8.GetBytes(value);
Span<byte> length = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
stream.Write(length);
stream.Write(bytes);
}
}