fd4be80913
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
156 lines
7.1 KiB
C#
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);
|
|
}
|
|
}
|