save progress
This commit is contained in:
@@ -215,7 +215,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Directory of PEM files
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
@@ -224,10 +224,10 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
}
|
||||
|
||||
// Also try Offline Kit path if configured
|
||||
var offlineKitPath = GetOfflineKitPath(rootType);
|
||||
var offlineKitPath = _options.UseOfflineKit ? GetOfflineKitPath(rootType) : null;
|
||||
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
|
||||
@@ -11,8 +11,12 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.X509;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
@@ -33,6 +37,8 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly ILogger<OfflineVerifier> _logger;
|
||||
private readonly OfflineVerificationConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new offline verifier.
|
||||
@@ -42,13 +48,15 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<OfflineVerifier> logger,
|
||||
IOptions<OfflineVerificationConfig> config,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
IOrgKeySigner? orgSigner = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_config = config?.Value ?? new OfflineVerificationConfig();
|
||||
_orgSigner = orgSigner;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -59,9 +67,9 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of bundle {BundleId} with {Count} attestations",
|
||||
@@ -166,9 +174,28 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_config.AllowUnbundled)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Error,
|
||||
"UNBUNDLED_NOT_ALLOWED",
|
||||
"Unbundled attestation verification is disabled by configuration.",
|
||||
attestation.EntryId));
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of attestation {EntryId}",
|
||||
@@ -220,13 +247,62 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
options = ResolveOptions(options);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loading bundle from {Path} to verify artifact {Digest}",
|
||||
bundlePath,
|
||||
artifactDigest);
|
||||
|
||||
if (_config.MaxCacheSizeMb > 0)
|
||||
{
|
||||
var info = new FileInfo(bundlePath);
|
||||
if (info.Exists)
|
||||
{
|
||||
var maxBytes = (long)_config.MaxCacheSizeMb * 1024 * 1024;
|
||||
if (info.Length > maxBytes)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_TOO_LARGE",
|
||||
$"Bundle size {info.Length} bytes exceeds MaxCacheSizeMb {_config.MaxCacheSizeMb}.")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load bundle from file
|
||||
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
AttestationBundle bundle;
|
||||
try
|
||||
{
|
||||
bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_LOAD_FAILED",
|
||||
$"Failed to load bundle from {bundlePath}: {ex.Message}")
|
||||
});
|
||||
}
|
||||
|
||||
// Find attestations for this artifact
|
||||
var matchingAttestations = bundle.Attestations
|
||||
@@ -242,7 +318,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
@@ -268,7 +344,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var summaries = new List<AttestationVerificationSummary>();
|
||||
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
@@ -410,17 +486,30 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
// Verify signature using the certificate
|
||||
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
if (string.Equals(bundle.OrgSignature.Algorithm, "Ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
if (!TryVerifyEd25519Signature(digestData, signatureBytes, cert, out var error))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
error ?? "Ed25519 signature verification failed."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
using var pubKey = cert.GetECDsaPublicKey();
|
||||
if (pubKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"ECDSA_P384" => HashAlgorithmName.SHA384,
|
||||
"ECDSA_P521" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
|
||||
if (!valid)
|
||||
{
|
||||
@@ -435,6 +524,13 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
using var rsaKey = cert.GetRSAPublicKey();
|
||||
if (rsaKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
"RSA_PSS_SHA384" => HashAlgorithmName.SHA384,
|
||||
"RSA_PSS_SHA512" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = rsaKey.VerifyData(
|
||||
digestData,
|
||||
signatureBytes,
|
||||
@@ -480,7 +576,58 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify at least one signature is present and has non-empty sig
|
||||
if (string.IsNullOrWhiteSpace(attestation.Envelope.PayloadType))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOADTYPE_MISSING",
|
||||
$"PayloadType missing in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(attestation.Envelope.Payload, out var payloadBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOAD_INVALID_BASE64",
|
||||
$"Invalid base64 payload in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestation.Envelope.CertificateChain == null || attestation.Envelope.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_MISSING",
|
||||
$"Certificate chain missing for DSSE envelope {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var leafCert = ParseCertificateFromPem(attestation.Envelope.CertificateChain[0]);
|
||||
if (leafCert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_PARSE_FAILED",
|
||||
$"Failed to parse leaf certificate for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCreateEnvelopeKey(leafCert, out var key, out var keyError))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_KEY_UNSUPPORTED",
|
||||
keyError ?? $"Unsupported public key for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var allValid = true;
|
||||
foreach (var sig in attestation.Envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
@@ -490,20 +637,70 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
"DSSE_EMPTY_SIG",
|
||||
$"Empty signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(sig.Sig, out var signatureBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID_BASE64",
|
||||
$"Invalid base64 signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sig.KeyId) &&
|
||||
!string.Equals(sig.KeyId, key.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_KEYID_MISMATCH",
|
||||
$"Signature key ID mismatch for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var signature = new StellaOps.Attestor.Envelope.EnvelopeSignature(
|
||||
string.IsNullOrWhiteSpace(sig.KeyId) ? key.KeyId : sig.KeyId,
|
||||
key.AlgorithmId,
|
||||
signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.VerifyDsse(
|
||||
attestation.Envelope.PayloadType,
|
||||
payloadBytes,
|
||||
signature,
|
||||
key);
|
||||
|
||||
if (!verifyResult.IsSuccess || !verifyResult.Value)
|
||||
{
|
||||
var message = verifyResult.IsSuccess
|
||||
? "DSSE signature verification failed."
|
||||
: $"DSSE signature verification failed: {verifyResult.Error.Code}";
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID",
|
||||
message,
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full cryptographic verification requires the certificate chain
|
||||
// Here we just validate structure; chain verification handles crypto
|
||||
_logger.LogDebug("DSSE envelope structure verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
if (allValid)
|
||||
{
|
||||
_logger.LogDebug("DSSE signatures verified for {EntryId}", attestation.EntryId);
|
||||
}
|
||||
|
||||
return allValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_VERIFY_ERROR",
|
||||
"DSSE_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
@@ -707,7 +904,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AttestationBundle> LoadBundleAsync(
|
||||
private async Task<AttestationBundle> LoadBundleAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -718,6 +915,130 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
|
||||
}
|
||||
|
||||
private OfflineVerificationOptions ResolveOptions(OfflineVerificationOptions? options)
|
||||
{
|
||||
if (options != null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: true,
|
||||
VerifyOrgSignature: true,
|
||||
RequireOrgSignature: _config.RequireOrgSignatureDefault,
|
||||
FulcioRootPath: null,
|
||||
OrgKeyPath: null,
|
||||
StrictMode: _config.StrictModeDefault);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateEnvelopeKey(
|
||||
X509Certificate2 cert,
|
||||
out EnvelopeKey key,
|
||||
out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = cert.GetECDsaPublicKey();
|
||||
if (ecdsa != null)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(false);
|
||||
var algorithmId = ResolveEcdsaAlgorithm(parameters.Curve);
|
||||
key = EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
error = $"Failed to read ECDSA public key: {ex.Message}";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryGetEd25519PublicKey(cert, out var ed25519Key))
|
||||
{
|
||||
key = EnvelopeKey.CreateEd25519Verifier(ed25519Key);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Unsupported public key algorithm.";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(ECCurve curve) => curve.Oid.Value switch
|
||||
{
|
||||
"1.2.840.10045.3.1.7" => "ES256", // NIST P-256
|
||||
"1.3.132.0.34" => "ES384", // NIST P-384
|
||||
"1.3.132.0.35" => "ES512", // NIST P-521
|
||||
_ => throw new ArgumentException("Unsupported ECDSA curve.")
|
||||
};
|
||||
|
||||
private static bool TryGetEd25519PublicKey(X509Certificate2 cert, out byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
publicKey = ed25519.GetEncoded();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow parse failures; caller handles error messaging.
|
||||
}
|
||||
|
||||
publicKey = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryVerifyEd25519Signature(
|
||||
byte[] message,
|
||||
byte[] signature,
|
||||
X509Certificate2 cert,
|
||||
out string? error)
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is not Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
error = "Ed25519 public key not found in certificate.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(false, ed25519);
|
||||
signer.BlockUpdate(message, 0, message.Length);
|
||||
if (!signer.VerifySignature(signature))
|
||||
{
|
||||
error = "Ed25519 signature verification failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0058-A | DONE | Applied DSSE verification, config defaults, offline kit gating, and deterministic ordering. |
|
||||
|
||||
Reference in New Issue
Block a user