save progress

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

View File

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

View File

@@ -215,7 +215,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
else if (Directory.Exists(path))
{
// Directory of PEM files
foreach (var file in Directory.EnumerateFiles(path, "*.pem"))
foreach (var file in Directory.EnumerateFiles(path, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
{
var certs = await LoadPemFileAsync(file, cancellationToken);
collection.AddRange(certs);
@@ -224,10 +224,10 @@ public sealed class FileSystemRootStore : IOfflineRootStore
}
// Also try Offline Kit path if configured
var offlineKitPath = GetOfflineKitPath(rootType);
var offlineKitPath = _options.UseOfflineKit ? GetOfflineKitPath(rootType) : null;
if (!string.IsNullOrEmpty(offlineKitPath) && Directory.Exists(offlineKitPath))
{
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem"))
foreach (var file in Directory.EnumerateFiles(offlineKitPath, "*.pem").OrderBy(x => x, StringComparer.Ordinal))
{
var certs = await LoadPemFileAsync(file, cancellationToken);
collection.AddRange(certs);

View File

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

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0058-M | DONE | Maintainability audit for StellaOps.Attestor.Offline. |
| AUDIT-0058-T | DONE | Test coverage audit for StellaOps.Attestor.Offline. |
| AUDIT-0058-A | DOING | Pending approval for changes. |
| AUDIT-0058-A | DONE | Applied DSSE verification, config defaults, offline kit gating, and deterministic ordering. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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