save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -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);

View File

@@ -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>

View File

@@ -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. |