| | | 1 | | using System.Collections.ObjectModel; |
| | | 2 | | using AsiBackbone.Core.Serialization; |
| | | 3 | | using AsiBackbone.Core.Signing; |
| | | 4 | | |
| | | 5 | | namespace AsiBackbone.Core.Integrity; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// Represents one append-only hash-chain link for a canonical audit or outbox record hash. |
| | | 9 | | /// </summary> |
| | | 10 | | /// <remarks> |
| | | 11 | | /// A link proves only local sequence continuity when later verified. It does not make storage immutable, |
| | | 12 | | /// externally anchored, or legally non-repudiable by itself. |
| | | 13 | | /// </remarks> |
| | | 14 | | public sealed class AuditIntegrityLink |
| | | 15 | | { |
| | 1 | 16 | | private static readonly IReadOnlyDictionary<string, string> EmptyMetadata = |
| | 1 | 17 | | new ReadOnlyDictionary<string, string>(new Dictionary<string, string>(StringComparer.Ordinal)); |
| | | 18 | | |
| | 45 | 19 | | private AuditIntegrityLink( |
| | 45 | 20 | | string chainId, |
| | 45 | 21 | | long sequence, |
| | 45 | 22 | | string recordId, |
| | 45 | 23 | | string recordType, |
| | 45 | 24 | | string recordHash, |
| | 45 | 25 | | string previousLinkHash, |
| | 45 | 26 | | string linkHash, |
| | 45 | 27 | | string hashAlgorithm, |
| | 45 | 28 | | string canonicalizationVersion, |
| | 45 | 29 | | string schemaVersion, |
| | 45 | 30 | | DateTimeOffset createdUtc, |
| | 45 | 31 | | IReadOnlyDictionary<string, string> metadata) |
| | | 32 | | { |
| | 45 | 33 | | ArgumentException.ThrowIfNullOrWhiteSpace(chainId); |
| | 45 | 34 | | ArgumentException.ThrowIfNullOrWhiteSpace(recordId); |
| | 45 | 35 | | ArgumentException.ThrowIfNullOrWhiteSpace(recordType); |
| | 45 | 36 | | ArgumentException.ThrowIfNullOrWhiteSpace(recordHash); |
| | 45 | 37 | | ArgumentException.ThrowIfNullOrWhiteSpace(linkHash); |
| | 45 | 38 | | ArgumentException.ThrowIfNullOrWhiteSpace(hashAlgorithm); |
| | 45 | 39 | | ArgumentException.ThrowIfNullOrWhiteSpace(canonicalizationVersion); |
| | 45 | 40 | | ArgumentException.ThrowIfNullOrWhiteSpace(schemaVersion); |
| | | 41 | | |
| | 45 | 42 | | if (sequence < 1) |
| | | 43 | | { |
| | 1 | 44 | | throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Chain sequence must be greater than zero. |
| | | 45 | | } |
| | | 46 | | |
| | 44 | 47 | | ChainId = chainId.Trim(); |
| | 44 | 48 | | Sequence = sequence; |
| | 44 | 49 | | RecordId = recordId.Trim(); |
| | 44 | 50 | | RecordType = recordType.Trim(); |
| | 44 | 51 | | RecordHash = recordHash.Trim().ToLowerInvariant(); |
| | 44 | 52 | | PreviousLinkHash = string.IsNullOrWhiteSpace(previousLinkHash) ? string.Empty : previousLinkHash.Trim().ToLowerI |
| | 44 | 53 | | LinkHash = linkHash.Trim().ToLowerInvariant(); |
| | 44 | 54 | | HashAlgorithm = CanonicalPayloadHash.NormalizeHashAlgorithm(hashAlgorithm); |
| | 44 | 55 | | CanonicalizationVersion = canonicalizationVersion.Trim(); |
| | 44 | 56 | | SchemaVersion = AsiBackboneSchemaVersions.Normalize(schemaVersion); |
| | 44 | 57 | | CreatedUtc = createdUtc.ToUniversalTime(); |
| | 44 | 58 | | Metadata = metadata; |
| | 44 | 59 | | } |
| | | 60 | | |
| | | 61 | | /// <summary> |
| | | 62 | | /// Gets the logical chain identifier. |
| | | 63 | | /// </summary> |
| | 117 | 64 | | public string ChainId { get; } |
| | | 65 | | |
| | | 66 | | /// <summary> |
| | | 67 | | /// Gets the one-based sequence number within the chain. |
| | | 68 | | /// </summary> |
| | 172 | 69 | | public long Sequence { get; } |
| | | 70 | | |
| | | 71 | | /// <summary> |
| | | 72 | | /// Gets the record identifier bound into this link. |
| | | 73 | | /// </summary> |
| | 66 | 74 | | public string RecordId { get; } |
| | | 75 | | |
| | | 76 | | /// <summary> |
| | | 77 | | /// Gets the canonical record artifact type bound into this link. |
| | | 78 | | /// </summary> |
| | 52 | 79 | | public string RecordType { get; } |
| | | 80 | | |
| | | 81 | | /// <summary> |
| | | 82 | | /// Gets the canonical record hash bound into this link. |
| | | 83 | | /// </summary> |
| | 53 | 84 | | public string RecordHash { get; } |
| | | 85 | | |
| | | 86 | | /// <summary> |
| | | 87 | | /// Gets the previous link hash. Genesis links use an empty value. |
| | | 88 | | /// </summary> |
| | 73 | 89 | | public string PreviousLinkHash { get; } |
| | | 90 | | |
| | | 91 | | /// <summary> |
| | | 92 | | /// Gets this link's canonical hash. |
| | | 93 | | /// </summary> |
| | 37 | 94 | | public string LinkHash { get; } |
| | | 95 | | |
| | | 96 | | /// <summary> |
| | | 97 | | /// Gets the hash algorithm used for record and link hashes. |
| | | 98 | | /// </summary> |
| | 66 | 99 | | public string HashAlgorithm { get; } |
| | | 100 | | |
| | | 101 | | /// <summary> |
| | | 102 | | /// Gets the canonicalization version used for link hashing. |
| | | 103 | | /// </summary> |
| | 52 | 104 | | public string CanonicalizationVersion { get; } |
| | | 105 | | |
| | | 106 | | /// <summary> |
| | | 107 | | /// Gets the schema version for the integrity link. |
| | | 108 | | /// </summary> |
| | 52 | 109 | | public string SchemaVersion { get; } |
| | | 110 | | |
| | | 111 | | /// <summary> |
| | | 112 | | /// Gets the UTC timestamp when this link was created. |
| | | 113 | | /// </summary> |
| | 53 | 114 | | public DateTimeOffset CreatedUtc { get; } |
| | | 115 | | |
| | | 116 | | /// <summary> |
| | | 117 | | /// Gets provider-neutral metadata associated with the link. |
| | | 118 | | /// </summary> |
| | 24 | 119 | | public IReadOnlyDictionary<string, string> Metadata { get; } |
| | | 120 | | |
| | | 121 | | /// <summary> |
| | | 122 | | /// Gets a value indicating whether this is the first link in the chain. |
| | | 123 | | /// </summary> |
| | 4 | 124 | | public bool IsGenesis => Sequence == 1 && PreviousLinkHash.Length == 0; |
| | | 125 | | |
| | | 126 | | /// <summary> |
| | | 127 | | /// Creates the first link in a chain. |
| | | 128 | | /// </summary> |
| | | 129 | | public static AuditIntegrityLink CreateGenesis( |
| | | 130 | | string chainId, |
| | | 131 | | CanonicalPayloadHash recordHash, |
| | | 132 | | DateTimeOffset createdUtc, |
| | | 133 | | IReadOnlyDictionary<string, string>? metadata = null) |
| | | 134 | | { |
| | 12 | 135 | | return CreateNext(chainId, 1, recordHash, string.Empty, createdUtc, metadata); |
| | | 136 | | } |
| | | 137 | | |
| | | 138 | | /// <summary> |
| | | 139 | | /// Appends a link after the supplied previous link. |
| | | 140 | | /// </summary> |
| | | 141 | | public static AuditIntegrityLink Append( |
| | | 142 | | AuditIntegrityLink previousLink, |
| | | 143 | | CanonicalPayloadHash recordHash, |
| | | 144 | | DateTimeOffset createdUtc, |
| | | 145 | | IReadOnlyDictionary<string, string>? metadata = null) |
| | | 146 | | { |
| | 8 | 147 | | ArgumentNullException.ThrowIfNull(previousLink); |
| | | 148 | | |
| | 7 | 149 | | return CreateNext( |
| | 7 | 150 | | previousLink.ChainId, |
| | 7 | 151 | | previousLink.Sequence + 1, |
| | 7 | 152 | | recordHash, |
| | 7 | 153 | | previousLink.LinkHash, |
| | 7 | 154 | | createdUtc, |
| | 7 | 155 | | metadata); |
| | | 156 | | } |
| | | 157 | | |
| | | 158 | | /// <summary> |
| | | 159 | | /// Rehydrates a persisted link without recomputing its link hash. |
| | | 160 | | /// </summary> |
| | | 161 | | public static AuditIntegrityLink Rehydrate( |
| | | 162 | | string chainId, |
| | | 163 | | long sequence, |
| | | 164 | | string recordId, |
| | | 165 | | string recordType, |
| | | 166 | | string recordHash, |
| | | 167 | | string previousLinkHash, |
| | | 168 | | string linkHash, |
| | | 169 | | string hashAlgorithm, |
| | | 170 | | string canonicalizationVersion, |
| | | 171 | | string schemaVersion, |
| | | 172 | | DateTimeOffset createdUtc, |
| | | 173 | | IReadOnlyDictionary<string, string>? metadata = null) |
| | | 174 | | { |
| | 9 | 175 | | return new AuditIntegrityLink( |
| | 9 | 176 | | chainId, |
| | 9 | 177 | | sequence, |
| | 9 | 178 | | recordId, |
| | 9 | 179 | | recordType, |
| | 9 | 180 | | recordHash, |
| | 9 | 181 | | previousLinkHash, |
| | 9 | 182 | | linkHash, |
| | 9 | 183 | | hashAlgorithm, |
| | 9 | 184 | | canonicalizationVersion, |
| | 9 | 185 | | schemaVersion, |
| | 9 | 186 | | createdUtc, |
| | 9 | 187 | | NormalizeMetadata(metadata)); |
| | | 188 | | } |
| | | 189 | | |
| | | 190 | | /// <summary> |
| | | 191 | | /// Recomputes the link hash from the persisted link fields. |
| | | 192 | | /// </summary> |
| | | 193 | | public string ComputeExpectedLinkHash() |
| | | 194 | | { |
| | 10 | 195 | | return CanonicalPayloadHasher.ComputeHash(BuildCanonicalPayload()).HashValue; |
| | | 196 | | } |
| | | 197 | | |
| | | 198 | | internal CanonicalPayload BuildCanonicalPayload() |
| | | 199 | | { |
| | 28 | 200 | | SortedDictionary<string, object?> content = new(StringComparer.Ordinal) |
| | 28 | 201 | | { |
| | 28 | 202 | | ["chainId"] = ChainId, |
| | 28 | 203 | | ["createdUtc"] = CreatedUtc.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", System.Globalization.CultureInfo.In |
| | 28 | 204 | | ["hashAlgorithm"] = HashAlgorithm, |
| | 28 | 205 | | ["previousLinkHash"] = PreviousLinkHash, |
| | 28 | 206 | | ["recordHash"] = RecordHash, |
| | 28 | 207 | | ["recordId"] = RecordId, |
| | 28 | 208 | | ["recordType"] = RecordType, |
| | 28 | 209 | | ["sequence"] = Sequence |
| | 28 | 210 | | }; |
| | | 211 | | |
| | 28 | 212 | | return CanonicalPayload.Create( |
| | 28 | 213 | | CanonicalArtifactTypes.AuditIntegrityLink, |
| | 28 | 214 | | $"{ChainId}:{Sequence}", |
| | 28 | 215 | | SchemaVersion, |
| | 28 | 216 | | CanonicalizationVersion, |
| | 28 | 217 | | content); |
| | | 218 | | } |
| | | 219 | | |
| | | 220 | | private static AuditIntegrityLink CreateNext( |
| | | 221 | | string chainId, |
| | | 222 | | long sequence, |
| | | 223 | | CanonicalPayloadHash recordHash, |
| | | 224 | | string previousLinkHash, |
| | | 225 | | DateTimeOffset createdUtc, |
| | | 226 | | IReadOnlyDictionary<string, string>? metadata) |
| | | 227 | | { |
| | 19 | 228 | | ArgumentNullException.ThrowIfNull(recordHash); |
| | | 229 | | |
| | 18 | 230 | | string schemaVersion = AsiBackboneSchemaVersions.StableArtifactsV1; |
| | 18 | 231 | | string canonicalizationVersion = recordHash.CanonicalizationVersion; |
| | 18 | 232 | | var draft = new AuditIntegrityLink( |
| | 18 | 233 | | chainId, |
| | 18 | 234 | | sequence, |
| | 18 | 235 | | recordHash.ArtifactId, |
| | 18 | 236 | | recordHash.ArtifactType, |
| | 18 | 237 | | recordHash.HashValue, |
| | 18 | 238 | | previousLinkHash, |
| | 18 | 239 | | recordHash.HashValue, |
| | 18 | 240 | | recordHash.HashAlgorithm, |
| | 18 | 241 | | canonicalizationVersion, |
| | 18 | 242 | | schemaVersion, |
| | 18 | 243 | | createdUtc, |
| | 18 | 244 | | NormalizeMetadata(metadata)); |
| | | 245 | | |
| | 18 | 246 | | string linkHash = CanonicalPayloadHasher.ComputeHash(draft.BuildCanonicalPayload()).HashValue; |
| | | 247 | | |
| | 18 | 248 | | return new AuditIntegrityLink( |
| | 18 | 249 | | draft.ChainId, |
| | 18 | 250 | | draft.Sequence, |
| | 18 | 251 | | draft.RecordId, |
| | 18 | 252 | | draft.RecordType, |
| | 18 | 253 | | draft.RecordHash, |
| | 18 | 254 | | draft.PreviousLinkHash, |
| | 18 | 255 | | linkHash, |
| | 18 | 256 | | draft.HashAlgorithm, |
| | 18 | 257 | | draft.CanonicalizationVersion, |
| | 18 | 258 | | draft.SchemaVersion, |
| | 18 | 259 | | draft.CreatedUtc, |
| | 18 | 260 | | draft.Metadata); |
| | | 261 | | } |
| | | 262 | | |
| | | 263 | | private static IReadOnlyDictionary<string, string> NormalizeMetadata(IReadOnlyDictionary<string, string>? metadata) |
| | | 264 | | { |
| | 27 | 265 | | if (metadata is null || metadata.Count == 0) |
| | | 266 | | { |
| | 24 | 267 | | return EmptyMetadata; |
| | | 268 | | } |
| | | 269 | | |
| | 3 | 270 | | Dictionary<string, string> normalized = new(StringComparer.Ordinal); |
| | | 271 | | |
| | 16 | 272 | | foreach (KeyValuePair<string, string> item in metadata) |
| | | 273 | | { |
| | 5 | 274 | | if (!string.IsNullOrWhiteSpace(item.Key)) |
| | | 275 | | { |
| | 3 | 276 | | normalized[item.Key.Trim()] = item.Value?.Trim() ?? string.Empty; |
| | | 277 | | } |
| | | 278 | | } |
| | | 279 | | |
| | 3 | 280 | | return normalized.Count == 0 |
| | 3 | 281 | | ? EmptyMetadata |
| | 3 | 282 | | : new ReadOnlyDictionary<string, string>(normalized); |
| | | 283 | | } |
| | | 284 | | } |