save progress
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Attestor.WebService.Options;
|
||||
|
||||
public sealed class AttestorWebServiceFeatures
|
||||
{
|
||||
public bool AnchorsEnabled { get; set; }
|
||||
|
||||
public bool ProofsEnabled { get; set; }
|
||||
|
||||
public bool VerifyEnabled { get; set; }
|
||||
|
||||
public bool VerdictsEnabled { get; set; } = true;
|
||||
}
|
||||
@@ -215,7 +215,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
// Directory of PEM files
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(path, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
@@ -224,10 +224,10 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
}
|
||||
|
||||
// Also try Offline Kit path if configured
|
||||
var offlineKitPath = GetOfflineKitPath(rootType);
|
||||
var offlineKitPath = _options.UseOfflineKit ? GetOfflineKitPath(rootType) : null;
|
||||
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
|
||||
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
var certs = await LoadPemFileAsync(file, cancellationToken);
|
||||
collection.AddRange(certs);
|
||||
|
||||
@@ -11,8 +11,12 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.X509;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
@@ -33,6 +37,8 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
private readonly IOrgKeySigner? _orgSigner;
|
||||
private readonly ILogger<OfflineVerifier> _logger;
|
||||
private readonly OfflineVerificationConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new offline verifier.
|
||||
@@ -42,13 +48,15 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
IMerkleTreeBuilder merkleBuilder,
|
||||
ILogger<OfflineVerifier> logger,
|
||||
IOptions<OfflineVerificationConfig> config,
|
||||
IOrgKeySigner? orgSigner = null)
|
||||
IOrgKeySigner? orgSigner = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_config = config?.Value ?? new OfflineVerificationConfig();
|
||||
_orgSigner = orgSigner;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -59,9 +67,9 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of bundle {BundleId} with {Count} attestations",
|
||||
@@ -166,9 +174,28 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var issues = new List<VerificationIssue>();
|
||||
var verifiedAt = DateTimeOffset.UtcNow;
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
if (!_config.AllowUnbundled)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Error,
|
||||
"UNBUNDLED_NOT_ALLOWED",
|
||||
"Unbundled attestation verification is disabled by configuration.",
|
||||
attestation.EntryId));
|
||||
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: verifiedAt,
|
||||
Issues: issues);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting offline verification of attestation {EntryId}",
|
||||
@@ -220,13 +247,62 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
|
||||
|
||||
options = ResolveOptions(options);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loading bundle from {Path} to verify artifact {Digest}",
|
||||
bundlePath,
|
||||
artifactDigest);
|
||||
|
||||
if (_config.MaxCacheSizeMb > 0)
|
||||
{
|
||||
var info = new FileInfo(bundlePath);
|
||||
if (info.Exists)
|
||||
{
|
||||
var maxBytes = (long)_config.MaxCacheSizeMb * 1024 * 1024;
|
||||
if (info.Length > maxBytes)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_TOO_LARGE",
|
||||
$"Bundle size {info.Length} bytes exceeds MaxCacheSizeMb {_config.MaxCacheSizeMb}.")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load bundle from file
|
||||
var bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
AttestationBundle bundle;
|
||||
try
|
||||
{
|
||||
bundle = await LoadBundleAsync(bundlePath, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OfflineVerificationResult(
|
||||
Valid: false,
|
||||
MerkleProofValid: false,
|
||||
SignaturesValid: false,
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
"BUNDLE_LOAD_FAILED",
|
||||
$"Failed to load bundle from {bundlePath}: {ex.Message}")
|
||||
});
|
||||
}
|
||||
|
||||
// Find attestations for this artifact
|
||||
var matchingAttestations = bundle.Attestations
|
||||
@@ -242,7 +318,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
CertificateChainValid: false,
|
||||
OrgSignatureValid: false,
|
||||
OrgSignatureKeyId: null,
|
||||
VerifiedAt: DateTimeOffset.UtcNow,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
Issues: new List<VerificationIssue>
|
||||
{
|
||||
new(Severity.Critical,
|
||||
@@ -268,7 +344,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
options ??= new OfflineVerificationOptions();
|
||||
options = ResolveOptions(options);
|
||||
var summaries = new List<AttestationVerificationSummary>();
|
||||
|
||||
var fulcioRoots = options.VerifyCertificateChain
|
||||
@@ -410,17 +486,30 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
// Verify signature using the certificate
|
||||
var signatureBytes = Convert.FromBase64String(bundle.OrgSignature.Signature);
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
if (string.Equals(bundle.OrgSignature.Algorithm, "Ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"Ed25519" => HashAlgorithmName.SHA256, // Ed25519 handles its own hashing
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
if (!TryVerifyEd25519Signature(digestData, signatureBytes, cert, out var error))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"ORG_SIG_INVALID",
|
||||
error ?? "Ed25519 signature verification failed."));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
using var pubKey = cert.GetECDsaPublicKey();
|
||||
if (pubKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"ECDSA_P256" => HashAlgorithmName.SHA256,
|
||||
"ECDSA_P384" => HashAlgorithmName.SHA384,
|
||||
"ECDSA_P521" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = pubKey.VerifyData(digestData, signatureBytes, algorithm);
|
||||
if (!valid)
|
||||
{
|
||||
@@ -435,6 +524,13 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
using var rsaKey = cert.GetRSAPublicKey();
|
||||
if (rsaKey != null)
|
||||
{
|
||||
var algorithm = bundle.OrgSignature.Algorithm switch
|
||||
{
|
||||
"RSA_PSS_SHA256" => HashAlgorithmName.SHA256,
|
||||
"RSA_PSS_SHA384" => HashAlgorithmName.SHA384,
|
||||
"RSA_PSS_SHA512" => HashAlgorithmName.SHA512,
|
||||
_ => HashAlgorithmName.SHA256
|
||||
};
|
||||
var valid = rsaKey.VerifyData(
|
||||
digestData,
|
||||
signatureBytes,
|
||||
@@ -480,7 +576,58 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify at least one signature is present and has non-empty sig
|
||||
if (string.IsNullOrWhiteSpace(attestation.Envelope.PayloadType))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOADTYPE_MISSING",
|
||||
$"PayloadType missing in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(attestation.Envelope.Payload, out var payloadBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_PAYLOAD_INVALID_BASE64",
|
||||
$"Invalid base64 payload in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attestation.Envelope.CertificateChain == null || attestation.Envelope.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_MISSING",
|
||||
$"Certificate chain missing for DSSE envelope {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var leafCert = ParseCertificateFromPem(attestation.Envelope.CertificateChain[0]);
|
||||
if (leafCert == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_CERT_PARSE_FAILED",
|
||||
$"Failed to parse leaf certificate for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryCreateEnvelopeKey(leafCert, out var key, out var keyError))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_KEY_UNSUPPORTED",
|
||||
keyError ?? $"Unsupported public key for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
}
|
||||
|
||||
var allValid = true;
|
||||
foreach (var sig in attestation.Envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
@@ -490,20 +637,70 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
"DSSE_EMPTY_SIG",
|
||||
$"Empty signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(sig.Sig, out var signatureBytes))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID_BASE64",
|
||||
$"Invalid base64 signature in DSSE envelope for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sig.KeyId) &&
|
||||
!string.Equals(sig.KeyId, key.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_KEYID_MISMATCH",
|
||||
$"Signature key ID mismatch for {attestation.EntryId}",
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var signature = new StellaOps.Attestor.Envelope.EnvelopeSignature(
|
||||
string.IsNullOrWhiteSpace(sig.KeyId) ? key.KeyId : sig.KeyId,
|
||||
key.AlgorithmId,
|
||||
signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.VerifyDsse(
|
||||
attestation.Envelope.PayloadType,
|
||||
payloadBytes,
|
||||
signature,
|
||||
key);
|
||||
|
||||
if (!verifyResult.IsSuccess || !verifyResult.Value)
|
||||
{
|
||||
var message = verifyResult.IsSuccess
|
||||
? "DSSE signature verification failed."
|
||||
: $"DSSE signature verification failed: {verifyResult.Error.Code}";
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_SIG_INVALID",
|
||||
message,
|
||||
attestation.EntryId));
|
||||
allValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Full cryptographic verification requires the certificate chain
|
||||
// Here we just validate structure; chain verification handles crypto
|
||||
_logger.LogDebug("DSSE envelope structure verified for {EntryId}", attestation.EntryId);
|
||||
return true;
|
||||
if (allValid)
|
||||
{
|
||||
_logger.LogDebug("DSSE signatures verified for {EntryId}", attestation.EntryId);
|
||||
}
|
||||
|
||||
return allValid;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
Severity.Critical,
|
||||
"DSSE_VERIFY_ERROR",
|
||||
"DSSE_SIG_VERIFY_ERROR",
|
||||
$"Failed to verify DSSE signature for {attestation.EntryId}: {ex.Message}",
|
||||
attestation.EntryId));
|
||||
return false;
|
||||
@@ -707,7 +904,7 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AttestationBundle> LoadBundleAsync(
|
||||
private async Task<AttestationBundle> LoadBundleAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -718,6 +915,130 @@ public sealed class OfflineVerifier : IOfflineVerifier
|
||||
|
||||
return bundle ?? throw new InvalidOperationException($"Failed to deserialize bundle from {path}");
|
||||
}
|
||||
|
||||
private OfflineVerificationOptions ResolveOptions(OfflineVerificationOptions? options)
|
||||
{
|
||||
if (options != null)
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: true,
|
||||
VerifySignatures: true,
|
||||
VerifyCertificateChain: true,
|
||||
VerifyOrgSignature: true,
|
||||
RequireOrgSignature: _config.RequireOrgSignatureDefault,
|
||||
FulcioRootPath: null,
|
||||
OrgKeyPath: null,
|
||||
StrictMode: _config.StrictModeDefault);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentNullException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryCreateEnvelopeKey(
|
||||
X509Certificate2 cert,
|
||||
out EnvelopeKey key,
|
||||
out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = cert.GetECDsaPublicKey();
|
||||
if (ecdsa != null)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(false);
|
||||
var algorithmId = ResolveEcdsaAlgorithm(parameters.Curve);
|
||||
key = EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
error = $"Failed to read ECDSA public key: {ex.Message}";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryGetEd25519PublicKey(cert, out var ed25519Key))
|
||||
{
|
||||
key = EnvelopeKey.CreateEd25519Verifier(ed25519Key);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "Unsupported public key algorithm.";
|
||||
key = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveEcdsaAlgorithm(ECCurve curve) => curve.Oid.Value switch
|
||||
{
|
||||
"1.2.840.10045.3.1.7" => "ES256", // NIST P-256
|
||||
"1.3.132.0.34" => "ES384", // NIST P-384
|
||||
"1.3.132.0.35" => "ES512", // NIST P-521
|
||||
_ => throw new ArgumentException("Unsupported ECDSA curve.")
|
||||
};
|
||||
|
||||
private static bool TryGetEd25519PublicKey(X509Certificate2 cert, out byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
publicKey = ed25519.GetEncoded();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow parse failures; caller handles error messaging.
|
||||
}
|
||||
|
||||
publicKey = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryVerifyEd25519Signature(
|
||||
byte[] message,
|
||||
byte[] signature,
|
||||
X509Certificate2 cert,
|
||||
out string? error)
|
||||
{
|
||||
var parser = new X509CertificateParser();
|
||||
var bcCert = parser.ReadCertificate(cert.RawData);
|
||||
if (bcCert?.GetPublicKey() is not Ed25519PublicKeyParameters ed25519)
|
||||
{
|
||||
error = "Ed25519 public key not found in certificate.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(false, ed25519);
|
||||
signer.BlockUpdate(message, 0, message.Length);
|
||||
if (!signer.VerifySignature(signature))
|
||||
{
|
||||
error = "Ed25519 signature verification failed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
|
||||
| AUDIT-0058-A | DOING | Pending approval for changes. |
|
||||
| AUDIT-0058-A | DONE | Applied DSSE verification, config defaults, offline kit gating, and deterministic ordering. |
|
||||
|
||||
@@ -190,10 +190,8 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
// Sort items deterministically by digest
|
||||
var sortedItems = items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
// Sort items deterministically by digest and stable tie-breakers
|
||||
var sortedItems = TrustEvidenceOrdering.OrderItems(items).ToList();
|
||||
|
||||
if (sortedItems.Count == 0)
|
||||
{
|
||||
@@ -328,6 +326,21 @@ public sealed class TrustEvidenceMerkleBuilder : ITrustEvidenceMerkleBuilder
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TrustEvidenceOrdering
|
||||
{
|
||||
public static IOrderedEnumerable<TrustEvidenceItem> OrderItems(IEnumerable<TrustEvidenceItem> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
return items
|
||||
.OrderBy(i => i.Digest, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Type, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Uri ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.Description ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.CollectedAt?.ToUniversalTime());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for TrustEvidenceMerkleTree.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// JsonCanonicalizer - Deterministic JSON serialization for content addressing
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict;
|
||||
|
||||
@@ -21,13 +20,11 @@ namespace StellaOps.Attestor.TrustVerdict;
|
||||
/// </remarks>
|
||||
public static class JsonCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_canonicalOptions = new()
|
||||
private static readonly JsonSerializerOptions CanonicalOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNamingPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Converters = { new SortedObjectConverter() }
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
@@ -35,12 +32,8 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize<T>(T value)
|
||||
{
|
||||
// First serialize to JSON document to get raw structure
|
||||
var json = JsonSerializer.Serialize(value, s_canonicalOptions);
|
||||
|
||||
// Re-parse and canonicalize
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
var json = JsonSerializer.Serialize(value, CanonicalOptions);
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,8 +41,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string Canonicalize(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return CanonicalizeElement(doc.RootElement);
|
||||
return StellaOps.Attestor.StandardPredicates.JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,146 +49,7 @@ public static class JsonCanonicalizer
|
||||
/// </summary>
|
||||
public static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
|
||||
WriteCanonical(writer, element);
|
||||
writer.Flush();
|
||||
|
||||
return Encoding.UTF8.GetString(buffer.WrittenSpan);
|
||||
}
|
||||
|
||||
private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.String:
|
||||
writer.WriteStringValue(element.GetString());
|
||||
break;
|
||||
|
||||
case JsonValueKind.Number:
|
||||
WriteCanonicalNumber(writer, element);
|
||||
break;
|
||||
|
||||
case JsonValueKind.True:
|
||||
writer.WriteBooleanValue(true);
|
||||
break;
|
||||
|
||||
case JsonValueKind.False:
|
||||
writer.WriteBooleanValue(false);
|
||||
break;
|
||||
|
||||
case JsonValueKind.Null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unsupported JSON value kind: {element.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
// Sort properties lexicographically by key
|
||||
var properties = element.EnumerateObject()
|
||||
.OrderBy(p => p.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(writer, property.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalNumber(Utf8JsonWriter writer, JsonElement element)
|
||||
{
|
||||
// RFC 8785: Numbers must be represented without exponent notation
|
||||
// and with minimal significant digits
|
||||
if (element.TryGetInt64(out var longValue))
|
||||
{
|
||||
writer.WriteNumberValue(longValue);
|
||||
}
|
||||
else if (element.TryGetDecimal(out var decimalValue))
|
||||
{
|
||||
// Normalize to remove trailing zeros
|
||||
writer.WriteNumberValue(decimalValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteRawValue(element.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom converter that ensures object properties are sorted.
|
||||
/// </summary>
|
||||
private sealed class SortedObjectConverter : JsonConverter<object>
|
||||
{
|
||||
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
throw new NotSupportedException("Deserialization not supported");
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var type = value.GetType();
|
||||
|
||||
// Get all public properties, sort by name
|
||||
var properties = type.GetProperties()
|
||||
.Where(p => p.CanRead)
|
||||
.OrderBy(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name, StringComparer.Ordinal);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var propValue = property.GetValue(value);
|
||||
if (propValue is null && options.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name;
|
||||
writer.WritePropertyName(name);
|
||||
JsonSerializer.Serialize(writer, propValue, property.PropertyType, options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
var json = element.GetRawText();
|
||||
return JsonCanonicalizer.Canonicalize(json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
try
|
||||
{
|
||||
// Parse reference
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return new TrustVerdictOciAttachResult
|
||||
@@ -154,18 +154,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
// 2. Create artifact manifest referencing the blob
|
||||
// 3. Push manifest with subject pointing to original image
|
||||
|
||||
_logger.LogInformation(
|
||||
"Would attach TrustVerdict {Digest} to {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
|
||||
// Placeholder - full implementation requires OCI client
|
||||
var mockDigest = $"sha256:{Guid.NewGuid():N}";
|
||||
_logger.LogWarning(
|
||||
"OCI attachment is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return new TrustVerdictOciAttachResult
|
||||
{
|
||||
Success = true,
|
||||
OciDigest = mockDigest,
|
||||
ManifestDigest = mockDigest,
|
||||
Success = false,
|
||||
ErrorMessage = "OCI attachment is not implemented.",
|
||||
Duration = _timeProvider.GetUtcNow() - startTime
|
||||
};
|
||||
}
|
||||
@@ -195,19 +191,14 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
_logger.LogWarning("Invalid OCI reference: {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Query referrers API
|
||||
// GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType}
|
||||
|
||||
_logger.LogDebug("Would fetch TrustVerdict from {Reference} (implementation pending)", imageReference);
|
||||
|
||||
// Placeholder
|
||||
_logger.LogWarning("OCI fetch is enabled but not implemented for {Reference}", imageReference);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -230,15 +221,13 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = ParseReference(imageReference);
|
||||
var parsed = ParseReference(imageReference, opts.DefaultRegistry);
|
||||
if (parsed == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// Query referrers API and filter by artifact type
|
||||
_logger.LogDebug("Would list TrustVerdicts for {Reference} (implementation pending)", imageReference);
|
||||
|
||||
_logger.LogWarning("OCI list is enabled but not implemented for {Reference}", imageReference);
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -262,10 +251,9 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
|
||||
try
|
||||
{
|
||||
// DELETE the referrer manifest
|
||||
_logger.LogDebug(
|
||||
"Would detach TrustVerdict {Digest} from {Reference} (implementation pending)",
|
||||
verdictDigest, imageReference);
|
||||
_logger.LogWarning(
|
||||
"OCI detach is enabled but not implemented for {Reference}",
|
||||
imageReference);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -276,38 +264,56 @@ public sealed class TrustVerdictOciAttacher : ITrustVerdictOciAttacher
|
||||
}
|
||||
}
|
||||
|
||||
private static OciReference? ParseReference(string reference)
|
||||
private static OciReference? ParseReference(string reference, string? defaultRegistry)
|
||||
{
|
||||
// Parse: registry/repo:tag or registry/repo@sha256:digest
|
||||
// Parse: registry/repo:tag, registry/repo@sha256:digest, repo:tag, repo@sha256:digest
|
||||
try
|
||||
{
|
||||
var atIdx = reference.IndexOf('@');
|
||||
var colonIdx = reference.LastIndexOf(':');
|
||||
var trimmed = reference.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var atIdx = trimmed.LastIndexOf('@');
|
||||
var digest = atIdx >= 0 ? trimmed[(atIdx + 1)..] : null;
|
||||
var namePart = atIdx >= 0 ? trimmed[..atIdx] : trimmed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(namePart))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? tag = null;
|
||||
var lastSlash = namePart.LastIndexOf('/');
|
||||
var lastColon = namePart.LastIndexOf(':');
|
||||
if (lastColon > lastSlash)
|
||||
{
|
||||
tag = namePart[(lastColon + 1)..];
|
||||
namePart = namePart[..lastColon];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tag) && string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string registry;
|
||||
string repository;
|
||||
string? tag = null;
|
||||
string? digest = null;
|
||||
|
||||
if (atIdx > 0)
|
||||
var slashIdx = namePart.IndexOf('/');
|
||||
if (slashIdx > 0)
|
||||
{
|
||||
// Has digest
|
||||
digest = reference[(atIdx + 1)..];
|
||||
var beforeDigest = reference[..atIdx];
|
||||
var slashIdx = beforeDigest.IndexOf('/');
|
||||
registry = beforeDigest[..slashIdx];
|
||||
repository = beforeDigest[(slashIdx + 1)..];
|
||||
}
|
||||
else if (colonIdx > 0 && colonIdx > reference.IndexOf('/'))
|
||||
{
|
||||
// Has tag
|
||||
tag = reference[(colonIdx + 1)..];
|
||||
var beforeTag = reference[..colonIdx];
|
||||
var slashIdx = beforeTag.IndexOf('/');
|
||||
registry = beforeTag[..slashIdx];
|
||||
repository = beforeTag[(slashIdx + 1)..];
|
||||
registry = namePart[..slashIdx];
|
||||
repository = namePart[(slashIdx + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
repository = namePart;
|
||||
registry = defaultRegistry ?? string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(repository) || string.IsNullOrWhiteSpace(registry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// TrustVerdictService - Service for generating signed TrustVerdict attestations
|
||||
// Part of SPRINT_1227_0004_0004: Signed TrustVerdict Attestations
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.StandardPredicates;
|
||||
using StellaOps.Attestor.TrustVerdict.Evidence;
|
||||
using StellaOps.Attestor.TrustVerdict.Predicates;
|
||||
|
||||
namespace StellaOps.Attestor.TrustVerdict.Services;
|
||||
@@ -266,6 +267,7 @@ public sealed record TrustVerdictResult
|
||||
public sealed class TrustVerdictService : ITrustVerdictService
|
||||
{
|
||||
private readonly IOptionsMonitor<TrustVerdictServiceOptions> _options;
|
||||
private readonly ITrustEvidenceMerkleBuilder _merkleBuilder;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TrustVerdictService> _logger;
|
||||
|
||||
@@ -275,10 +277,12 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
public TrustVerdictService(
|
||||
IOptionsMonitor<TrustVerdictServiceOptions> options,
|
||||
ILogger<TrustVerdictService> logger,
|
||||
ITrustEvidenceMerkleBuilder merkleBuilder,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_merkleBuilder = merkleBuilder ?? throw new ArgumentNullException(nameof(merkleBuilder));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -441,7 +445,6 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
|
||||
// Build evidence chain
|
||||
var evidenceItems = request.EvidenceItems
|
||||
.OrderBy(e => e.Digest, StringComparer.Ordinal)
|
||||
.Select(e => new TrustEvidenceItem
|
||||
{
|
||||
Type = e.Type,
|
||||
@@ -452,12 +455,13 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var merkleRoot = ComputeMerkleRoot(evidenceItems);
|
||||
var orderedEvidence = TrustEvidenceOrdering.OrderItems(evidenceItems).ToList();
|
||||
var merkleTree = _merkleBuilder.Build(orderedEvidence);
|
||||
|
||||
var evidence = new TrustEvidenceChain
|
||||
{
|
||||
MerkleRoot = merkleRoot,
|
||||
Items = evidenceItems
|
||||
MerkleRoot = merkleTree.Root,
|
||||
Items = orderedEvidence
|
||||
};
|
||||
|
||||
// Build metadata
|
||||
@@ -560,54 +564,17 @@ public sealed class TrustVerdictService : ITrustVerdictService
|
||||
reasons.Add($"VEX freshness: {freshness.Status} ({freshness.AgeInDays} days old)");
|
||||
|
||||
// Reputation reason
|
||||
reasons.Add($"Issuer reputation: {reputation.Composite:P0} ({reputation.SampleCount} samples)");
|
||||
var reputationPercent = reputation.Composite.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Issuer reputation: {reputationPercent} ({reputation.SampleCount} samples)");
|
||||
|
||||
// Composite summary
|
||||
var tier = TrustTiers.FromScore(compositeScore);
|
||||
reasons.Add($"Overall trust: {tier} ({compositeScore:P0})");
|
||||
var compositePercent = compositeScore.ToString("P0", CultureInfo.InvariantCulture);
|
||||
reasons.Add($"Overall trust: {tier} ({compositePercent})");
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private static string ComputeMerkleRoot(IReadOnlyList<TrustEvidenceItem> items)
|
||||
{
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return "sha256:" + Convert.ToHexStringLower(SHA256.HashData([]));
|
||||
}
|
||||
|
||||
// Get leaf hashes
|
||||
var hashes = items
|
||||
.Select(i => SHA256.HashData(Encoding.UTF8.GetBytes(i.Digest)))
|
||||
.ToList();
|
||||
|
||||
// Build tree bottom-up
|
||||
while (hashes.Count > 1)
|
||||
{
|
||||
var newLevel = new List<byte[]>();
|
||||
|
||||
for (var i = 0; i < hashes.Count; i += 2)
|
||||
{
|
||||
if (i + 1 < hashes.Count)
|
||||
{
|
||||
// Combine two nodes
|
||||
var combined = new byte[hashes[i].Length + hashes[i + 1].Length];
|
||||
hashes[i].CopyTo(combined, 0);
|
||||
hashes[i + 1].CopyTo(combined, hashes[i].Length);
|
||||
newLevel.Add(SHA256.HashData(combined));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Odd node, promote as-is
|
||||
newLevel.Add(hashes[i]);
|
||||
}
|
||||
}
|
||||
|
||||
hashes = newLevel;
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexStringLower(hashes[0])}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0067-M | DONE | Maintainability audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-T | DONE | Test coverage audit for StellaOps.Attestor.TrustVerdict. |
|
||||
| AUDIT-0067-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0067-A | DOING | Applying audit fixes for TrustVerdict library. |
|
||||
|
||||
@@ -98,6 +98,32 @@ public class FileSystemRootStoreTests : IDisposable
|
||||
roots.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithDirectory_OrdersByFileName()
|
||||
{
|
||||
// Arrange
|
||||
var fulcioDir = Path.Combine(_testRootPath, "fulcio-ordered");
|
||||
Directory.CreateDirectory(fulcioDir);
|
||||
|
||||
var certA = CreateTestCertificate("CN=Root A");
|
||||
var certB = CreateTestCertificate("CN=Root B");
|
||||
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "b.pem"), certB);
|
||||
await WritePemFileAsync(Path.Combine(fulcioDir, "a.pem"), certA);
|
||||
|
||||
var options = CreateOptions(fulcioPath: fulcioDir);
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().HaveCount(2);
|
||||
roots[0].Subject.Should().Be("CN=Root A");
|
||||
roots[1].Subject.Should().Be("CN=Root B");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_CachesCertificates_OnSecondCall()
|
||||
@@ -328,6 +354,33 @@ public class FileSystemRootStoreTests : IDisposable
|
||||
roots[0].Subject.Should().Be("CN=Offline Kit Root");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetFulcioRootsAsync_WithOfflineKitPath_Disabled_DoesNotLoad()
|
||||
{
|
||||
// Arrange
|
||||
var offlineKitPath = Path.Combine(_testRootPath, "offline-kit-disabled");
|
||||
var fulcioKitDir = Path.Combine(offlineKitPath, "roots", "fulcio");
|
||||
Directory.CreateDirectory(fulcioKitDir);
|
||||
|
||||
var cert = CreateTestCertificate("CN=Offline Kit Root");
|
||||
await WritePemFileAsync(Path.Combine(fulcioKitDir, "root.pem"), cert);
|
||||
|
||||
var options = Options.Create(new OfflineRootStoreOptions
|
||||
{
|
||||
BaseRootPath = _testRootPath,
|
||||
OfflineKitPath = offlineKitPath,
|
||||
UseOfflineKit = false
|
||||
});
|
||||
var store = CreateStore(options);
|
||||
|
||||
// Act
|
||||
var roots = await store.GetFulcioRootsAsync();
|
||||
|
||||
// Assert
|
||||
roots.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private FileSystemRootStore CreateStore(IOptions<OfflineRootStoreOptions> options)
|
||||
{
|
||||
return new FileSystemRootStore(_loggerMock.Object, options);
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
// Description: Unit tests for OfflineVerifier service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
using StellaOps.Attestor.Offline.Services;
|
||||
using StellaOps.Attestor.ProofChain.Merkle;
|
||||
using BundlingEnvelopeSignature = StellaOps.Attestor.Bundling.Models.EnvelopeSignature;
|
||||
|
||||
// Alias to resolve ambiguity
|
||||
using Severity = StellaOps.Attestor.Offline.Models.VerificationIssueSeverity;
|
||||
@@ -25,6 +29,7 @@ namespace StellaOps.Attestor.Offline.Tests;
|
||||
|
||||
public class OfflineVerifierTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private readonly Mock<IOfflineRootStore> _rootStoreMock;
|
||||
private readonly IMerkleTreeBuilder _merkleBuilder;
|
||||
private readonly Mock<IOrgKeySigner> _orgSignerMock;
|
||||
@@ -137,7 +142,7 @@ public class OfflineVerifierTests
|
||||
KeyId = "org-key-2025",
|
||||
Algorithm = "ECDSA_P256",
|
||||
Signature = Convert.ToBase64String(new byte[64]),
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = FixedNow,
|
||||
CertificateChain = null
|
||||
};
|
||||
|
||||
@@ -197,7 +202,7 @@ public class OfflineVerifierTests
|
||||
{
|
||||
Envelope = attestation.Envelope with
|
||||
{
|
||||
Signatures = new List<EnvelopeSignature>()
|
||||
Signatures = new List<BundlingEnvelopeSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,7 +260,7 @@ public class OfflineVerifierTests
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
},
|
||||
Path = new List<string>() // Empty path triggers warning
|
||||
}
|
||||
@@ -278,6 +283,85 @@ public class OfflineVerifierTests
|
||||
result.Issues.Should().Contain(i => i.Severity == Severity.Warning);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_UsesConfigDefaults_WhenOptionsNull()
|
||||
{
|
||||
// Arrange
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
RequireOrgSignatureDefault = true
|
||||
});
|
||||
var bundle = CreateTestBundle(1);
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyBundleAsync(bundle, options: null);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "ORG_SIG_MISSING");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyAttestationAsync_UnbundledDisabled_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
AllowUnbundled = false
|
||||
});
|
||||
var attestation = CreateTestAttestation("entry-001");
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyAttestationAsync(attestation, options: null);
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "UNBUNDLED_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyByArtifactAsync_BundleTooLarge_ReturnsInvalid()
|
||||
{
|
||||
// Arrange
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
await File.WriteAllBytesAsync(tempPath, new byte[2 * 1024 * 1024]);
|
||||
|
||||
var config = Options.Create(new OfflineVerificationConfig
|
||||
{
|
||||
MaxCacheSizeMb = 1
|
||||
});
|
||||
var verifier = CreateVerifier(config);
|
||||
|
||||
// Act
|
||||
var result = await verifier.VerifyByArtifactAsync(
|
||||
"sha256:deadbeef",
|
||||
tempPath,
|
||||
new OfflineVerificationOptions(
|
||||
VerifyMerkleProof: false,
|
||||
VerifySignatures: false,
|
||||
VerifyCertificateChain: false,
|
||||
VerifyOrgSignature: false));
|
||||
|
||||
// Assert
|
||||
result.Valid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Code == "BUNDLE_TOO_LARGE");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerifyBundleAsync_DeterministicOrdering_SameMerkleValidation()
|
||||
@@ -306,16 +390,6 @@ public class OfflineVerifierTests
|
||||
result1.MerkleProofValid.Should().Be(result2.MerkleProofValid);
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier()
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
_config,
|
||||
_orgSignerMock.Object);
|
||||
}
|
||||
|
||||
private AttestationBundle CreateTestBundle(int attestationCount)
|
||||
{
|
||||
var attestations = Enumerable.Range(0, attestationCount)
|
||||
@@ -346,9 +420,9 @@ public class OfflineVerifierTests
|
||||
{
|
||||
BundleId = merkleRootHex,
|
||||
Version = "1.0",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PeriodStart = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
PeriodEnd = DateTimeOffset.UtcNow,
|
||||
CreatedAt = FixedNow,
|
||||
PeriodStart = FixedNow.AddDays(-30),
|
||||
PeriodEnd = FixedNow,
|
||||
AttestationCount = attestations.Length
|
||||
},
|
||||
Attestations = attestations,
|
||||
@@ -363,14 +437,28 @@ public class OfflineVerifierTests
|
||||
|
||||
private static BundledAttestation CreateTestAttestation(string entryId)
|
||||
{
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payloadBytes = "{\"test\":true}"u8.ToArray();
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var (cert, key) = CreateTestKeyMaterial();
|
||||
var signatureService = new EnvelopeSignatureService();
|
||||
var signatureResult = signatureService.SignDsse(payloadType, payloadBytes, key);
|
||||
if (!signatureResult.IsSuccess)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to sign DSSE payload: {signatureResult.Error.Code}");
|
||||
}
|
||||
|
||||
var envelopeSignature = signatureResult.Value;
|
||||
|
||||
return new BundledAttestation
|
||||
{
|
||||
EntryId = entryId,
|
||||
RekorUuid = Guid.NewGuid().ToString("N"),
|
||||
RekorUuid = entryId,
|
||||
RekorLogIndex = 10000,
|
||||
ArtifactDigest = $"sha256:{entryId.PadRight(64, 'a')}",
|
||||
PredicateType = "verdict.stella/v1",
|
||||
SignedAt = DateTimeOffset.UtcNow,
|
||||
SignedAt = FixedNow,
|
||||
SigningMode = "keyless",
|
||||
SigningIdentity = new SigningIdentity
|
||||
{
|
||||
@@ -385,7 +473,7 @@ public class OfflineVerifierTests
|
||||
Origin = "rekor.sigstore.dev",
|
||||
Size = 100000,
|
||||
RootHash = Convert.ToBase64String(new byte[32]),
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = FixedNow
|
||||
},
|
||||
Path = new List<string>
|
||||
{
|
||||
@@ -395,17 +483,53 @@ public class OfflineVerifierTests
|
||||
},
|
||||
Envelope = new DsseEnvelopeData
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String("{\"test\":true}"u8.ToArray()),
|
||||
Signatures = new List<EnvelopeSignature>
|
||||
PayloadType = payloadType,
|
||||
Payload = payloadBase64,
|
||||
Signatures = new List<BundlingEnvelopeSignature>
|
||||
{
|
||||
new() { KeyId = "key-1", Sig = Convert.ToBase64String(new byte[64]) }
|
||||
new()
|
||||
{
|
||||
KeyId = envelopeSignature.KeyId,
|
||||
Sig = Convert.ToBase64String(envelopeSignature.Value.ToArray())
|
||||
}
|
||||
},
|
||||
CertificateChain = new List<string>
|
||||
{
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
ToPem(cert)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private OfflineVerifier CreateVerifier(
|
||||
IOptions<OfflineVerificationConfig>? config = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
return new OfflineVerifier(
|
||||
_rootStoreMock.Object,
|
||||
_merkleBuilder,
|
||||
_loggerMock.Object,
|
||||
config ?? _config,
|
||||
_orgSignerMock.Object,
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private static (X509Certificate2 Cert, EnvelopeKey Key) CreateTestKeyMaterial()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var request = new CertificateRequest("CN=Test Fulcio Root", ecdsa, HashAlgorithmName.SHA256);
|
||||
var cert = request.CreateSelfSigned(FixedNow.AddDays(-1), FixedNow.AddYears(1));
|
||||
var key = EnvelopeKey.CreateEcdsaSigner("ES256", ecdsa.ExportParameters(true));
|
||||
return (cert, key);
|
||||
}
|
||||
|
||||
private static string ToPem(X509Certificate2 cert)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
builder.AppendLine(base64);
|
||||
builder.AppendLine("-----END CERTIFICATE-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user