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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user