using System.Buffers.Binary; using System.Globalization; using System.Security.Cryptography; using System.Text; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; /// /// The stored column values of one dbo.AuditLog row as the hash chain sees them /// (ScadaBridge#41). Strings are the raw column values exactly as SQL Server returns them /// (e.g. / are the stored enum NAMES), so /// the hash never depends on how a later build happens to map them to CLR enums. /// /// The row's EventId. /// The row's OccurredAtUtc (datetime2(7) — full tick precision). /// Actor column. /// Action column. /// Outcome column. /// Category (channel) column. /// Target column. /// SourceNode column. /// CorrelationId column. /// DetailsJson column. /// Stored ChainSeq, or null when unchained. /// Stored RowHash, or null when unchained. 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); /// /// Canonical serialisation + SHA-256 link computation for the AuditLog hash chain /// (alog.md §11.4, ScadaBridge#41): RowHash = SHA-256(prev.RowHash || canonical(row)). /// /// /// /// Canonical form (v1), all integers big-endian: the ASCII domain tag /// "ScadaBridge.AuditLog.Chain.v1"; the chain partition (YYYY-MM); the /// 64-bit ChainSeq; then the ten writable columns in table order — EventId (as its /// lowercase D-format string), OccurredAtUtc (64-bit ticks), Actor, Action, Outcome, /// Category, Target, SourceNode, CorrelationId (D-format string), DetailsJson. /// Every string-valued field is encoded as one presence byte (0 = SQL NULL, /// 1 = 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. /// /// /// 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. /// /// /// 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. /// /// public static class AuditChainHasher { /// Length of a link hash in bytes. public const int HashLength = 32; private static readonly byte[] DomainTag = Encoding.ASCII.GetBytes("ScadaBridge.AuditLog.Chain.v1"); /// The previous-hash value the first row of every chain links to (32 zero bytes). public static ReadOnlySpan GenesisHash => new byte[HashLength]; /// Returns the first instant (UTC) of the month containing . /// A UTC timestamp. /// Day 1, 00:00:00 UTC of that month. public static DateTime MonthOf(DateTime occurredAtUtc) => new(occurredAtUtc.Year, occurredAtUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc); /// Formats a chain partition as YYYY-MM. /// The month's first instant. /// The partition label. public static string PartitionLabel(DateTime monthStartUtc) => monthStartUtc.ToString("yyyy-MM", CultureInfo.InvariantCulture); /// Computes the link hash for at . /// The RowHash of the row at chainSeq - 1, or . /// The chain partition the row belongs to. /// The row's position in the chain. /// The row's stored column values. /// The 32-byte SHA-256 link hash. public static byte[] ComputeRowHash(ReadOnlySpan 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(); } /// Builds the canonical v1 byte form of at its chain position. /// The chain partition. /// The row's position in the chain. /// The row's stored column values. /// The canonical bytes (excluding the previous hash). 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 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 length = stackalloc byte[4]; BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); stream.Write(length); stream.Write(bytes); } }