Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
|
||||
namespace StellaOps.Attestor.Verify;
|
||||
|
||||
public sealed class AttestorVerificationEngine : IAttestorVerificationEngine
|
||||
{
|
||||
private readonly IDsseCanonicalizer _canonicalizer;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly ILogger<AttestorVerificationEngine> _logger;
|
||||
|
||||
public AttestorVerificationEngine(
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationEngine> logger)
|
||||
{
|
||||
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<VerificationReport> EvaluateAsync(
|
||||
AttestorEntry entry,
|
||||
AttestorSubmissionRequest.SubmissionBundle? bundle,
|
||||
DateTimeOffset evaluationTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
var signatureIssuer = await EvaluateSignatureAndIssuerAsync(entry, bundle, cancellationToken).ConfigureAwait(false);
|
||||
var freshness = EvaluateFreshness(entry, evaluationTime);
|
||||
var transparency = EvaluateTransparency(entry);
|
||||
var policy = EvaluatePolicy(entry, signatureIssuer.Signatures, signatureIssuer.Issuer, freshness, transparency, bundle is not null);
|
||||
|
||||
return new VerificationReport(policy, signatureIssuer.Issuer, freshness, signatureIssuer.Signatures, transparency);
|
||||
}
|
||||
|
||||
private async Task<(SignatureEvaluationResult Signatures, IssuerEvaluationResult Issuer)> EvaluateSignatureAndIssuerAsync(
|
||||
AttestorEntry entry,
|
||||
AttestorSubmissionRequest.SubmissionBundle? bundle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var signatureIssues = new List<string>();
|
||||
var issuerIssues = new List<string>();
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
var issuerFromEntry = entry.SignerIdentity;
|
||||
return (
|
||||
new SignatureEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Skipped,
|
||||
BundleProvided = false,
|
||||
TotalSignatures = 0,
|
||||
VerifiedSignatures = 0,
|
||||
RequiredSignatures = Math.Max(1, _options.Verification.MinimumSignatures),
|
||||
Issues = Array.Empty<string>()
|
||||
},
|
||||
new IssuerEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Skipped,
|
||||
Mode = issuerFromEntry.Mode ?? "unknown",
|
||||
Issuer = issuerFromEntry.Issuer,
|
||||
SubjectAlternativeName = issuerFromEntry.SubjectAlternativeName,
|
||||
KeyId = issuerFromEntry.KeyId,
|
||||
Issues = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
|
||||
var canonicalRequest = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = bundle,
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind,
|
||||
ImageDigest = entry.Artifact.ImageDigest,
|
||||
SubjectUri = entry.Artifact.SubjectUri
|
||||
},
|
||||
BundleSha256 = entry.BundleSha256
|
||||
}
|
||||
};
|
||||
|
||||
byte[] canonicalBundle;
|
||||
try
|
||||
{
|
||||
canonicalBundle = await _canonicalizer.CanonicalizeAsync(canonicalRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||
{
|
||||
signatureIssues.Add("bundle_canonicalize_failed");
|
||||
_logger.LogWarning(ex, "Failed to canonicalize DSSE bundle for {Uuid}", entry.RekorUuid);
|
||||
|
||||
var issuerFromEntry = entry.SignerIdentity;
|
||||
return (
|
||||
new SignatureEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Fail,
|
||||
BundleProvided = true,
|
||||
TotalSignatures = bundle.Dsse.Signatures.Count,
|
||||
VerifiedSignatures = 0,
|
||||
RequiredSignatures = Math.Max(1, _options.Verification.MinimumSignatures),
|
||||
Issues = signatureIssues.ToArray()
|
||||
},
|
||||
new IssuerEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Warn,
|
||||
Mode = issuerFromEntry.Mode ?? (bundle.Mode ?? "unknown"),
|
||||
Issuer = issuerFromEntry.Issuer,
|
||||
SubjectAlternativeName = issuerFromEntry.SubjectAlternativeName,
|
||||
KeyId = issuerFromEntry.KeyId,
|
||||
Issues = new[] { "issuer_verification_skipped" }
|
||||
});
|
||||
}
|
||||
|
||||
var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
signatureIssues.Add("bundle_hash_mismatch");
|
||||
}
|
||||
|
||||
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? "unknown").ToLowerInvariant();
|
||||
var requiredSignatures = Math.Max(1, _options.Verification.MinimumSignatures);
|
||||
var totalSignatures = bundle.Dsse.Signatures.Count;
|
||||
var verifiedSignatures = 0;
|
||||
string? subjectAlternativeName = null;
|
||||
|
||||
if (!TryDecodeBase64(bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
signatureIssues.Add("bundle_payload_invalid_base64");
|
||||
|
||||
return (
|
||||
new SignatureEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Fail,
|
||||
BundleProvided = true,
|
||||
TotalSignatures = bundle.Dsse.Signatures.Count,
|
||||
VerifiedSignatures = 0,
|
||||
RequiredSignatures = requiredSignatures,
|
||||
Issues = signatureIssues.ToArray()
|
||||
},
|
||||
new IssuerEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Warn,
|
||||
Mode = mode,
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId,
|
||||
Issues = issuerIssues.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var preAuth = ComputePreAuthEncoding(bundle.Dsse.PayloadType, payloadBytes);
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case "kms":
|
||||
verifiedSignatures = EvaluateKmsSignature(bundle, preAuth, signatureIssues, issuerIssues);
|
||||
break;
|
||||
|
||||
case "keyless":
|
||||
var keylessResult = EvaluateKeylessSignature(entry, bundle, preAuth, signatureIssues, issuerIssues);
|
||||
verifiedSignatures = keylessResult.VerifiedSignatures;
|
||||
subjectAlternativeName = keylessResult.SubjectAlternativeName;
|
||||
break;
|
||||
|
||||
default:
|
||||
issuerIssues.Add(string.IsNullOrWhiteSpace(mode) ? "signer_mode_unknown" : $"signer_mode_unsupported:{mode}");
|
||||
break;
|
||||
}
|
||||
|
||||
var signatureStatus = DetermineSignatureStatus(signatureIssues, verifiedSignatures, requiredSignatures, totalSignatures);
|
||||
var issuerStatus = DetermineIssuerStatus(issuerIssues, mode, verifiedSignatures > 0);
|
||||
|
||||
return (
|
||||
new SignatureEvaluationResult
|
||||
{
|
||||
Status = signatureStatus,
|
||||
BundleProvided = true,
|
||||
TotalSignatures = totalSignatures,
|
||||
VerifiedSignatures = verifiedSignatures,
|
||||
RequiredSignatures = requiredSignatures,
|
||||
Issues = signatureIssues.ToArray()
|
||||
},
|
||||
new IssuerEvaluationResult
|
||||
{
|
||||
Status = issuerStatus,
|
||||
Mode = mode,
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = subjectAlternativeName ?? entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId,
|
||||
Issues = issuerIssues.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
private int EvaluateKmsSignature(
|
||||
AttestorSubmissionRequest.SubmissionBundle bundle,
|
||||
byte[] preAuthEncoding,
|
||||
List<string> signatureIssues,
|
||||
List<string> issuerIssues)
|
||||
{
|
||||
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
|
||||
{
|
||||
issuerIssues.Add("kms_key_missing");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
signatureIssues.Add("signature_invalid_base64");
|
||||
return 0;
|
||||
}
|
||||
|
||||
signatures.Add(signatureBytes);
|
||||
}
|
||||
|
||||
var verified = 0;
|
||||
|
||||
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
||||
{
|
||||
if (!TryDecodeSecret(secret, out var secretBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var computed = hmac.ComputeHash(preAuthEncoding);
|
||||
|
||||
foreach (var candidate in signatures)
|
||||
{
|
||||
if (CryptographicOperations.FixedTimeEquals(computed, candidate))
|
||||
{
|
||||
verified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (verified == 0)
|
||||
{
|
||||
signatureIssues.Add("signature_invalid");
|
||||
}
|
||||
|
||||
return verified;
|
||||
}
|
||||
|
||||
private (int VerifiedSignatures, string? SubjectAlternativeName) EvaluateKeylessSignature(
|
||||
AttestorEntry entry,
|
||||
AttestorSubmissionRequest.SubmissionBundle bundle,
|
||||
byte[] preAuthEncoding,
|
||||
List<string> signatureIssues,
|
||||
List<string> issuerIssues)
|
||||
{
|
||||
if (bundle.CertificateChain.Count == 0)
|
||||
{
|
||||
issuerIssues.Add("certificate_chain_missing");
|
||||
return (0, null);
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
foreach (var pem in bundle.CertificateChain)
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPem(pem));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
issuerIssues.Add("certificate_chain_invalid");
|
||||
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
|
||||
return (0, null);
|
||||
}
|
||||
|
||||
var leafCertificate = certificates[0];
|
||||
var subjectAltName = GetSubjectAlternativeNames(leafCertificate).FirstOrDefault();
|
||||
|
||||
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(rootPath))
|
||||
{
|
||||
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chain.Build(leafCertificate))
|
||||
{
|
||||
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim())).Trim(';');
|
||||
issuerIssues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
|
||||
{
|
||||
var sans = GetSubjectAlternativeNames(leafCertificate);
|
||||
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
issuerIssues.Add("certificate_san_untrusted");
|
||||
}
|
||||
}
|
||||
|
||||
var verified = 0;
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
signatureIssues.Add("signature_invalid_base64");
|
||||
return (0, subjectAltName);
|
||||
}
|
||||
|
||||
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
|
||||
{
|
||||
verified++;
|
||||
}
|
||||
}
|
||||
|
||||
if (verified == 0)
|
||||
{
|
||||
signatureIssues.Add("signature_invalid");
|
||||
}
|
||||
|
||||
return (verified, subjectAltName);
|
||||
}
|
||||
|
||||
private FreshnessEvaluationResult EvaluateFreshness(AttestorEntry entry, DateTimeOffset evaluationTime)
|
||||
{
|
||||
if (entry.CreatedAt == default)
|
||||
{
|
||||
return new FreshnessEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Warn,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
EvaluatedAt = evaluationTime,
|
||||
Age = TimeSpan.Zero,
|
||||
MaxAge = null,
|
||||
Issues = new[] { "freshness_unknown" }
|
||||
};
|
||||
}
|
||||
|
||||
var age = evaluationTime - entry.CreatedAt;
|
||||
var maxAgeMinutes = _options.Verification.FreshnessMaxAgeMinutes;
|
||||
var warnAgeMinutes = _options.Verification.FreshnessWarnAgeMinutes;
|
||||
|
||||
if (maxAgeMinutes is null)
|
||||
{
|
||||
return new FreshnessEvaluationResult
|
||||
{
|
||||
Status = VerificationSectionStatus.Skipped,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
EvaluatedAt = evaluationTime,
|
||||
Age = age,
|
||||
MaxAge = null,
|
||||
Issues = Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
|
||||
var maxAge = TimeSpan.FromMinutes(maxAgeMinutes.Value);
|
||||
VerificationSectionStatus status;
|
||||
var issues = new List<string>();
|
||||
|
||||
if (age > maxAge)
|
||||
{
|
||||
status = VerificationSectionStatus.Fail;
|
||||
issues.Add("freshness_stale");
|
||||
}
|
||||
else if (warnAgeMinutes is not null && age > TimeSpan.FromMinutes(warnAgeMinutes.Value))
|
||||
{
|
||||
status = VerificationSectionStatus.Warn;
|
||||
issues.Add("freshness_warning");
|
||||
}
|
||||
else
|
||||
{
|
||||
status = VerificationSectionStatus.Pass;
|
||||
}
|
||||
|
||||
return new FreshnessEvaluationResult
|
||||
{
|
||||
Status = status,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
EvaluatedAt = evaluationTime,
|
||||
Age = age,
|
||||
MaxAge = maxAge,
|
||||
Issues = issues.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private TransparencyEvaluationResult EvaluateTransparency(AttestorEntry entry)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
|
||||
TransparencyEvaluationResult Finalize(VerificationSectionStatus finalStatus, bool proofPresent, bool checkpointPresent, bool inclusionPresent)
|
||||
{
|
||||
var witness = entry.Witness;
|
||||
var witnessPresent = witness is not null;
|
||||
var witnessMatches = false;
|
||||
var witnessAggregator = witness?.Aggregator;
|
||||
var witnessStatus = witness?.Status ?? "missing";
|
||||
|
||||
if (witness is null)
|
||||
{
|
||||
issues.Add("witness_missing");
|
||||
if (_options.Verification.RequireWitnessEndorsement)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Fail;
|
||||
}
|
||||
else if (finalStatus != VerificationSectionStatus.Fail)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Warn;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var normalizedStatus = string.IsNullOrWhiteSpace(witness.Status) ? "unknown" : witness.Status!;
|
||||
if (!string.Equals(normalizedStatus, "endorsed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("witness_status_" + normalizedStatus.ToLowerInvariant());
|
||||
if (_options.Verification.RequireWitnessEndorsement)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Fail;
|
||||
}
|
||||
else if (finalStatus != VerificationSectionStatus.Fail)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Warn;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(witness.RootHash) && entry.Proof?.Checkpoint?.RootHash is not null)
|
||||
{
|
||||
if (string.Equals(witness.RootHash, entry.Proof.Checkpoint.RootHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
witnessMatches = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add("witness_root_mismatch");
|
||||
if (_options.Verification.RequireWitnessEndorsement)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Fail;
|
||||
}
|
||||
else if (finalStatus != VerificationSectionStatus.Fail)
|
||||
{
|
||||
finalStatus = VerificationSectionStatus.Warn;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BuildTransparencyResult(finalStatus, issues, proofPresent, checkpointPresent, inclusionPresent, witnessPresent, witnessMatches, witnessAggregator, witnessStatus);
|
||||
}
|
||||
|
||||
if (entry.Proof is null)
|
||||
{
|
||||
issues.Add("proof_missing");
|
||||
var finalStatus = _options.Verification.RequireTransparencyInclusion ? VerificationSectionStatus.Fail : VerificationSectionStatus.Warn;
|
||||
return Finalize(finalStatus, false, false, false);
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
|
||||
{
|
||||
issues.Add("bundle_hash_decode_failed");
|
||||
return Finalize(VerificationSectionStatus.Fail, true, entry.Proof.Checkpoint is not null, entry.Proof.Inclusion is not null);
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion is null)
|
||||
{
|
||||
issues.Add("proof_inclusion_missing");
|
||||
var finalStatus = _options.Verification.RequireTransparencyInclusion ? VerificationSectionStatus.Fail : VerificationSectionStatus.Warn;
|
||||
return Finalize(finalStatus, true, entry.Proof.Checkpoint is not null, false);
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion.LeafHash is not null)
|
||||
{
|
||||
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_decode_failed");
|
||||
return Finalize(VerificationSectionStatus.Fail, true, entry.Proof.Checkpoint is not null, true);
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var current = bundleHash;
|
||||
var inclusionNodesPresent = entry.Proof.Inclusion.Path.Count > 0;
|
||||
|
||||
if (inclusionNodesPresent)
|
||||
{
|
||||
var nodes = new List<ProofPathNode>();
|
||||
foreach (var element in entry.Proof.Inclusion.Path)
|
||||
{
|
||||
if (!ProofPathNode.TryParse(element, out var node))
|
||||
{
|
||||
issues.Add("proof_path_decode_failed");
|
||||
return Finalize(VerificationSectionStatus.Fail, true, entry.Proof.Checkpoint is not null, true);
|
||||
}
|
||||
|
||||
if (!node.HasOrientation)
|
||||
{
|
||||
issues.Add("proof_path_orientation_missing");
|
||||
return Finalize(VerificationSectionStatus.Fail, true, entry.Proof.Checkpoint is not null, true);
|
||||
}
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
current = node.Left ? HashInternal(node.Hash, current) : HashInternal(current, node.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Proof.Checkpoint is null)
|
||||
{
|
||||
issues.Add("checkpoint_missing");
|
||||
var finalStatus = _options.Verification.RequireCheckpoint ? VerificationSectionStatus.Fail : VerificationSectionStatus.Warn;
|
||||
return Finalize(finalStatus, true, false, inclusionNodesPresent);
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
|
||||
{
|
||||
issues.Add("checkpoint_root_decode_failed");
|
||||
return Finalize(VerificationSectionStatus.Fail, true, true, inclusionNodesPresent);
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
|
||||
{
|
||||
issues.Add("proof_root_mismatch");
|
||||
}
|
||||
|
||||
var status = issues.Count == 0 ? VerificationSectionStatus.Pass : VerificationSectionStatus.Fail;
|
||||
return Finalize(status, true, true, inclusionNodesPresent);
|
||||
}
|
||||
|
||||
private PolicyEvaluationResult EvaluatePolicy(
|
||||
AttestorEntry entry,
|
||||
SignatureEvaluationResult signatures,
|
||||
IssuerEvaluationResult issuer,
|
||||
FreshnessEvaluationResult freshness,
|
||||
TransparencyEvaluationResult transparency,
|
||||
bool bundleProvided)
|
||||
{
|
||||
var issues = new List<string>();
|
||||
var status = VerificationSectionStatus.Pass;
|
||||
|
||||
if (!string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add($"log_status_{entry.Status.ToLowerInvariant()}");
|
||||
status = VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
if (_options.Verification.RequireBundleForSignatureValidation && !bundleProvided)
|
||||
{
|
||||
issues.Add("bundle_required");
|
||||
status = VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
status = CombinePolicyStatus(status, signatures.Status, "signatures", issues);
|
||||
status = CombinePolicyStatus(status, issuer.Status, "issuer", issues);
|
||||
status = CombinePolicyStatus(status, freshness.Status, "freshness", issues, warnOnly: true);
|
||||
status = CombinePolicyStatus(status, transparency.Status, "transparency", issues);
|
||||
|
||||
var verdict = status switch
|
||||
{
|
||||
VerificationSectionStatus.Fail => "fail",
|
||||
VerificationSectionStatus.Warn => "warn",
|
||||
VerificationSectionStatus.Pass => "pass",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
var attributes = ImmutableDictionary<string, string>.Empty
|
||||
.Add("status", entry.Status ?? "unknown")
|
||||
.Add("logBackend", entry.Log.Backend ?? "primary")
|
||||
.Add("logUrl", entry.Log.Url ?? string.Empty);
|
||||
|
||||
if (entry.Index.HasValue)
|
||||
{
|
||||
attributes = attributes.Add("index", entry.Index.Value.ToString());
|
||||
}
|
||||
|
||||
if (entry.Proof?.Checkpoint?.Timestamp is not null)
|
||||
{
|
||||
attributes = attributes.Add("checkpointTs", entry.Proof.Checkpoint.Timestamp.Value.ToString("O"));
|
||||
}
|
||||
|
||||
return new PolicyEvaluationResult
|
||||
{
|
||||
Status = status,
|
||||
PolicyId = _options.Verification.PolicyId,
|
||||
PolicyVersion = _options.Verification.PolicyVersion,
|
||||
Verdict = verdict,
|
||||
Issues = issues.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
Attributes = attributes
|
||||
};
|
||||
}
|
||||
|
||||
private static VerificationSectionStatus DetermineSignatureStatus(
|
||||
IReadOnlyCollection<string> issues,
|
||||
int verified,
|
||||
int required,
|
||||
int total)
|
||||
{
|
||||
if (total == 0)
|
||||
{
|
||||
return VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return issues.Contains("signature_invalid", StringComparer.OrdinalIgnoreCase)
|
||||
|| issues.Contains("bundle_payload_invalid_base64", StringComparer.OrdinalIgnoreCase)
|
||||
|| issues.Contains("bundle_hash_mismatch", StringComparer.OrdinalIgnoreCase)
|
||||
? VerificationSectionStatus.Fail
|
||||
: VerificationSectionStatus.Warn;
|
||||
}
|
||||
|
||||
return verified >= required ? VerificationSectionStatus.Pass : VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
private static VerificationSectionStatus DetermineIssuerStatus(
|
||||
IReadOnlyCollection<string> issues,
|
||||
string mode,
|
||||
bool signatureVerified)
|
||||
{
|
||||
if (issues.Count == 0)
|
||||
{
|
||||
return signatureVerified ? VerificationSectionStatus.Pass : VerificationSectionStatus.Warn;
|
||||
}
|
||||
|
||||
if (issues.Any(issue => issue.StartsWith("certificate_", StringComparison.OrdinalIgnoreCase) || issue.StartsWith("kms_", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
if (issues.Any(issue => issue.StartsWith("signer_mode", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
return VerificationSectionStatus.Warn;
|
||||
}
|
||||
|
||||
private static VerificationSectionStatus CombinePolicyStatus(
|
||||
VerificationSectionStatus current,
|
||||
VerificationSectionStatus next,
|
||||
string component,
|
||||
List<string> issues,
|
||||
bool warnOnly = false)
|
||||
{
|
||||
if (next == VerificationSectionStatus.Fail)
|
||||
{
|
||||
issues.Add($"policy_blocked:{component}");
|
||||
return VerificationSectionStatus.Fail;
|
||||
}
|
||||
|
||||
if (next == VerificationSectionStatus.Warn && !warnOnly)
|
||||
{
|
||||
issues.Add($"policy_warn:{component}");
|
||||
return current == VerificationSectionStatus.Fail ? current : VerificationSectionStatus.Warn;
|
||||
}
|
||||
|
||||
if (next == VerificationSectionStatus.Warn && warnOnly)
|
||||
{
|
||||
issues.Add($"policy_warn:{component}");
|
||||
return current;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private static TransparencyEvaluationResult BuildTransparencyResult(
|
||||
VerificationSectionStatus status,
|
||||
List<string> issues,
|
||||
bool proofPresent,
|
||||
bool checkpointPresent,
|
||||
bool inclusionPresent,
|
||||
bool witnessPresent,
|
||||
bool witnessMatches,
|
||||
string? witnessAggregator,
|
||||
string witnessStatus)
|
||||
{
|
||||
return new TransparencyEvaluationResult
|
||||
{
|
||||
Status = status,
|
||||
ProofPresent = proofPresent,
|
||||
CheckpointPresent = checkpointPresent,
|
||||
InclusionPathPresent = inclusionPresent,
|
||||
WitnessPresent = witnessPresent,
|
||||
WitnessMatchesRoot = witnessMatches,
|
||||
WitnessAggregator = witnessAggregator,
|
||||
WitnessStatus = witnessStatus,
|
||||
Issues = issues.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
using (ecdsa)
|
||||
{
|
||||
if (ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
using (rsa)
|
||||
{
|
||||
if (rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
var formatted = extension.Format(true);
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private static bool TryDecodeSecret(string value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeBase64(value[7..], out bytes);
|
||||
}
|
||||
|
||||
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeHex(value[4..], out bytes);
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeHex(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHex(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHash(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (TryDecodeHex(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly struct ProofPathNode
|
||||
{
|
||||
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
|
||||
{
|
||||
HasOrientation = hasOrientation;
|
||||
Left = left;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public bool HasOrientation { get; }
|
||||
|
||||
public bool Left { get; }
|
||||
|
||||
public byte[] Hash { get; }
|
||||
|
||||
public static bool TryParse(string value, out ProofPathNode node)
|
||||
{
|
||||
node = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(':', 2);
|
||||
bool hasOrientation = false;
|
||||
bool left = false;
|
||||
string hashPart = trimmed;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var prefix = parts[0].Trim().ToLowerInvariant();
|
||||
if (prefix is "l" or "left")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = true;
|
||||
}
|
||||
else if (prefix is "r" or "right")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = false;
|
||||
}
|
||||
|
||||
hashPart = parts[1].Trim();
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(hashPart, out var hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = new ProofPathNode(hasOrientation, left, hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user