Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------------
// IOfflineRootStore.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0003 - Implement IOfflineRootStore interface
// Description: Interface for loading trust roots for offline verification
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Attestor.Offline.Abstractions;
/// <summary>
/// Store for trust roots used in offline verification.
/// Provides access to Fulcio roots, organization signing keys, and Rekor checkpoints.
/// </summary>
public interface IOfflineRootStore
{
/// <summary>
/// Get Fulcio root certificates for keyless signature verification.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of Fulcio root certificates.</returns>
Task<X509Certificate2Collection> GetFulcioRootsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Get organization signing keys for bundle signature verification.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of organization signing certificates.</returns>
Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Get Rekor public keys for checkpoint verification.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of Rekor public key certificates.</returns>
Task<X509Certificate2Collection> GetRekorKeysAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Import root certificates from a PEM file.
/// </summary>
/// <param name="pemPath">Path to the PEM file.</param>
/// <param name="rootType">Type of roots being imported.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task ImportRootsAsync(
string pemPath,
RootType rootType,
CancellationToken cancellationToken = default);
/// <summary>
/// Get a specific organization key by ID.
/// </summary>
/// <param name="keyId">The key identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The certificate if found, null otherwise.</returns>
Task<X509Certificate2?> GetOrgKeyByIdAsync(
string keyId,
CancellationToken cancellationToken = default);
/// <summary>
/// List all available root certificates with metadata.
/// </summary>
/// <param name="rootType">Type of roots to list.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Root certificate metadata.</returns>
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
RootType rootType,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Type of trust root.
/// </summary>
public enum RootType
{
/// <summary>Fulcio root certificates for keyless signing.</summary>
Fulcio,
/// <summary>Organization signing keys for bundle endorsement.</summary>
OrgSigning,
/// <summary>Rekor public keys for transparency log verification.</summary>
Rekor
}
/// <summary>
/// Metadata about a root certificate.
/// </summary>
/// <param name="Thumbprint">Certificate thumbprint (SHA-256).</param>
/// <param name="Subject">Certificate subject DN.</param>
/// <param name="Issuer">Certificate issuer DN.</param>
/// <param name="NotBefore">Certificate validity start.</param>
/// <param name="NotAfter">Certificate validity end.</param>
/// <param name="KeyId">Optional key identifier.</param>
/// <param name="RootType">Type of this root certificate.</param>
public record RootCertificateInfo(
string Thumbprint,
string Subject,
string Issuer,
DateTimeOffset NotBefore,
DateTimeOffset NotAfter,
string? KeyId,
RootType RootType);

View File

