| | | 1 | | namespace AsiBackbone.Core.Signing; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Provides provider-neutral helpers for verifying signed governance artifacts and applying verification policy. |
| | | 5 | | /// </summary> |
| | | 6 | | /// <remarks> |
| | | 7 | | /// The verifier wrapper does not resolve provider-specific keys in Core and does not imply legal evidence, compliance c |
| | | 8 | | /// </remarks> |
| | | 9 | | public static class GovernanceArtifactVerifier |
| | | 10 | | { |
| | | 11 | | /// <summary> |
| | | 12 | | /// Verifies a signed governance artifact's retained canonical payload and maps the result to a host-facing policy o |
| | | 13 | | /// </summary> |
| | | 14 | | /// <remarks> |
| | | 15 | | /// This payload-only path establishes that <see cref="SignedGovernanceArtifact{TArtifact}.CanonicalPayload" /> hash |
| | | 16 | | /// the signed canonical hash. It does not establish that <see cref="SignedGovernanceArtifact{TArtifact}.Artifact" / |
| | | 17 | | /// corresponds to that payload. Use <see cref="VerifyTypedAsync{TArtifact}" /> when the typed artifact will be cons |
| | | 18 | | /// after verification. |
| | | 19 | | /// </remarks> |
| | | 20 | | public static async ValueTask<VerificationPolicyOutcome> VerifyAsync<TArtifact>( |
| | | 21 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 22 | | IGovernanceSignatureVerificationService verificationService, |
| | | 23 | | VerificationPolicyOptions? options = null, |
| | | 24 | | VerificationPolicyContext? context = null, |
| | | 25 | | CancellationToken cancellationToken = default) |
| | | 26 | | { |
| | 53 | 27 | | return await VerifyCoreAsync( |
| | 53 | 28 | | artifact, |
| | 53 | 29 | | verificationService, |
| | 53 | 30 | | canonicalPayloadBuilder: null, |
| | 53 | 31 | | options, |
| | 53 | 32 | | context, |
| | 53 | 33 | | cancellationToken).ConfigureAwait(false); |
| | 53 | 34 | | } |
| | | 35 | | |
| | | 36 | | /// <summary> |
| | | 37 | | /// Verifies a signed governance artifact, binds its typed artifact to the signed canonical payload, and maps the re |
| | | 38 | | /// to a host-facing policy outcome. |
| | | 39 | | /// </summary> |
| | | 40 | | /// <remarks> |
| | | 41 | | /// The supplied builder must use the same canonicalization options and schema rules that were used when the artifac |
| | | 42 | | /// was signed. The rebuilt payload is hashed with the recorded hash algorithm and compared with the signed canonica |
| | | 43 | | /// hash before the verification provider is called. A mismatch fails closed with |
| | | 44 | | /// <c>signature.typed-artifact-mismatch</c> in the <see cref="SignatureVerificationCategory.HashMismatch" /> catego |
| | | 45 | | /// </remarks> |
| | | 46 | | public static async ValueTask<VerificationPolicyOutcome> VerifyTypedAsync<TArtifact>( |
| | | 47 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 48 | | IGovernanceSignatureVerificationService verificationService, |
| | | 49 | | Func<TArtifact, CanonicalPayload> canonicalPayloadBuilder, |
| | | 50 | | VerificationPolicyOptions? options = null, |
| | | 51 | | VerificationPolicyContext? context = null, |
| | | 52 | | CancellationToken cancellationToken = default) |
| | | 53 | | { |
| | 9 | 54 | | ArgumentNullException.ThrowIfNull(canonicalPayloadBuilder); |
| | | 55 | | |
| | 9 | 56 | | return await VerifyCoreAsync( |
| | 9 | 57 | | artifact, |
| | 9 | 58 | | verificationService, |
| | 9 | 59 | | canonicalPayloadBuilder, |
| | 9 | 60 | | options, |
| | 9 | 61 | | context, |
| | 9 | 62 | | cancellationToken).ConfigureAwait(false); |
| | 9 | 63 | | } |
| | | 64 | | |
| | | 65 | | private static async ValueTask<VerificationPolicyOutcome> VerifyCoreAsync<TArtifact>( |
| | | 66 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 67 | | IGovernanceSignatureVerificationService verificationService, |
| | | 68 | | Func<TArtifact, CanonicalPayload>? canonicalPayloadBuilder, |
| | | 69 | | VerificationPolicyOptions? options, |
| | | 70 | | VerificationPolicyContext? context, |
| | | 71 | | CancellationToken cancellationToken) |
| | | 72 | | { |
| | 62 | 73 | | ArgumentNullException.ThrowIfNull(artifact); |
| | 62 | 74 | | ArgumentNullException.ThrowIfNull(verificationService); |
| | 62 | 75 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 76 | | |
| | 62 | 77 | | VerificationPolicyContext effectiveContext = context ?? VerificationPolicyContext.Default; |
| | 62 | 78 | | SignatureVerificationResult? preflightResult = ValidateBeforeProvider( |
| | 62 | 79 | | artifact, |
| | 62 | 80 | | effectiveContext, |
| | 62 | 81 | | canonicalPayloadBuilder); |
| | | 82 | | |
| | 62 | 83 | | if (preflightResult is not null) |
| | | 84 | | { |
| | 22 | 85 | | return VerificationPolicyEvaluator.Evaluate(artifact, preflightResult, options); |
| | | 86 | | } |
| | | 87 | | |
| | | 88 | | try |
| | | 89 | | { |
| | | 90 | | // The version 1 input binds the canonical descriptors, hash, and signing policy context, so a relabeled |
| | | 91 | | // policy_version or policy_hash no longer verifies. Previously the provider was asked to verify the hash te |
| | | 92 | | // alone and every signing metadata label was unauthenticated. |
| | 40 | 93 | | SignatureVerificationResult verificationResult = await verificationService |
| | 40 | 94 | | .VerifyAsync( |
| | 40 | 95 | | CreateVerificationRequest( |
| | 40 | 96 | | artifact, |
| | 40 | 97 | | effectiveContext, |
| | 40 | 98 | | GovernanceSignatureInput.CreateV1(artifact.CanonicalHash, artifact.SigningMetadata.Metadata)), |
| | 40 | 99 | | cancellationToken) |
| | 40 | 100 | | .ConfigureAwait(false); |
| | | 101 | | |
| | 36 | 102 | | if (!verificationResult.IsValid |
| | 36 | 103 | | && effectiveContext.AllowLegacySignatureInput |
| | 36 | 104 | | && VerificationPolicyEvaluator.Categorize(verificationResult) is SignatureVerificationCategory.InvalidSi |
| | | 105 | | { |
| | 2 | 106 | | verificationResult = await VerifyLegacySignatureInputAsync( |
| | 2 | 107 | | artifact, |
| | 2 | 108 | | verificationService, |
| | 2 | 109 | | effectiveContext, |
| | 2 | 110 | | verificationResult, |
| | 2 | 111 | | cancellationToken).ConfigureAwait(false); |
| | | 112 | | } |
| | | 113 | | |
| | 36 | 114 | | return VerificationPolicyEvaluator.Evaluate(artifact, verificationResult, options); |
| | | 115 | | } |
| | 2 | 116 | | catch (InvalidOperationException exception) |
| | | 117 | | { |
| | 2 | 118 | | return CreateProviderUnavailableOutcome(artifact, options, exception); |
| | | 119 | | } |
| | 1 | 120 | | catch (NotSupportedException exception) |
| | | 121 | | { |
| | 1 | 122 | | return CreateProviderUnavailableOutcome(artifact, options, exception); |
| | | 123 | | } |
| | 1 | 124 | | catch (TimeoutException exception) |
| | | 125 | | { |
| | 1 | 126 | | return CreateProviderUnavailableOutcome(artifact, options, exception); |
| | | 127 | | } |
| | 62 | 128 | | } |
| | | 129 | | |
| | | 130 | | /// <summary> |
| | | 131 | | /// Verifies a pre-6.0 artifact against the hash-only signature input after version 1 verification failed. |
| | | 132 | | /// </summary> |
| | | 133 | | /// <remarks> |
| | | 134 | | /// Runs only when the host opted in through <see cref="VerificationPolicyContext.WithLegacySignatureInputAllowed" / |
| | | 135 | | /// and the version 1 attempt failed as an invalid signature. A legacy signature authenticates the canonical payload |
| | | 136 | | /// only, so the policy labels it carries are unauthenticated and cannot satisfy a policy pin. If the legacy attempt |
| | | 137 | | /// also fails, the version 1 failure is reported, because it describes the current format. |
| | | 138 | | /// </remarks> |
| | | 139 | | private static async ValueTask<SignatureVerificationResult> VerifyLegacySignatureInputAsync<TArtifact>( |
| | | 140 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 141 | | IGovernanceSignatureVerificationService verificationService, |
| | | 142 | | VerificationPolicyContext context, |
| | | 143 | | SignatureVerificationResult versionOneResult, |
| | | 144 | | CancellationToken cancellationToken) |
| | | 145 | | { |
| | | 146 | | #pragma warning disable ASIB902 // Explicit legacy-verification path requested by the host. |
| | 2 | 147 | | SignatureVerificationResult legacyResult = await verificationService |
| | 2 | 148 | | .VerifyAsync( |
| | 2 | 149 | | CreateVerificationRequest( |
| | 2 | 150 | | artifact, |
| | 2 | 151 | | context, |
| | 2 | 152 | | GovernanceSignatureInput.CreateLegacy(artifact.SigningHash)), |
| | 2 | 153 | | cancellationToken) |
| | 2 | 154 | | .ConfigureAwait(false); |
| | | 155 | | #pragma warning restore ASIB902 |
| | | 156 | | |
| | 2 | 157 | | return !legacyResult.IsValid |
| | 2 | 158 | | ? versionOneResult |
| | 2 | 159 | | : context.ExpectedPolicyVersion is not null || context.ExpectedPolicyHash is not null |
| | 2 | 160 | | ? SignatureVerificationResult.Failed( |
| | 2 | 161 | | "signature.policy-context-not-authenticated", |
| | 2 | 162 | | SignatureVerificationCategory.UntrustedSigningContext, |
| | 2 | 163 | | "The artifact carries a pre-6.0 signature that does not cover the signing policy context, so it cannot s |
| | 2 | 164 | | : legacyResult; |
| | 2 | 165 | | } |
| | | 166 | | |
| | | 167 | | private static SignatureVerificationRequest CreateVerificationRequest<TArtifact>( |
| | | 168 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 169 | | VerificationPolicyContext context, |
| | | 170 | | ReadOnlyMemory<byte> signatureInput) |
| | | 171 | | { |
| | 42 | 172 | | return new SignatureVerificationRequest( |
| | 42 | 173 | | artifact.SigningHash, |
| | 42 | 174 | | artifact.SigningMetadata, |
| | 42 | 175 | | purpose: context.Purpose ?? artifact.ArtifactType, |
| | 42 | 176 | | metadata: context.Metadata) |
| | 42 | 177 | | { |
| | 42 | 178 | | SignatureInput = signatureInput |
| | 42 | 179 | | }; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | private static VerificationPolicyOutcome CreateProviderUnavailableOutcome<TArtifact>( |
| | | 183 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 184 | | VerificationPolicyOptions? options, |
| | | 185 | | Exception exception) |
| | | 186 | | { |
| | 4 | 187 | | var providerUnavailableResult = SignatureVerificationResult.Failed( |
| | 4 | 188 | | "signature.provider-unavailable", |
| | 4 | 189 | | SignatureVerificationCategory.ProviderUnavailable, |
| | 4 | 190 | | exception.GetType().Name); |
| | | 191 | | |
| | 4 | 192 | | return VerificationPolicyEvaluator.Evaluate(artifact, providerUnavailableResult, options); |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | private static SignatureVerificationResult? ValidateBeforeProvider<TArtifact>( |
| | | 196 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 197 | | VerificationPolicyContext context, |
| | | 198 | | Func<TArtifact, CanonicalPayload>? canonicalPayloadBuilder) |
| | | 199 | | { |
| | 62 | 200 | | SignatureVerificationResult? metadataResult = ValidateSigningMetadata(artifact, context); |
| | | 201 | | |
| | 62 | 202 | | if (metadataResult is not null) |
| | | 203 | | { |
| | 20 | 204 | | return metadataResult; |
| | | 205 | | } |
| | | 206 | | |
| | 42 | 207 | | SignatureVerificationResult? canonicalBindingResult = ValidateCanonicalBinding(artifact); |
| | | 208 | | |
| | 42 | 209 | | return canonicalBindingResult ?? ValidateTypedArtifactBinding(artifact, canonicalPayloadBuilder); |
| | | 210 | | } |
| | | 211 | | |
| | | 212 | | private static SignatureVerificationResult? ValidateTypedArtifactBinding<TArtifact>( |
| | | 213 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 214 | | Func<TArtifact, CanonicalPayload>? canonicalPayloadBuilder) |
| | | 215 | | { |
| | 41 | 216 | | if (canonicalPayloadBuilder is null) |
| | | 217 | | { |
| | 32 | 218 | | return null; |
| | | 219 | | } |
| | | 220 | | |
| | 9 | 221 | | CanonicalPayload rebuiltPayload = canonicalPayloadBuilder(artifact.Artifact) |
| | 9 | 222 | | ?? throw new InvalidOperationException("The canonical payload builder returned null."); |
| | 9 | 223 | | CanonicalPayloadHash rebuiltHash = CanonicalPayloadHasher.ComputeHash(rebuiltPayload, artifact.HashAlgorithm); |
| | | 224 | | |
| | 9 | 225 | | return string.Equals(rebuiltHash.HashValue, artifact.CanonicalHash.HashValue, StringComparison.Ordinal) |
| | 9 | 226 | | ? null |
| | 9 | 227 | | : SignatureVerificationResult.Failed( |
| | 9 | 228 | | "signature.typed-artifact-mismatch", |
| | 9 | 229 | | SignatureVerificationCategory.HashMismatch, |
| | 9 | 230 | | "The typed artifact does not rebuild to the signed canonical payload hash."); |
| | | 231 | | } |
| | | 232 | | |
| | | 233 | | /// <summary> |
| | | 234 | | /// Recomputes the canonical payload hash and rejects an artifact whose signed hash is not the hash of its own canon |
| | | 235 | | /// </summary> |
| | | 236 | | /// <remarks> |
| | | 237 | | /// Without this step the provider verifies a signature over a hash the artifact carries about itself, which leaves |
| | | 238 | | /// artifact content unbound to the signature. A caller that rehydrates an artifact from storage or a queue can othe |
| | | 239 | | /// present modified content beside an authentic hash and signature pair and receive a valid outcome. |
| | | 240 | | /// </remarks> |
| | | 241 | | private static SignatureVerificationResult? ValidateCanonicalBinding<TArtifact>( |
| | | 242 | | SignedGovernanceArtifact<TArtifact> artifact) |
| | | 243 | | { |
| | | 244 | | CanonicalPayloadHash recomputedHash; |
| | | 245 | | |
| | | 246 | | try |
| | | 247 | | { |
| | 42 | 248 | | recomputedHash = CanonicalPayloadHasher.ComputeHash(artifact.CanonicalPayload, artifact.HashAlgorithm); |
| | 42 | 249 | | } |
| | 0 | 250 | | catch (NotSupportedException) |
| | | 251 | | { |
| | 0 | 252 | | return SignatureVerificationResult.Failed( |
| | 0 | 253 | | "signature.hash-algorithm-unsupported", |
| | 0 | 254 | | SignatureVerificationCategory.UnsupportedAlgorithm, |
| | 0 | 255 | | "The canonical payload hash cannot be recomputed with the built-in hasher, so the signed hash is not bou |
| | | 256 | | } |
| | | 257 | | |
| | 42 | 258 | | return string.Equals(recomputedHash.HashValue, artifact.CanonicalHash.HashValue, StringComparison.Ordinal) |
| | 42 | 259 | | ? null |
| | 42 | 260 | | : SignatureVerificationResult.Failed( |
| | 42 | 261 | | "signature.hash-mismatch", |
| | 42 | 262 | | SignatureVerificationCategory.HashMismatch, |
| | 42 | 263 | | "The canonical payload does not hash to the signed canonical hash value."); |
| | 0 | 264 | | } |
| | | 265 | | |
| | | 266 | | private static SignatureVerificationResult? ValidateSigningMetadata<TArtifact>( |
| | | 267 | | SignedGovernanceArtifact<TArtifact> artifact, |
| | | 268 | | VerificationPolicyContext context) |
| | | 269 | | { |
| | 62 | 270 | | SigningMetadata metadata = artifact.SigningMetadata; |
| | | 271 | | |
| | 62 | 272 | | return artifact.HasNoSignature || !metadata.HasSignature |
| | 62 | 273 | | ? SignatureVerificationResult.MissingSignature("The governance artifact does not carry signature metadata.") |
| | 62 | 274 | | : string.IsNullOrWhiteSpace(metadata.SigningHash) |
| | 62 | 275 | | ? SignatureVerificationResult.MissingSignature("The governance artifact does not carry the hash that was sig |
| | 62 | 276 | | : !string.Equals(metadata.SigningHash, artifact.SigningHash, StringComparison.Ordinal) |
| | 62 | 277 | | ? SignatureVerificationResult.Failed( |
| | 62 | 278 | | "signature.hash-mismatch", |
| | 62 | 279 | | SignatureVerificationCategory.HashMismatch, |
| | 62 | 280 | | "The signing metadata hash does not match the canonical artifact hash.") |
| | 62 | 281 | | : metadata.HashAlgorithm is not null |
| | 62 | 282 | | && !string.Equals(metadata.HashAlgorithm, artifact.HashAlgorithm, StringComparison.OrdinalIgnoreCase) |
| | 62 | 283 | | ? SignatureVerificationResult.Failed( |
| | 62 | 284 | | "signature.hash-algorithm-unsupported", |
| | 62 | 285 | | SignatureVerificationCategory.HashMismatch, |
| | 62 | 286 | | "The signing metadata hash algorithm does not match the canonical artifact hash algorithm.") |
| | 62 | 287 | | : context.RequiredHashAlgorithm is not null |
| | 62 | 288 | | && !string.Equals(context.RequiredHashAlgorithm, artifact.HashAlgorithm, StringComparison.OrdinalIgnoreCase) |
| | 62 | 289 | | ? SignatureVerificationResult.Failed( |
| | 62 | 290 | | "signature.hash-algorithm-unsupported", |
| | 62 | 291 | | SignatureVerificationCategory.HashMismatch, |
| | 62 | 292 | | "The canonical artifact hash algorithm does not match the required verification policy algorithm.") |
| | 62 | 293 | | : !MatchesCanonicalMetadata(metadata, "artifact_id", artifact.ArtifactId) |
| | 62 | 294 | | || !MatchesCanonicalMetadata(metadata, "artifact_type", artifact.ArtifactType) |
| | 62 | 295 | | || !MatchesCanonicalMetadata(metadata, "canonicalization_version", artifact.CanonicalHash.CanonicalizationVe |
| | 62 | 296 | | || !MatchesCanonicalMetadata(metadata, "payload_schema_version", artifact.CanonicalHash.PayloadSchemaVersion |
| | 62 | 297 | | ? SignatureVerificationResult.Failed( |
| | 62 | 298 | | "signature.canonicalization-mismatch", |
| | 62 | 299 | | SignatureVerificationCategory.CanonicalizationMismatch, |
| | 62 | 300 | | "The signing metadata canonical artifact descriptors do not match the artifact being verified.") |
| | 62 | 301 | | : context.ExpectedKeyId is not null |
| | 62 | 302 | | && !string.Equals(context.ExpectedKeyId, metadata.KeyId, StringComparison.Ordinal) |
| | 62 | 303 | | ? SignatureVerificationResult.Failed( |
| | 62 | 304 | | "signature.key-not-trusted", |
| | 62 | 305 | | SignatureVerificationCategory.UntrustedKey, |
| | 62 | 306 | | "The signing key identifier does not match the verification policy expectation.") |
| | 62 | 307 | | : context.ExpectedKeyVersion is not null |
| | 62 | 308 | | && !string.Equals(context.ExpectedKeyVersion, metadata.KeyVersion, StringComparison.Ordinal) |
| | 62 | 309 | | ? SignatureVerificationResult.Failed( |
| | 62 | 310 | | "signature.key-not-trusted", |
| | 62 | 311 | | SignatureVerificationCategory.UntrustedKey, |
| | 62 | 312 | | "The signing key version does not match the verification policy expectation.") |
| | 62 | 313 | | // A provider or policy-context pin mismatch is a trust decision, not an outage. Reporting it as |
| | 62 | 314 | | // ProviderUnavailable (Defer) or CanonicalizationMismatch (formerly Escalate) gave an artifact signed under |
| | 62 | 315 | | // wrong provider or policy a softer outcome than a bad signature, the same defect 5.0 corrected for key pin |
| | 62 | 316 | | : context.RequiredProvider is not null |
| | 62 | 317 | | && !string.Equals(context.RequiredProvider, metadata.Provider, StringComparison.Ordinal) |
| | 62 | 318 | | ? SignatureVerificationResult.Failed( |
| | 62 | 319 | | "signature.provider-not-trusted", |
| | 62 | 320 | | SignatureVerificationCategory.UntrustedSigningContext, |
| | 62 | 321 | | "The signing provider does not match the required verification policy provider.") |
| | 62 | 322 | | : !MatchesOptionalPolicyMetadata(metadata, "policy_version", context.ExpectedPolicyVersion) |
| | 62 | 323 | | || !MatchesOptionalPolicyMetadata(metadata, "policy_hash", context.ExpectedPolicyHash) |
| | 62 | 324 | | ? SignatureVerificationResult.Failed( |
| | 62 | 325 | | "signature.policy-context-not-trusted", |
| | 62 | 326 | | SignatureVerificationCategory.UntrustedSigningContext, |
| | 62 | 327 | | "The signing metadata policy context does not match the verification policy expectation.") |
| | 62 | 328 | | : null; |
| | | 329 | | } |
| | | 330 | | |
| | | 331 | | /// <summary> |
| | | 332 | | /// Requires a canonical descriptor to be present in signing metadata and to match the artifact being verified. |
| | | 333 | | /// </summary> |
| | | 334 | | /// <remarks> |
| | | 335 | | /// An absent descriptor previously matched anything, which let a signature produced for one artifact type or identi |
| | | 336 | | /// be presented alongside a different artifact. The shipped factories always write these descriptors, so a signed |
| | | 337 | | /// artifact that is missing one did not come from a canonical signing path. |
| | | 338 | | /// </remarks> |
| | | 339 | | private static bool MatchesCanonicalMetadata(SigningMetadata metadata, string key, string expectedValue) |
| | | 340 | | { |
| | 216 | 341 | | return metadata.Metadata.TryGetValue(key, out string? value) |
| | 216 | 342 | | && string.Equals(value, expectedValue, StringComparison.Ordinal); |
| | | 343 | | } |
| | | 344 | | |
| | | 345 | | private static bool MatchesOptionalPolicyMetadata(SigningMetadata metadata, string key, string? expectedValue) |
| | | 346 | | { |
| | 90 | 347 | | return expectedValue is null |
| | 90 | 348 | | || (metadata.Metadata.TryGetValue(key, out string? value) |
| | 90 | 349 | | && string.Equals(value, expectedValue, StringComparison.Ordinal)); |
| | | 350 | | } |
| | | 351 | | } |