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:
@@ -0,0 +1,155 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user