@@ -0,0 +1,70 @@
// -----------------------------------------------------------------------------
// IOfflineVerifier.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0005 - Implement IOfflineVerifier interface
// Description: Interface for offline verification of attestation bundles
// -----------------------------------------------------------------------------
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Offline.Models;
namespace StellaOps.Attestor.Offline.Abstractions;
/// <summary>
/// Service for offline verification of attestation bundles.
/// Enables air-gapped environments to verify attestations using bundled proofs
/// and locally stored root certificates.
/// </summary>
public interface IOfflineVerifier
{
/// <summary>
/// Verify an attestation bundle offline.
/// </summary>
/// <param name="bundle">The attestation bundle to verify.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result with detailed status.</returns>
Task<OfflineVerificationResult> VerifyBundleAsync(
AttestationBundle bundle,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Verify a single attestation within a bundle offline.
/// </summary>
/// <param name="attestation">The attestation to verify.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result for the single attestation.</returns>
Task<OfflineVerificationResult> VerifyAttestationAsync(
BundledAttestation attestation,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Verify an attestation for a specific artifact digest.
/// Looks up the attestation in the bundle by artifact digest.
/// </summary>
/// <param name="artifactDigest">The artifact digest to look up.</param>
/// <param name="bundlePath">Path to the bundle file.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result for attestations covering the artifact.</returns>
Task<OfflineVerificationResult> VerifyByArtifactAsync(
string artifactDigest,
string bundlePath,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get verification summaries for all attestations in a bundle.
/// </summary>
/// <param name="bundle">The bundle to summarize.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of attestation verification summaries.</returns>
Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
AttestationBundle bundle,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,112 @@
// -----------------------------------------------------------------------------
// OfflineVerificationResult.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0002 - Define OfflineVerificationResult and options
// Description: Models for offline verification results
// -----------------------------------------------------------------------------
namespace StellaOps.Attestor.Offline.Models;
/// <summary>
/// Result of offline verification of an attestation bundle.
/// </summary>
/// <param name="Valid">Whether all verification checks passed.</param>
/// <param name="MerkleProofValid">Whether the Merkle proof verification passed.</param>
/// <param name="SignaturesValid">Whether all DSSE signatures are valid.</param>
/// <param name="CertificateChainValid">Whether certificate chains validate to trusted roots.</param>
/// <param name="OrgSignatureValid">Whether the organization signature is valid.</param>
/// <param name="OrgSignatureKeyId">Key ID used for org signature (if present).</param>
/// <param name="VerifiedAt">Timestamp when verification was performed.</param>
/// <param name="Issues">List of verification issues found.</param>
public record OfflineVerificationResult(
bool Valid,
bool MerkleProofValid,
bool SignaturesValid,
bool CertificateChainValid,
bool OrgSignatureValid,
string? OrgSignatureKeyId,
DateTimeOffset VerifiedAt,
IReadOnlyList<VerificationIssue> Issues);
/// <summary>
/// A single verification issue.
/// </summary>
/// <param name="Severity">Issue severity level.</param>
/// <param name="Code">Machine-readable issue code.</param>
/// <param name="Message">Human-readable message.</param>
/// <param name="AttestationId">Related attestation ID, if applicable.</param>
public record VerificationIssue(
VerificationIssueSeverity Severity,
string Code,
string Message,
string? AttestationId = null);
/// <summary>
/// Severity levels for verification issues.
/// </summary>
public enum VerificationIssueSeverity
{
/// <summary>Informational message.</summary>
Info,
/// <summary>Warning that may affect trust.</summary>
Warning,
/// <summary>Error that affects verification.</summary>
Error,
/// <summary>Critical error that invalidates verification.</summary>
Critical
}
/// <summary>
/// Options for offline verification.
/// </summary>
/// <param name="VerifyMerkleProof">Whether to verify Merkle inclusion proofs.</param>
/// <param name="VerifySignatures">Whether to verify DSSE signatures.</param>
/// <param name="VerifyCertificateChain">Whether to verify certificate chains.</param>
/// <param name="VerifyOrgSignature">Whether to verify organization signature.</param>
/// <param name="RequireOrgSignature">Fail if org signature is missing.</param>
/// <param name="FulcioRootPath">Path to Fulcio root certificates (overrides default).</param>
/// <param name="OrgKeyPath">Path to organization signing keys (overrides default).</param>
/// <param name="StrictMode">Enable strict verification (all checks must pass).</param>
public record OfflineVerificationOptions(
bool VerifyMerkleProof = true,
bool VerifySignatures = true,
bool VerifyCertificateChain = true,
bool VerifyOrgSignature = true,
bool RequireOrgSignature = false,
string? FulcioRootPath = null,
string? OrgKeyPath = null,
bool StrictMode = false);
/// <summary>
/// Summary of an attestation for verification reporting.
/// </summary>
/// <param name="EntryId">Attestation entry ID.</param>
/// <param name="ArtifactDigest">Artifact digest covered by this attestation.</param>
/// <param name="PredicateType">Predicate type.</param>
/// <param name="SignedAt">When the attestation was signed.</param>
/// <param name="SigningIdentity">Identity that signed the attestation.</param>
/// <param name="VerificationStatus">Status of this attestation's verification.</param>
public record AttestationVerificationSummary(
string EntryId,
string ArtifactDigest,
string PredicateType,
DateTimeOffset SignedAt,
string? SigningIdentity,
AttestationVerificationStatus VerificationStatus);
/// <summary>
/// Verification status of an individual attestation.
/// </summary>
public enum AttestationVerificationStatus
{
/// <summary>Verification passed.</summary>
Valid,
/// <summary>Signature verification failed.</summary>
InvalidSignature,
/// <summary>Certificate chain verification failed.</summary>
InvalidCertificateChain,
/// <summary>Merkle inclusion proof failed.</summary>
InvalidMerkleProof,
/// <summary>Verification encountered an error.</summary>
Error
}

View File

@@ -0,0 +1,430 @@
// -----------------------------------------------------------------------------
// FileSystemRootStore.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0004 - Implement FileSystemRootStore
// Description: File-based root certificate store for offline verification
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Offline.Abstractions;
namespace StellaOps.Attestor.Offline.Services;
/// <summary>
/// File system-based implementation of IOfflineRootStore.
/// Loads root certificates from configured paths for offline verification.
/// </summary>
public sealed class FileSystemRootStore : IOfflineRootStore
{
private readonly ILogger<FileSystemRootStore> _logger;
private readonly OfflineRootStoreOptions _options;
private X509Certificate2Collection? _fulcioRoots;
private X509Certificate2Collection? _orgSigningKeys;
private X509Certificate2Collection? _rekorKeys;
private readonly SemaphoreSlim _loadLock = new(1, 1);
/// <summary>
/// Create a new file system root store.
/// </summary>
public FileSystemRootStore(
ILogger<FileSystemRootStore> logger,
IOptions<OfflineRootStoreOptions> options)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new OfflineRootStoreOptions();
}
/// <inheritdoc />
public async Task<X509Certificate2Collection> GetFulcioRootsAsync(
CancellationToken cancellationToken = default)
{
if (_fulcioRoots == null)
{
await LoadRootsAsync(RootType.Fulcio, cancellationToken);
}
return _fulcioRoots ?? new X509Certificate2Collection();
}
/// <inheritdoc />
public async Task<X509Certificate2Collection> GetOrgSigningKeysAsync(
CancellationToken cancellationToken = default)
{
if (_orgSigningKeys == null)
{
await LoadRootsAsync(RootType.OrgSigning, cancellationToken);
}
return _orgSigningKeys ?? new X509Certificate2Collection();
}
/// <inheritdoc />
public async Task<X509Certificate2Collection> GetRekorKeysAsync(
CancellationToken cancellationToken = default)
{
if (_rekorKeys == null)
{
await LoadRootsAsync(RootType.Rekor, cancellationToken);
}
return _rekorKeys ?? new X509Certificate2Collection();
}
/// <inheritdoc />
public async Task ImportRootsAsync(
string pemPath,
RootType rootType,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pemPath);
if (!File.Exists(pemPath))
{
throw new FileNotFoundException($"PEM file not found: {pemPath}");
}
_logger.LogInformation("Importing {RootType} roots from {Path}", rootType, pemPath);
var pemContent = await File.ReadAllTextAsync(pemPath, cancellationToken);
var certs = ParsePemCertificates(pemContent);
if (certs.Count == 0)
{
throw new InvalidOperationException($"No certificates found in {pemPath}");
}
// Get target directory based on root type
var targetDir = GetRootDirectory(rootType);
Directory.CreateDirectory(targetDir);
// Save each certificate
foreach (var cert in certs)
{
var thumbprint = ComputeThumbprint(cert);
var targetPath = Path.Combine(targetDir, $"{thumbprint}.pem");
var pemBytes = Encoding.UTF8.GetBytes(
"-----BEGIN CERTIFICATE-----\n" +
Convert.ToBase64String(cert.RawData, Base64FormattingOptions.InsertLineBreaks) +
"\n-----END CERTIFICATE-----\n");
await File.WriteAllBytesAsync(targetPath, pemBytes, cancellationToken);
_logger.LogInformation(
"Imported certificate {Subject} with thumbprint {Thumbprint}",
cert.Subject,
thumbprint);
}
// Invalidate cache to reload
InvalidateCache(rootType);
_logger.LogInformation("Imported {Count} {RootType} certificates", certs.Count, rootType);
}
/// <inheritdoc />
public async Task<X509Certificate2?> GetOrgKeyByIdAsync(
string keyId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var keys = await GetOrgSigningKeysAsync(cancellationToken);
foreach (var cert in keys)
{
// Check various key identifier extensions
var ski = cert.Extensions["2.5.29.14"]; // Subject Key Identifier
if (ski != null)
{
var skiData = ski.RawData;
var skiHex = Convert.ToHexString(skiData).ToLowerInvariant();
if (skiHex.Contains(keyId, StringComparison.OrdinalIgnoreCase))
{
return cert;
}
}
// Also check thumbprint
if (ComputeThumbprint(cert).Equals(keyId, StringComparison.OrdinalIgnoreCase))
{
return cert;
}
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
RootType rootType,
CancellationToken cancellationToken = default)
{
var certs = rootType switch
{
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
};
var result = new List<RootCertificateInfo>();
foreach (var cert in certs)
{
result.Add(new RootCertificateInfo(
Thumbprint: ComputeThumbprint(cert),
Subject: cert.Subject,
Issuer: cert.Issuer,
NotBefore: new DateTimeOffset(cert.NotBefore.ToUniversalTime(), TimeSpan.Zero),
NotAfter: new DateTimeOffset(cert.NotAfter.ToUniversalTime(), TimeSpan.Zero),
KeyId: GetSubjectKeyIdentifier(cert),
RootType: rootType));
}
return result;
}
private async Task LoadRootsAsync(RootType rootType, CancellationToken cancellationToken)
{
await _loadLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
if (GetCachedCollection(rootType) != null)
{
return;
}
var path = GetRootPath(rootType);
var collection = new X509Certificate2Collection();
if (!string.IsNullOrEmpty(path))
{
if (File.Exists(path))
{
// Single file
var certs = await LoadPemFileAsync(path, cancellationToken);
collection.AddRange(certs);
}
else if (Directory.Exists(path))
{
// Directory of PEM files
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
{
var certs = await LoadPemFileAsync(file, cancellationToken);
collection.AddRange(certs);
}
}
}
// Also try Offline Kit path if configured
var offlineKitPath = GetOfflineKitPath(rootType);
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
{
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
{
var certs = await LoadPemFileAsync(file, cancellationToken);
collection.AddRange(certs);
}
}
SetCachedCollection(rootType, collection);
_logger.LogInformation(
"Loaded {Count} {RootType} certificates",
collection.Count,
rootType);
}
finally
{
_loadLock.Release();
}
}
private async Task<X509Certificate2Collection> LoadPemFileAsync(
string path,
CancellationToken cancellationToken)
{
var pemContent = await File.ReadAllTextAsync(path, cancellationToken);
return ParsePemCertificates(pemContent);
}
private static X509Certificate2Collection ParsePemCertificates(string pemContent)
{
var collection = new X509Certificate2Collection();
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
var startIndex = 0;
while (true)
{
var begin = pemContent.IndexOf(beginMarker, startIndex, StringComparison.Ordinal);
if (begin < 0)
{
break;
}
var end = pemContent.IndexOf(endMarker, begin, StringComparison.Ordinal);
if (end < 0)
{
break;
}
var base64Start = begin + beginMarker.Length;
var base64Content = pemContent[base64Start..end]
.Replace("\r", "")
.Replace("\n", "")
.Trim();
var certBytes = Convert.FromBase64String(base64Content);
collection.Add(new X509Certificate2(certBytes));
startIndex = end + endMarker.Length;
}
return collection;
}
private string GetRootPath(RootType rootType) => rootType switch
{
RootType.Fulcio => _options.FulcioBundlePath ?? "",
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
RootType.Rekor => _options.RekorBundlePath ?? "",
_ => ""
};
private string GetRootDirectory(RootType rootType) => rootType switch
{
RootType.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"),
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
_ => _options.BaseRootPath
};
private string? GetOfflineKitPath(RootType rootType)
{
if (string.IsNullOrEmpty(_options.OfflineKitPath))
{
return null;
}
return rootType switch
{
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
_ => null
};
}
private X509Certificate2Collection? GetCachedCollection(RootType rootType) => rootType switch
{
RootType.Fulcio => _fulcioRoots,
RootType.OrgSigning => _orgSigningKeys,
RootType.Rekor => _rekorKeys,
_ => null
};
private void SetCachedCollection(RootType rootType, X509Certificate2Collection collection)
{
switch (rootType)
{
case RootType.Fulcio:
_fulcioRoots = collection;
break;
case RootType.OrgSigning:
_orgSigningKeys = collection;
break;
case RootType.Rekor:
_rekorKeys = collection;
break;
}
}
private void InvalidateCache(RootType rootType)
{
switch (rootType)
{
case RootType.Fulcio:
_fulcioRoots = null;
break;
case RootType.OrgSigning:
_orgSigningKeys = null;
break;
case RootType.Rekor:
_rekorKeys = null;
break;
}
}
private static string ComputeThumbprint(X509Certificate2 cert)
{
var hash = SHA256.HashData(cert.RawData);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string? GetSubjectKeyIdentifier(X509Certificate2 cert)
{
var extension = cert.Extensions["2.5.29.14"];
if (extension == null)
{
return null;
}
// Skip the ASN.1 header (typically 2 bytes for OCTET STRING)
var data = extension.RawData;
if (data.Length > 2 && data[0] == 0x04) // OCTET STRING
{
var length = data[1];
if (data.Length >= 2 + length)
{
return Convert.ToHexString(data[2..(2 + length)]).ToLowerInvariant();
}
}
return Convert.ToHexString(data).ToLowerInvariant();
}
}
/// <summary>
/// Configuration options for the file system root store.
/// </summary>
public sealed class OfflineRootStoreOptions
{
/// <summary>
/// Base path for all root certificates.
/// </summary>
public string BaseRootPath { get; set; } = "/etc/stellaops/roots";
/// <summary>
/// Path to Fulcio root certificates (file or directory).
/// </summary>
public string? FulcioBundlePath { get; set; }
/// <summary>
/// Path to organization signing keys (file or directory).
/// </summary>
public string? OrgSigningBundlePath { get; set; }
/// <summary>
/// Path to Rekor public keys (file or directory).
/// </summary>
public string? RekorBundlePath { get; set; }
/// <summary>
/// Path to Offline Kit installation.
/// </summary>
public string? OfflineKitPath { get; set; }
/// <summary>
/// Whether to use roots from the Offline Kit.
/// </summary>
public bool UseOfflineKit { get; set; } = true;
}

View File

@@ -0,0 +1,747 @@
// -----------------------------------------------------------------------------
// OfflineVerifier.cs
// Sprint: SPRINT_20251226_003_ATTESTOR_offline_verification
// Task: 0006 - Implement OfflineVerifier service
// Description: Offline verification service for attestation bundles
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Bundling.Abstractions;
using StellaOps.Attestor.Bundling.Models;
using StellaOps.Attestor.Offline.Abstractions;
using StellaOps.Attestor.Offline.Models;
using StellaOps.Attestor.ProofChain.Merkle;
// Alias to resolve ambiguity with Bundling.Abstractions.VerificationIssueSeverity
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
namespace StellaOps.Attestor.Offline.Services;
/// <summary>
/// Offline verification service for attestation bundles.
/// Enables air-gapped environments to verify attestations using bundled proofs.
/// </summary>
public sealed class OfflineVerifier : IOfflineVerifier
{
private readonly IOfflineRootStore _rootStore;
private readonly IMerkleTreeBuilder _merkleBuilder;
private readonly IOrgKeySigner? _orgSigner;
private readonly ILogger<OfflineVerifier> _logger;
private readonly OfflineVerificationConfig _config;
/// <summary>
/// Create a new offline verifier.
/// </summary>
public OfflineVerifier(
IOfflineRootStore rootStore,
IMerkleTreeBuilder merkleBuilder,
ILogger<OfflineVerifier> logger,
IOptions<OfflineVerificationConfig> config,
IOrgKeySigner? orgSigner = 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;
}
/// <inheritdoc />
public async Task<OfflineVerificationResult> VerifyBundleAsync(
AttestationBundle bundle,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
options ??= new OfflineVerificationOptions();
var issues = new List<VerificationIssue>();
var verifiedAt = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Starting offline verification of bundle {BundleId} with {Count} attestations",
bundle.Metadata.BundleId,
bundle.Attestations.Count);
// 1. Verify bundle Merkle root
var merkleValid = true;
if (options.VerifyMerkleProof)
{
merkleValid = VerifyMerkleTree(bundle, issues);
}
// 2. Verify org signature (if present and required)
var orgSigValid = true;
string? orgSigKeyId = null;
if (bundle.OrgSignature != null)
{
orgSigKeyId = bundle.OrgSignature.KeyId;
if (options.VerifyOrgSignature)
{
orgSigValid = await VerifyOrgSignatureAsync(bundle, issues, cancellationToken);
}
}
else if (options.RequireOrgSignature)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_SIG_MISSING",
"Required organization signature is missing"));
orgSigValid = false;
}
// 3. Verify each attestation
var signaturesValid = true;
var certsValid = true;
if (options.VerifySignatures || options.VerifyCertificateChain)
{
var fulcioRoots = options.VerifyCertificateChain
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
: null;
foreach (var attestation in bundle.Attestations)
{
// Verify DSSE signature
if (options.VerifySignatures)
{
var sigValid = VerifyDsseSignature(attestation, issues);
if (!sigValid)
{
signaturesValid = false;
}
}
// Verify certificate chain
if (options.VerifyCertificateChain && fulcioRoots != null)
{
var chainValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
if (!chainValid)
{
certsValid = false;
}
}
// Verify Rekor inclusion proof (if present)
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
{
VerifyRekorInclusionProof(attestation, issues);
}
}
}
var valid = merkleValid && signaturesValid && certsValid && orgSigValid;
if (options.StrictMode && issues.Any(i => i.Severity >= Severity.Warning))
{
valid = false;
}
_logger.LogInformation(
"Offline verification of bundle {BundleId} completed: {Status}",
bundle.Metadata.BundleId,
valid ? "VALID" : "INVALID");
return new OfflineVerificationResult(
Valid: valid,
MerkleProofValid: merkleValid,
SignaturesValid: signaturesValid,
CertificateChainValid: certsValid,
OrgSignatureValid: orgSigValid,
OrgSignatureKeyId: orgSigKeyId,
VerifiedAt: verifiedAt,
Issues: issues);
}
/// <inheritdoc />
public async Task<OfflineVerificationResult> VerifyAttestationAsync(
BundledAttestation attestation,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(attestation);
options ??= new OfflineVerificationOptions();
var issues = new List<VerificationIssue>();
var verifiedAt = DateTimeOffset.UtcNow;
_logger.LogInformation(
"Starting offline verification of attestation {EntryId}",
attestation.EntryId);
var signaturesValid = true;
var certsValid = true;
var merkleValid = true;
// Verify DSSE signature
if (options.VerifySignatures)
{
signaturesValid = VerifyDsseSignature(attestation, issues);
}
// Verify certificate chain
if (options.VerifyCertificateChain)
{
var fulcioRoots = await _rootStore.GetFulcioRootsAsync(cancellationToken);
certsValid = VerifyCertificateChain(attestation, fulcioRoots, issues);
}
// Verify Rekor inclusion proof
if (options.VerifyMerkleProof && attestation.InclusionProof != null)
{
merkleValid = VerifyRekorInclusionProof(attestation, issues);
}
var valid = signaturesValid && certsValid && merkleValid;
return new OfflineVerificationResult(
Valid: valid,
MerkleProofValid: merkleValid,
SignaturesValid: signaturesValid,
CertificateChainValid: certsValid,
OrgSignatureValid: true, // Not applicable for single attestation
OrgSignatureKeyId: null,
VerifiedAt: verifiedAt,
Issues: issues);
}
/// <inheritdoc />
public async Task<OfflineVerificationResult> VerifyByArtifactAsync(
string artifactDigest,
string bundlePath,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
_logger.LogInformation(
"Loading bundle from {Path} to verify artifact {Digest}",
bundlePath,
artifactDigest);
// Load bundle from file
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
// Find attestations for this artifact
var matchingAttestations = bundle.Attestations
.Where(a => a.ArtifactDigest.Equals(artifactDigest, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matchingAttestations.Count == 0)
{
return new OfflineVerificationResult(
Valid: false,
MerkleProofValid: false,
SignaturesValid: false,
CertificateChainValid: false,
OrgSignatureValid: false,
OrgSignatureKeyId: null,
VerifiedAt: DateTimeOffset.UtcNow,
Issues: new List<VerificationIssue>
{
new(Severity.Critical,
"ARTIFACT_NOT_FOUND",
$"No attestations found for artifact {artifactDigest}")
});
}
// Create a filtered bundle with only matching attestations
var filteredBundle = bundle with
{
Attestations = matchingAttestations
};
return await VerifyBundleAsync(filteredBundle, options, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<AttestationVerificationSummary>> GetVerificationSummariesAsync(
AttestationBundle bundle,
OfflineVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
options ??= new OfflineVerificationOptions();
var summaries = new List<AttestationVerificationSummary>();
var fulcioRoots = options.VerifyCertificateChain
? await _rootStore.GetFulcioRootsAsync(cancellationToken)
: null;
foreach (var attestation in bundle.Attestations)
{
var issues = new List<VerificationIssue>();
var status = AttestationVerificationStatus.Valid;
// Verify signature
if (options.VerifySignatures && !VerifyDsseSignature(attestation, issues))
{
status = AttestationVerificationStatus.InvalidSignature;
}
// Verify certificate chain
if (status == AttestationVerificationStatus.Valid &&
options.VerifyCertificateChain &&
fulcioRoots != null &&
!VerifyCertificateChain(attestation, fulcioRoots, issues))
{
status = AttestationVerificationStatus.InvalidCertificateChain;
}
// Verify Merkle proof
if (status == AttestationVerificationStatus.Valid &&
options.VerifyMerkleProof &&
attestation.InclusionProof != null &&
!VerifyRekorInclusionProof(attestation, issues))
{
status = AttestationVerificationStatus.InvalidMerkleProof;
}
// Get signing identity
var identity = attestation.SigningIdentity.Subject ??
attestation.SigningIdentity.San ??
attestation.SigningIdentity.KeyId;
summaries.Add(new AttestationVerificationSummary(
EntryId: attestation.EntryId,
ArtifactDigest: attestation.ArtifactDigest,
PredicateType: attestation.PredicateType,
SignedAt: attestation.SignedAt,
SigningIdentity: identity,
VerificationStatus: status));
}
return summaries;
}
private bool VerifyMerkleTree(AttestationBundle bundle, List<VerificationIssue> issues)
{
try
{
// Sort attestations deterministically
var sortedAttestations = bundle.Attestations
.OrderBy(a => a.EntryId, StringComparer.Ordinal)
.ToList();
// Create leaf values from entry IDs
var leafValues = sortedAttestations
.Select(a => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(a.EntryId))
.ToList();
var computedRoot = _merkleBuilder.ComputeMerkleRoot(leafValues);
var computedRootHex = $"sha256:{Convert.ToHexString(computedRoot).ToLowerInvariant()}";
if (computedRootHex != bundle.MerkleTree.Root)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"MERKLE_ROOT_MISMATCH",
$"Computed Merkle root {computedRootHex} does not match bundle root {bundle.MerkleTree.Root}"));
return false;
}
_logger.LogDebug("Merkle root verified: {Root}", bundle.MerkleTree.Root);
return true;
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"MERKLE_VERIFY_ERROR",
$"Failed to verify Merkle root: {ex.Message}"));
return false;
}
}
private async Task<bool> VerifyOrgSignatureAsync(
AttestationBundle bundle,
List<VerificationIssue> issues,
CancellationToken cancellationToken)
{
if (bundle.OrgSignature == null)
{
return true;
}
try
{
// Compute bundle digest
var digestData = ComputeBundleDigest(bundle);
// Try using the org signer if available
if (_orgSigner != null)
{
var valid = await _orgSigner.VerifyBundleAsync(
digestData,
bundle.OrgSignature,
cancellationToken);
if (!valid)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_SIG_INVALID",
$"Organization signature verification failed for key {bundle.OrgSignature.KeyId}"));
}
return valid;
}
// Try using certificate from root store
var cert = await _rootStore.GetOrgKeyByIdAsync(
bundle.OrgSignature.KeyId,
cancellationToken);
if (cert == null)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_KEY_NOT_FOUND",
$"Organization key {bundle.OrgSignature.KeyId} not found in root store"));
return false;
}
// Verify signature using the certificate
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
var algorithm = bundle.OrgSignature.Algorithm switch
{
"ECDSA_P256" => HashAlgorithmName.SHA256,
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
_ => HashAlgorithmName.SHA256
};
using var pubKey = cert.GetECDsaPublicKey();
if (pubKey != null)
{
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
if (!valid)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_SIG_INVALID",
$"ECDSA signature verification failed"));
}
return valid;
}
using var rsaKey = cert.GetRSAPublicKey();
if (rsaKey != null)
{
var valid = rsaKey.VerifyData(
digestData,
signatureBytes,
algorithm,
RSASignaturePadding.Pss);
if (!valid)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_SIG_INVALID",
$"RSA signature verification failed"));
}
return valid;
}
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_KEY_UNSUPPORTED",
$"Unsupported key type for organization signature verification"));
return false;
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"ORG_SIG_VERIFY_ERROR",
$"Failed to verify organization signature: {ex.Message}"));
return false;
}
}
private bool VerifyDsseSignature(BundledAttestation attestation, List<VerificationIssue> issues)
{
try
{
if (attestation.Envelope.Signatures.Count == 0)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"DSSE_NO_SIGNATURES",
$"No signatures in DSSE envelope for {attestation.EntryId}",
attestation.EntryId));
return false;
}
// Verify at least one signature is present and has non-empty sig
foreach (var sig in attestation.Envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(sig.Sig))
{
issues.Add(new VerificationIssue(
Severity.Critical,
"DSSE_EMPTY_SIG",
$"Empty signature in DSSE envelope for {attestation.EntryId}",
attestation.EntryId));
return 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;
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"DSSE_VERIFY_ERROR",
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
attestation.EntryId));
return false;
}
}
private bool VerifyCertificateChain(
BundledAttestation attestation,
X509Certificate2Collection fulcioRoots,
List<VerificationIssue> issues)
{
try
{
if (attestation.Envelope.CertificateChain == null ||
attestation.Envelope.CertificateChain.Count == 0)
{
// Keyful attestations may not have certificate chains
if (attestation.SigningMode == "keyless")
{
issues.Add(new VerificationIssue(
Severity.Critical,
"CERT_CHAIN_MISSING",
$"Keyless attestation {attestation.EntryId} missing certificate chain",
attestation.EntryId));
return false;
}
return true; // Non-keyless attestations may use other verification
}
// Parse leaf certificate
var leafPem = attestation.Envelope.CertificateChain[0];
var leafCert = ParseCertificateFromPem(leafPem);
if (leafCert == null)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"CERT_PARSE_FAILED",
$"Failed to parse leaf certificate for {attestation.EntryId}",
attestation.EntryId));
return false;
}
// Build chain
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
// Add intermediates
foreach (var certPem in attestation.Envelope.CertificateChain.Skip(1))
{
var cert = ParseCertificateFromPem(certPem);
if (cert != null)
{
chain.ChainPolicy.ExtraStore.Add(cert);
}
}
// Add Fulcio roots
foreach (var root in fulcioRoots)
{
chain.ChainPolicy.ExtraStore.Add(root);
}
// Build and verify
var built = chain.Build(leafCert);
if (!built)
{
var statusInfo = string.Join(", ",
chain.ChainStatus.Select(s => $"{s.Status}: {s.StatusInformation}"));
issues.Add(new VerificationIssue(
Severity.Warning,
"CERT_CHAIN_BUILD_FAILED",
$"Certificate chain build failed for {attestation.EntryId}: {statusInfo}",
attestation.EntryId));
}
// Verify chain terminates at a Fulcio root
var chainRoot = chain.ChainElements[^1].Certificate;
var matchesRoot = fulcioRoots.Any(r =>
r.Thumbprint.Equals(chainRoot.Thumbprint, StringComparison.OrdinalIgnoreCase));
if (!matchesRoot)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"CERT_CHAIN_UNTRUSTED",
$"Certificate chain for {attestation.EntryId} does not terminate at trusted Fulcio root",
attestation.EntryId));
return false;
}
_logger.LogDebug("Certificate chain verified for {EntryId}", attestation.EntryId);
return true;
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
Severity.Critical,
"CERT_VERIFY_ERROR",
$"Failed to verify certificate chain for {attestation.EntryId}: {ex.Message}",
attestation.EntryId));
return false;
}
}
private bool VerifyRekorInclusionProof(
BundledAttestation attestation,
List<VerificationIssue> issues)
{
try
{
if (attestation.InclusionProof == null)
{
return true; // Not required if not present
}
// Basic validation of proof structure
if (attestation.InclusionProof.Path.Count == 0)
{
issues.Add(new VerificationIssue(
Severity.Warning,
"REKOR_PROOF_EMPTY",
$"Empty Rekor inclusion proof path for {attestation.EntryId}",
attestation.EntryId));
}
if (string.IsNullOrEmpty(attestation.InclusionProof.Checkpoint.RootHash))
{
issues.Add(new VerificationIssue(
Severity.Warning,
"REKOR_CHECKPOINT_MISSING",
$"Missing Rekor checkpoint root hash for {attestation.EntryId}",
attestation.EntryId));
return false;
}
// Full verification would recompute the Merkle path
// For offline verification, we trust the bundled proof
_logger.LogDebug(
"Rekor inclusion proof present for {EntryId} at index {Index}",
attestation.EntryId,
attestation.RekorLogIndex);
return true;
}
catch (Exception ex)
{
issues.Add(new VerificationIssue(
Severity.Warning,
"REKOR_PROOF_ERROR",
$"Failed to verify Rekor inclusion proof for {attestation.EntryId}: {ex.Message}",
attestation.EntryId));
return false;
}
}
private static byte[] ComputeBundleDigest(AttestationBundle bundle)
{
var sb = new StringBuilder();
sb.Append(bundle.MerkleTree.Root);
foreach (var attestation in bundle.Attestations.OrderBy(a => a.EntryId, StringComparer.Ordinal))
{
sb.Append('\n');
sb.Append(attestation.EntryId);
}
return SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
}
private static X509Certificate2? ParseCertificateFromPem(string pem)
{
try
{
const string beginMarker = "-----BEGIN CERTIFICATE-----";
const string endMarker = "-----END CERTIFICATE-----";
var begin = pem.IndexOf(beginMarker, StringComparison.Ordinal);
var end = pem.IndexOf(endMarker, StringComparison.Ordinal);
if (begin < 0 || end < 0)
{
// Try as raw base64
var certBytes = Convert.FromBase64String(pem.Trim());
return new X509Certificate2(certBytes);
}
var base64Start = begin + beginMarker.Length;
var base64Content = pem[base64Start..end]
.Replace("\r", "")
.Replace("\n", "")
.Trim();
var bytes = Convert.FromBase64String(base64Content);
return new X509Certificate2(bytes);
}
catch
{
return null;
}
}
private static async Task<AttestationBundle> LoadBundleAsync(
string path,
CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var bundle = await JsonSerializer.DeserializeAsync<AttestationBundle>(
stream,
cancellationToken: cancellationToken);
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
}
}
/// <summary>
/// Configuration for offline verification.
/// </summary>
public sealed class OfflineVerificationConfig
{
/// <summary>
/// Enable strict mode by default.
/// </summary>
public bool StrictModeDefault { get; set; }
/// <summary>
/// Require organization signature by default.
/// </summary>
public bool RequireOrgSignatureDefault { get; set; }
/// <summary>
/// Allow verification of unbundled attestations.
/// </summary>
public bool AllowUnbundled { get; set; } = true;
/// <summary>
/// Maximum bundle cache size in MB.
/// </summary>
public int MaxCacheSizeMb { get; set; } = 1024;
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Offline</RootNamespace>
<Description>Offline verification of attestation bundles for air-gapped environments.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
</ItemGroup>
</Project>