1015 lines
36 KiB
C#
1015 lines
36 KiB
C#
using System.Collections.Immutable;
|
|
using System.Formats.Asn1;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
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;
|
|
using StellaOps.Cryptography;
|
|
|
|
namespace StellaOps.Attestor.Verify;
|
|
|
|
public sealed class AttestorVerificationEngine : IAttestorVerificationEngine
|
|
{
|
|
private readonly IDsseCanonicalizer _canonicalizer;
|
|
private readonly ICryptoHash _cryptoHash;
|
|
private readonly AttestorOptions _options;
|
|
private readonly ILogger<AttestorVerificationEngine> _logger;
|
|
|
|
public AttestorVerificationEngine(
|
|
IDsseCanonicalizer canonicalizer,
|
|
ICryptoHash cryptoHash,
|
|
IOptions<AttestorOptions> options,
|
|
ILogger<AttestorVerificationEngine> logger)
|
|
{
|
|
_canonicalizer = canonicalizer ?? throw new ArgumentNullException(nameof(canonicalizer));
|
|
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
|
_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, evaluationTime, 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,
|
|
DateTimeOffset evaluationTime,
|
|
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 = _cryptoHash.ComputeHashHexForPurpose(canonicalBundle, HashPurpose.Attestation);
|
|
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, evaluationTime);
|
|
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 expectedSignatures = new List<byte[]>();
|
|
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
|
{
|
|
if (!TryDecodeSecret(secret, out var secretBytes))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
using var hmac = new HMACSHA256(secretBytes);
|
|
expectedSignatures.Add(hmac.ComputeHash(preAuthEncoding));
|
|
}
|
|
|
|
var verified = 0;
|
|
foreach (var candidate in signatures)
|
|
{
|
|
if (expectedSignatures.Any(expected => CryptographicOperations.FixedTimeEquals(expected, 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,
|
|
DateTimeOffset evaluationTime)
|
|
{
|
|
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,
|
|
VerificationTime = evaluationTime.UtcDateTime
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
for (var i = 1; i < certificates.Count; i++)
|
|
{
|
|
chain.ChainPolicy.ExtraStore.Add(certificates[i]);
|
|
}
|
|
|
|
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", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
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))
|
|
{
|
|
AsnReader reader;
|
|
try
|
|
{
|
|
reader = new AsnReader(extension.RawData, AsnEncodingRules.DER);
|
|
}
|
|
catch (AsnContentException)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
var sequence = reader.ReadSequence();
|
|
while (sequence.HasData)
|
|
{
|
|
var tag = sequence.PeekTag();
|
|
if (tag.TagClass != TagClass.ContextSpecific)
|
|
{
|
|
sequence.ReadEncodedValue();
|
|
continue;
|
|
}
|
|
|
|
switch (tag.TagValue)
|
|
{
|
|
case 1:
|
|
yield return sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 1));
|
|
break;
|
|
case 2:
|
|
yield return sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 2));
|
|
break;
|
|
case 6:
|
|
yield return sequence.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6));
|
|
break;
|
|
case 7:
|
|
var ipBytes = sequence.ReadOctetString(new Asn1Tag(TagClass.ContextSpecific, 7));
|
|
yield return new IPAddress(ipBytes).ToString();
|
|
break;
|
|
default:
|
|
sequence.ReadEncodedValue();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
|
{
|
|
var payloadTypeValue = payloadType ?? string.Empty;
|
|
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadTypeValue);
|
|
var payloadTypeLength = Encoding.ASCII.GetBytes(payloadTypeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
var payloadLength = Encoding.ASCII.GetBytes(payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
var space = new byte[] { (byte)' ' };
|
|
|
|
var totalLength = 6 + space.Length + payloadTypeLength.Length + space.Length + payloadTypeBytes.Length
|
|
+ space.Length + payloadLength.Length + space.Length + payload.Length;
|
|
var buffer = new byte[totalLength];
|
|
var offset = 0;
|
|
|
|
static void CopyBytes(byte[] source, byte[] destination, ref int index)
|
|
{
|
|
Buffer.BlockCopy(source, 0, destination, index, source.Length);
|
|
index += source.Length;
|
|
}
|
|
|
|
CopyBytes(Encoding.ASCII.GetBytes("DSSEv1"), buffer, ref offset);
|
|
CopyBytes(space, buffer, ref offset);
|
|
CopyBytes(payloadTypeLength, buffer, ref offset);
|
|
CopyBytes(space, buffer, ref offset);
|
|
CopyBytes(payloadTypeBytes, buffer, ref offset);
|
|
CopyBytes(space, buffer, ref offset);
|
|
CopyBytes(payloadLength, buffer, ref offset);
|
|
CopyBytes(space, buffer, ref offset);
|
|
payload.CopyTo(buffer.AsSpan(offset));
|
|
|
|
return buffer;
|
|
}
|
|
|
|
private byte[] HashInternal(byte[] left, byte[] right)
|
|
{
|
|
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 _cryptoHash.ComputeHashForPurpose(buffer, HashPurpose.Merkle);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|