| | | 1 | | using System.Diagnostics.CodeAnalysis; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using System.Text.Json; |
| | | 5 | | |
| | | 6 | | namespace AsiBackbone.Samples.NcatAuditCompletionAdapter; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Applies version 1 of NCAT's published audit-completion contract to a received completion message. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// The rules are modeled independently from NCAT and are checked against NCAT's pinned contract vectors in |
| | | 13 | | /// the sample tests. Validation rejects anything the contract does not define instead of guessing a mapping. |
| | | 14 | | /// </remarks> |
| | | 15 | | public static class NcatAuditCompletionContract |
| | | 16 | | { |
| | | 17 | | /// <summary>Gets the major contract version this adapter understands.</summary> |
| | | 18 | | public const int SupportedContractMajorVersion = 1; |
| | | 19 | | |
| | | 20 | | /// <summary>Gets the supported completion message schema version.</summary> |
| | | 21 | | public const string MessageSchemaVersion = "1.0"; |
| | | 22 | | |
| | | 23 | | /// <summary>Gets the supported canonical mutation manifest schema version.</summary> |
| | | 24 | | public const string ManifestSchemaVersion = "1.0"; |
| | | 25 | | |
| | | 26 | | /// <summary>Gets the supported canonical mutation manifest digest algorithm.</summary> |
| | | 27 | | public const string ManifestAlgorithm = "SHA-256"; |
| | | 28 | | |
| | | 29 | | /// <summary>Gets the only persistence outcome carried by an NCAT completion message.</summary> |
| | | 30 | | public const string CommittedOutcome = "Committed"; |
| | | 31 | | |
| | | 32 | | /// <summary>Gets the prefix of every NCAT completion idempotency key.</summary> |
| | | 33 | | public const string IdempotencyKeyPrefix = "ncat-audit-completion:"; |
| | | 34 | | |
| | | 35 | | /// <summary>Gets the maximum destination length accepted by NCAT staging.</summary> |
| | | 36 | | public const int MaximumDestinationLength = 128; |
| | | 37 | | |
| | | 38 | | private const int Sha256HexLength = 64; |
| | | 39 | | |
| | | 40 | | /// <summary> |
| | | 41 | | /// Derives the idempotency key NCAT assigns to a destination and mutation batch. |
| | | 42 | | /// </summary> |
| | | 43 | | /// <remarks> |
| | | 44 | | /// The key hashes the trimmed destination and the trimmed mutation batch identifier. The message itself keeps |
| | | 45 | | /// the batch identifier exactly as NCAT recorded it. |
| | | 46 | | /// </remarks> |
| | | 47 | | public static string ComputeIdempotencyKey(string destination, string mutationBatchId) |
| | | 48 | | { |
| | 14 | 49 | | ArgumentNullException.ThrowIfNull(destination); |
| | 14 | 50 | | ArgumentNullException.ThrowIfNull(mutationBatchId); |
| | | 51 | | |
| | 14 | 52 | | byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(destination.Trim() + "\n" + mutationBatchId.Trim())); |
| | 14 | 53 | | return IdempotencyKeyPrefix + Convert.ToHexString(digest); |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | /// <summary> |
| | | 57 | | /// Validates a completion message against the contract and translates it into an adapter handoff. |
| | | 58 | | /// </summary> |
| | | 59 | | /// <returns><see langword="true" /> when the message satisfies the contract; otherwise <see langword="false" />.</r |
| | | 60 | | public static bool TryCreateHandoff( |
| | | 61 | | NcatAuditCompletionMessage message, |
| | | 62 | | int deliveryAttempt, |
| | | 63 | | [NotNullWhen(true)] out NcatAuditCompletionHandoff? handoff, |
| | | 64 | | [NotNullWhen(false)] out string? reasonCode) |
| | | 65 | | { |
| | 25 | 66 | | ArgumentNullException.ThrowIfNull(message); |
| | 25 | 67 | | handoff = null; |
| | | 68 | | |
| | 25 | 69 | | reasonCode = Validate(message); |
| | 25 | 70 | | if (reasonCode is not null) |
| | | 71 | | { |
| | 16 | 72 | | return false; |
| | | 73 | | } |
| | | 74 | | |
| | 9 | 75 | | handoff = new NcatAuditCompletionHandoff( |
| | 9 | 76 | | CompletionEntryId: message.IdempotencyKey, |
| | 9 | 77 | | PersistenceOutcome: message.PersistenceOutcome, |
| | 9 | 78 | | CompletedUtc: message.ReceiptCompletedUtc.ToUniversalTime(), |
| | 9 | 79 | | OperationExecutionId: message.OperationExecutionId, |
| | 9 | 80 | | ExecutionAttemptId: message.ExecutionAttemptId, |
| | 9 | 81 | | DecisionAuditRecordId: message.DecisionAuditRecordId, |
| | 9 | 82 | | CorrelationId: message.CorrelationId, |
| | 9 | 83 | | TraceId: message.TraceId, |
| | 9 | 84 | | MutationBatchId: message.MutationBatchId, |
| | 9 | 85 | | AuditRecordCount: message.AuditRecordCount, |
| | 9 | 86 | | MutationManifestHash: message.MutationManifestHash, |
| | 9 | 87 | | MutationManifestAlgorithm: message.MutationManifestAlgorithm, |
| | 9 | 88 | | DeliveryAttempt: deliveryAttempt); |
| | 9 | 89 | | return true; |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | /// <summary> |
| | | 93 | | /// Verifies a message against retained canonical manifest bytes, such as an archive's copy of the manifest. |
| | | 94 | | /// </summary> |
| | | 95 | | /// <param name="message">The completion message to verify.</param> |
| | | 96 | | /// <param name="canonicalManifestUtf8">The exact canonical manifest text, encoded as UTF-8 without a byte order mar |
| | | 97 | | /// <param name="reasonCode">The rejection reason when verification fails.</param> |
| | | 98 | | /// <returns><see langword="true" /> when the manifest corresponds to the message; otherwise <see langword="false" / |
| | | 99 | | public static bool TryVerifyCanonicalManifest( |
| | | 100 | | NcatAuditCompletionMessage message, |
| | | 101 | | ReadOnlySpan<byte> canonicalManifestUtf8, |
| | | 102 | | [NotNullWhen(false)] out string? reasonCode) |
| | | 103 | | { |
| | 14 | 104 | | ArgumentNullException.ThrowIfNull(message); |
| | | 105 | | |
| | 14 | 106 | | if (!string.Equals(message.MutationManifestAlgorithm, ManifestAlgorithm, StringComparison.Ordinal)) |
| | | 107 | | { |
| | 0 | 108 | | reasonCode = "unsupported-manifest-algorithm"; |
| | 0 | 109 | | return false; |
| | | 110 | | } |
| | | 111 | | |
| | 14 | 112 | | if (!IsSha256Hex(message.MutationManifestHash)) |
| | | 113 | | { |
| | 4 | 114 | | reasonCode = "invalid-manifest-hash"; |
| | 4 | 115 | | return false; |
| | | 116 | | } |
| | | 117 | | |
| | 10 | 118 | | if (!TryReadManifestHeader(canonicalManifestUtf8, out ManifestHeader? header)) |
| | | 119 | | { |
| | 4 | 120 | | reasonCode = "manifest-malformed"; |
| | 4 | 121 | | return false; |
| | | 122 | | } |
| | | 123 | | |
| | 6 | 124 | | reasonCode = |
| | 6 | 125 | | !string.Equals(header.SchemaVersion, message.MutationManifestSchemaVersion, StringComparison.Ordinal) |
| | 6 | 126 | | ? "manifest-schema-version-mismatch" |
| | 6 | 127 | | : !string.Equals(header.MutationBatchId, message.MutationBatchId, StringComparison.Ordinal) |
| | 6 | 128 | | ? "manifest-batch-id-mismatch" |
| | 6 | 129 | | : header.AuditRecordCount != message.AuditRecordCount |
| | 6 | 130 | | ? "manifest-record-count-mismatch" |
| | 6 | 131 | | : !DigestMatches(message.MutationManifestHash, SHA256.HashData(canonicalManifestUtf8)) |
| | 6 | 132 | | ? "manifest-digest-mismatch" |
| | 6 | 133 | | : null; |
| | | 134 | | |
| | 6 | 135 | | return reasonCode is null; |
| | | 136 | | } |
| | | 137 | | |
| | | 138 | | private static string? Validate(NcatAuditCompletionMessage message) |
| | | 139 | | { |
| | 25 | 140 | | return !string.Equals(message.SchemaVersion, MessageSchemaVersion, StringComparison.Ordinal) |
| | 25 | 141 | | ? "unsupported-message-schema-version" |
| | 25 | 142 | | : !string.Equals(message.MutationManifestSchemaVersion, ManifestSchemaVersion, StringComparison.Ordinal) |
| | 25 | 143 | | ? "unsupported-manifest-schema-version" |
| | 25 | 144 | | : !string.Equals(message.MutationManifestAlgorithm, ManifestAlgorithm, StringComparison.Ordinal) |
| | 25 | 145 | | ? "unsupported-manifest-algorithm" |
| | 25 | 146 | | : !string.Equals(message.PersistenceOutcome, CommittedOutcome, StringComparison.Ordinal) |
| | 25 | 147 | | ? "unsupported-persistence-outcome" |
| | 25 | 148 | | : !IsSha256Hex(message.MutationManifestHash) |
| | 25 | 149 | | ? "invalid-manifest-hash" |
| | 25 | 150 | | : string.IsNullOrWhiteSpace(message.MutationBatchId) |
| | 25 | 151 | | ? "mutation-batch-id-required" |
| | 25 | 152 | | : message.AuditRecordCount < 1 |
| | 25 | 153 | | ? "audit-record-count-invalid" |
| | 25 | 154 | | : !IsValidDestination(message.Destination) |
| | 25 | 155 | | ? "invalid-destination" |
| | 25 | 156 | | : HasWhitespaceOnlyIdentifier(message) |
| | 25 | 157 | | ? "malformed-optional-identifier" |
| | 25 | 158 | | : !string.Equals( |
| | 25 | 159 | | message.IdempotencyKey, |
| | 25 | 160 | | ComputeIdempotencyKey(message.Destination, message.MutationBatchId), |
| | 25 | 161 | | StringComparison.Ordinal) |
| | 25 | 162 | | ? "idempotency-key-mismatch" |
| | 25 | 163 | | : null; |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | // Version 1 of the contract encodes digests as uppercase hex, so lowercase a-f is not a v1 digest. |
| | | 167 | | private static bool IsSha256Hex(string? value) |
| | | 168 | | { |
| | 37 | 169 | | return value is { Length: Sha256HexLength } && value.All(char.IsAsciiHexDigitUpper); |
| | | 170 | | } |
| | | 171 | | |
| | | 172 | | private static bool IsValidDestination(string? destination) |
| | | 173 | | { |
| | 12 | 174 | | return !string.IsNullOrWhiteSpace(destination) && |
| | 12 | 175 | | destination.Length <= MaximumDestinationLength && |
| | 12 | 176 | | string.Equals(destination, destination.Trim(), StringComparison.Ordinal); |
| | | 177 | | } |
| | | 178 | | |
| | | 179 | | private static bool HasWhitespaceOnlyIdentifier(NcatAuditCompletionMessage message) |
| | | 180 | | { |
| | 11 | 181 | | string?[] identifiers = |
| | 11 | 182 | | [ |
| | 11 | 183 | | message.OperationExecutionId, |
| | 11 | 184 | | message.ExecutionAttemptId, |
| | 11 | 185 | | message.DecisionAuditRecordId, |
| | 11 | 186 | | message.CorrelationId, |
| | 11 | 187 | | message.TraceId |
| | 11 | 188 | | ]; |
| | | 189 | | |
| | 11 | 190 | | return identifiers.Any(identifier => identifier is not null && string.IsNullOrWhiteSpace(identifier)); |
| | | 191 | | } |
| | | 192 | | |
| | | 193 | | private static bool DigestMatches(string expectedHex, byte[] actualDigest) |
| | | 194 | | { |
| | 5 | 195 | | if (!IsSha256Hex(expectedHex)) |
| | | 196 | | { |
| | 0 | 197 | | return false; |
| | | 198 | | } |
| | | 199 | | |
| | 5 | 200 | | byte[] expected = Convert.FromHexString(expectedHex); |
| | 5 | 201 | | return CryptographicOperations.FixedTimeEquals(expected, actualDigest); |
| | | 202 | | } |
| | | 203 | | |
| | | 204 | | private static bool TryReadManifestHeader( |
| | | 205 | | ReadOnlySpan<byte> canonicalManifestUtf8, |
| | | 206 | | [NotNullWhen(true)] out ManifestHeader? header) |
| | | 207 | | { |
| | 10 | 208 | | header = null; |
| | | 209 | | |
| | | 210 | | try |
| | | 211 | | { |
| | 10 | 212 | | Utf8JsonReader reader = new(canonicalManifestUtf8); |
| | 10 | 213 | | using var document = JsonDocument.ParseValue(ref reader); |
| | | 214 | | |
| | | 215 | | // A canonical manifest is exactly one JSON value. Trailing whitespace is skipped by the reader; |
| | | 216 | | // any further token means the digest covers content other than the manifest. |
| | 10 | 217 | | if (reader.Read()) |
| | | 218 | | { |
| | 0 | 219 | | return false; |
| | | 220 | | } |
| | | 221 | | |
| | 6 | 222 | | JsonElement root = document.RootElement; |
| | | 223 | | |
| | 6 | 224 | | if (root.ValueKind != JsonValueKind.Object || |
| | 6 | 225 | | !root.TryGetProperty("schemaVersion", out JsonElement schemaVersion) || |
| | 6 | 226 | | schemaVersion.ValueKind != JsonValueKind.String || |
| | 6 | 227 | | !root.TryGetProperty("mutationBatchId", out JsonElement mutationBatchId) || |
| | 6 | 228 | | mutationBatchId.ValueKind != JsonValueKind.String || |
| | 6 | 229 | | !root.TryGetProperty("auditRecordCount", out JsonElement auditRecordCount) || |
| | 6 | 230 | | !auditRecordCount.TryGetInt32(out int count)) |
| | | 231 | | { |
| | 0 | 232 | | return false; |
| | | 233 | | } |
| | | 234 | | |
| | 6 | 235 | | header = new ManifestHeader(schemaVersion.GetString()!, mutationBatchId.GetString()!, count); |
| | 6 | 236 | | return true; |
| | | 237 | | } |
| | 4 | 238 | | catch (JsonException) |
| | | 239 | | { |
| | 4 | 240 | | return false; |
| | | 241 | | } |
| | 10 | 242 | | } |
| | | 243 | | |
| | | 244 | | private sealed record ManifestHeader(string SchemaVersion, string MutationBatchId, int AuditRecordCount); |
| | | 245 | | } |