sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// SigstoreBundleBuilder.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Tasks: BUNDLE-8200-008 to BUNDLE-8200-011 - Bundle builder
// Description: Fluent builder for constructing Sigstore bundles
// -----------------------------------------------------------------------------
using StellaOps.Attestor.Bundle.Models;
using StellaOps.Attestor.Bundle.Serialization;
namespace StellaOps.Attestor.Bundle.Builder;
/// <summary>
/// Fluent builder for constructing Sigstore bundles.
/// </summary>
public sealed class SigstoreBundleBuilder
{
private BundleDsseEnvelope? _dsseEnvelope;
private CertificateInfo? _certificate;
private PublicKeyInfo? _publicKey;
private List<TransparencyLogEntry>? _tlogEntries;
private TimestampVerificationData? _timestampData;
private string _mediaType = SigstoreBundleConstants.MediaTypeV03;
/// <summary>
/// Sets the DSSE envelope from raw components.
/// </summary>
/// <param name="payloadType">Payload type (e.g., "application/vnd.in-toto+json").</param>
/// <param name="payload">Base64-encoded payload.</param>
/// <param name="signatures">Signatures over the payload.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithDsseEnvelope(
string payloadType,
string payload,
IEnumerable<BundleSignature> signatures)
{
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
ArgumentException.ThrowIfNullOrWhiteSpace(payload);
ArgumentNullException.ThrowIfNull(signatures);
_dsseEnvelope = new BundleDsseEnvelope
{
PayloadType = payloadType,
Payload = payload,
Signatures = signatures.ToList()
};
return this;
}
/// <summary>
/// Sets the DSSE envelope from an existing envelope object.
/// </summary>
/// <param name="envelope">The DSSE envelope.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithDsseEnvelope(BundleDsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
_dsseEnvelope = envelope;
return this;
}
/// <summary>
/// Adds a certificate for keyless signing verification.
/// </summary>
/// <param name="derCertificate">DER-encoded certificate bytes.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithCertificate(byte[] derCertificate)
{
ArgumentNullException.ThrowIfNull(derCertificate);
_certificate = new CertificateInfo
{
RawBytes = Convert.ToBase64String(derCertificate)
};
return this;
}
/// <summary>
/// Adds a certificate from base64-encoded DER.
/// </summary>
/// <param name="base64DerCertificate">Base64-encoded DER certificate.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithCertificateBase64(string base64DerCertificate)
{
ArgumentException.ThrowIfNullOrWhiteSpace(base64DerCertificate);
_certificate = new CertificateInfo
{
RawBytes = base64DerCertificate
};
return this;
}
/// <summary>
/// Adds a public key for keyful signing verification.
/// </summary>
/// <param name="publicKeyBytes">Public key bytes.</param>
/// <param name="hint">Optional key hint for identification.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithPublicKey(byte[] publicKeyBytes, string? hint = null)
{
ArgumentNullException.ThrowIfNull(publicKeyBytes);
_publicKey = new PublicKeyInfo
{
RawBytes = Convert.ToBase64String(publicKeyBytes),
Hint = hint
};
return this;
}
/// <summary>
/// Adds a transparency log (Rekor) entry.
/// </summary>
/// <param name="entry">The transparency log entry.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithRekorEntry(TransparencyLogEntry entry)
{
ArgumentNullException.ThrowIfNull(entry);
_tlogEntries ??= new List<TransparencyLogEntry>();
_tlogEntries.Add(entry);
return this;
}
/// <summary>
/// Adds a transparency log entry from components.
/// </summary>
/// <param name="logIndex">Log index.</param>
/// <param name="logIdKeyId">Log ID key identifier (base64).</param>
/// <param name="integratedTime">Unix timestamp when integrated.</param>
/// <param name="canonicalizedBody">Base64-encoded canonicalized body.</param>
/// <param name="kind">Entry kind (e.g., "dsse").</param>
/// <param name="version">Entry version (e.g., "0.0.1").</param>
/// <param name="inclusionProof">Optional inclusion proof.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithRekorEntry(
string logIndex,
string logIdKeyId,
string integratedTime,
string canonicalizedBody,
string kind = "dsse",
string version = "0.0.1",
InclusionProof? inclusionProof = null)
{
var entry = new TransparencyLogEntry
{
LogIndex = logIndex,
LogId = new LogId { KeyId = logIdKeyId },
KindVersion = new KindVersion { Kind = kind, Version = version },
IntegratedTime = integratedTime,
CanonicalizedBody = canonicalizedBody,
InclusionProof = inclusionProof
};
return WithRekorEntry(entry);
}
/// <summary>
/// Adds an inclusion proof to the most recent Rekor entry.
/// </summary>
/// <param name="proof">The inclusion proof.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithInclusionProof(InclusionProof proof)
{
ArgumentNullException.ThrowIfNull(proof);
if (_tlogEntries is null || _tlogEntries.Count == 0)
{
throw new InvalidOperationException("Cannot add inclusion proof without a Rekor entry");
}
var lastEntry = _tlogEntries[^1];
_tlogEntries[^1] = lastEntry with { InclusionProof = proof };
return this;
}
/// <summary>
/// Adds timestamp verification data.
/// </summary>
/// <param name="rfc3161Timestamps">RFC 3161 timestamp responses.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithTimestamps(IEnumerable<string> rfc3161Timestamps)
{
ArgumentNullException.ThrowIfNull(rfc3161Timestamps);
var timestamps = rfc3161Timestamps
.Select(t => new Rfc3161Timestamp { SignedTimestamp = t })
.ToList();
if (timestamps.Count > 0)
{
_timestampData = new TimestampVerificationData
{
Rfc3161Timestamps = timestamps
};
}
return this;
}
/// <summary>
/// Sets the bundle media type (defaults to v0.3).
/// </summary>
/// <param name="mediaType">Media type string.</param>
/// <returns>This builder for chaining.</returns>
public SigstoreBundleBuilder WithMediaType(string mediaType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(mediaType);
_mediaType = mediaType;
return this;
}
/// <summary>
/// Builds the Sigstore bundle.
/// </summary>
/// <returns>The constructed bundle.</returns>
/// <exception cref="SigstoreBundleException">Thrown when required components are missing.</exception>
public SigstoreBundle Build()
{
if (_dsseEnvelope is null)
{
throw new SigstoreBundleException("DSSE envelope is required");
}
if (_certificate is null && _publicKey is null)
{
throw new SigstoreBundleException("Either certificate or public key is required");
}
var verificationMaterial = new VerificationMaterial
{
Certificate = _certificate,
PublicKey = _publicKey,
TlogEntries = _tlogEntries?.Count > 0 ? _tlogEntries : null,
TimestampVerificationData = _timestampData
};
return new SigstoreBundle
{
MediaType = _mediaType,
VerificationMaterial = verificationMaterial,
DsseEnvelope = _dsseEnvelope
};
}
/// <summary>
/// Builds the bundle and serializes to JSON.
/// </summary>
/// <returns>JSON string representation of the bundle.</returns>
public string BuildJson()
{
var bundle = Build();
return SigstoreBundleSerializer.Serialize(bundle);
}
/// <summary>
/// Builds the bundle and serializes to UTF-8 bytes.
/// </summary>
/// <returns>UTF-8 encoded JSON bytes.</returns>
public byte[] BuildUtf8Bytes()
{
var bundle = Build();
return SigstoreBundleSerializer.SerializeToUtf8Bytes(bundle);
}
}

View File

@@ -0,0 +1,58 @@
// -----------------------------------------------------------------------------
// InclusionProof.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-004 - Create InclusionProof model
// Description: Merkle inclusion proof for transparency log verification
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Bundle.Models;
/// <summary>
/// Merkle inclusion proof for verifying entry presence in transparency log.
/// </summary>
public sealed record InclusionProof
{
/// <summary>
/// Index of the entry in the log at the time of proof generation.
/// </summary>
[JsonPropertyName("logIndex")]
public required string LogIndex { get; init; }
/// <summary>
/// Base64-encoded Merkle root hash.
/// </summary>
[JsonPropertyName("rootHash")]
public required string RootHash { get; init; }
/// <summary>
/// Tree size at the time of proof generation.
/// </summary>
[JsonPropertyName("treeSize")]
public required string TreeSize { get; init; }
/// <summary>
/// Base64-encoded sibling hashes for the Merkle path.
/// </summary>
[JsonPropertyName("hashes")]
public required IReadOnlyList<string> Hashes { get; init; }
/// <summary>
/// Signed checkpoint from the log.
/// </summary>
[JsonPropertyName("checkpoint")]
public required Checkpoint Checkpoint { get; init; }
}
/// <summary>
/// Signed checkpoint from the transparency log.
/// </summary>
public sealed record Checkpoint
{
/// <summary>
/// Checkpoint envelope in note format.
/// </summary>
[JsonPropertyName("envelope")]
public required string Envelope { get; init; }
}

View File

@@ -0,0 +1,101 @@
// -----------------------------------------------------------------------------
// SigstoreBundle.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-001 - Create SigstoreBundle record matching v0.3 schema
// Description: Sigstore Bundle v0.3 model for offline verification
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Bundle.Models;
/// <summary>
/// Sigstore Bundle v0.3 format for offline verification.
/// Contains all material needed to verify a DSSE envelope without network access.
/// See: https://github.com/sigstore/cosign/blob/main/specs/BUNDLE_SPEC.md
/// </summary>
public sealed record SigstoreBundle
{
/// <summary>
/// Media type identifying this as a Sigstore bundle v0.3.
/// </summary>
[JsonPropertyName("mediaType")]
public string MediaType { get; init; } = SigstoreBundleConstants.MediaTypeV03;
/// <summary>
/// Verification material containing certificates and transparency log entries.
/// </summary>
[JsonPropertyName("verificationMaterial")]
public required VerificationMaterial VerificationMaterial { get; init; }
/// <summary>
/// The signed DSSE envelope containing the attestation.
/// </summary>
[JsonPropertyName("dsseEnvelope")]
public required BundleDsseEnvelope DsseEnvelope { get; init; }
}
/// <summary>
/// DSSE envelope representation within a Sigstore bundle.
/// Uses base64-encoded payload for JSON serialization.
/// </summary>
public sealed record BundleDsseEnvelope
{
/// <summary>
/// The payload type (e.g., "application/vnd.in-toto+json").
/// </summary>
[JsonPropertyName("payloadType")]
public required string PayloadType { get; init; }
/// <summary>
/// Base64-encoded payload content.
/// </summary>
[JsonPropertyName("payload")]
public required string Payload { get; init; }
/// <summary>
/// Signatures over the payload.
/// </summary>
[JsonPropertyName("signatures")]
public required IReadOnlyList<BundleSignature> Signatures { get; init; }
}
/// <summary>
/// Signature within a bundle DSSE envelope.
/// </summary>
public sealed record BundleSignature
{
/// <summary>
/// Optional key identifier.
/// </summary>
[JsonPropertyName("keyid")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? KeyId { get; init; }
/// <summary>
/// Base64-encoded signature.
/// </summary>
[JsonPropertyName("sig")]
public required string Sig { get; init; }
}
/// <summary>
/// Constants for Sigstore bundle media types and versions.
/// </summary>
public static class SigstoreBundleConstants
{
/// <summary>
/// Media type for Sigstore Bundle v0.3 JSON format.
/// </summary>
public const string MediaTypeV03 = "application/vnd.dev.sigstore.bundle.v0.3+json";
/// <summary>
/// Media type for Sigstore Bundle v0.2 JSON format (legacy).
/// </summary>
public const string MediaTypeV02 = "application/vnd.dev.sigstore.bundle+json;version=0.2";
/// <summary>
/// Rekor log ID for production Sigstore instance.
/// </summary>
public const string RekorProductionLogId = "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d";
}

View File

@@ -0,0 +1,102 @@
// -----------------------------------------------------------------------------
// TransparencyLogEntry.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-003 - Create TransparencyLogEntry model
// Description: Rekor transparency log entry model
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Bundle.Models;
/// <summary>
/// Transparency log entry from Rekor.
/// </summary>
public sealed record TransparencyLogEntry
{
/// <summary>
/// Log index (position in the transparency log).
/// </summary>
[JsonPropertyName("logIndex")]
public required string LogIndex { get; init; }
/// <summary>
/// Log identifier (hash of the log's public key).
/// </summary>
[JsonPropertyName("logId")]
public required LogId LogId { get; init; }
/// <summary>
/// Kind and version of the entry type.
/// </summary>
[JsonPropertyName("kindVersion")]
public required KindVersion KindVersion { get; init; }
/// <summary>
/// Unix timestamp when the entry was integrated into the log.
/// </summary>
[JsonPropertyName("integratedTime")]
public required string IntegratedTime { get; init; }
/// <summary>
/// Signed promise of inclusion (older format, pre-checkpoint).
/// </summary>
[JsonPropertyName("inclusionPromise")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public InclusionPromise? InclusionPromise { get; init; }
/// <summary>
/// Merkle inclusion proof with checkpoint.
/// </summary>
[JsonPropertyName("inclusionProof")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public InclusionProof? InclusionProof { get; init; }
/// <summary>
/// Base64-encoded canonicalized entry body.
/// </summary>
[JsonPropertyName("canonicalizedBody")]
public required string CanonicalizedBody { get; init; }
}
/// <summary>
/// Log identifier.
/// </summary>
public sealed record LogId
{
/// <summary>
/// Base64-encoded key identifier (SHA256 of public key).
/// </summary>
[JsonPropertyName("keyId")]
public required string KeyId { get; init; }
}
/// <summary>
/// Entry type kind and version.
/// </summary>
public sealed record KindVersion
{
/// <summary>
/// Entry kind (e.g., "dsse", "hashedrekord", "intoto").
/// </summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }
/// <summary>
/// Entry version (e.g., "0.0.1").
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
}
/// <summary>
/// Signed inclusion promise (legacy, pre-checkpoint format).
/// </summary>
public sealed record InclusionPromise
{
/// <summary>
/// Base64-encoded signed entry timestamp.
/// </summary>
[JsonPropertyName("signedEntryTimestamp")]
public required string SignedEntryTimestamp { get; init; }
}

View File

@@ -0,0 +1,101 @@
// -----------------------------------------------------------------------------
// VerificationMaterial.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-002 - Create VerificationMaterial model
// Description: Certificate and transparency log verification material
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Bundle.Models;
/// <summary>
/// Verification material containing certificates and transparency log entries.
/// </summary>
public sealed record VerificationMaterial
{
/// <summary>
/// X.509 certificate used for signing.
/// Either Certificate or PublicKey must be present.
/// </summary>
[JsonPropertyName("certificate")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public CertificateInfo? Certificate { get; init; }
/// <summary>
/// Public key used for signing (alternative to certificate).
/// </summary>
[JsonPropertyName("publicKey")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PublicKeyInfo? PublicKey { get; init; }
/// <summary>
/// Transparency log entries (Rekor entries).
/// </summary>
[JsonPropertyName("tlogEntries")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<TransparencyLogEntry>? TlogEntries { get; init; }
/// <summary>
/// Timestamp verification data from timestamp authorities.
/// </summary>
[JsonPropertyName("timestampVerificationData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public TimestampVerificationData? TimestampVerificationData { get; init; }
}
/// <summary>
/// X.509 certificate information.
/// </summary>
public sealed record CertificateInfo
{
/// <summary>
/// Base64-encoded DER certificate.
/// </summary>
[JsonPropertyName("rawBytes")]
public required string RawBytes { get; init; }
}
/// <summary>
/// Public key information (for keyful signing).
/// </summary>
public sealed record PublicKeyInfo
{
/// <summary>
/// Key hint for identifying the public key.
/// </summary>
[JsonPropertyName("hint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Hint { get; init; }
/// <summary>
/// Base64-encoded public key bytes.
/// </summary>
[JsonPropertyName("rawBytes")]
public required string RawBytes { get; init; }
}
/// <summary>
/// Timestamp verification data from timestamp authorities.
/// </summary>
public sealed record TimestampVerificationData
{
/// <summary>
/// RFC 3161 timestamp responses.
/// </summary>
[JsonPropertyName("rfc3161Timestamps")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<Rfc3161Timestamp>? Rfc3161Timestamps { get; init; }
}
/// <summary>
/// RFC 3161 timestamp response.
/// </summary>
public sealed record Rfc3161Timestamp
{
/// <summary>
/// Base64-encoded timestamp response.
/// </summary>
[JsonPropertyName("signedTimestamp")]
public required string SignedTimestamp { get; init; }
}

View File

@@ -0,0 +1,176 @@
// -----------------------------------------------------------------------------
// SigstoreBundleSerializer.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Tasks: BUNDLE-8200-005, BUNDLE-8200-006 - Bundle serialization
// Description: JSON serialization for Sigstore bundles
// -----------------------------------------------------------------------------
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Bundle.Models;
namespace StellaOps.Attestor.Bundle.Serialization;
/// <summary>
/// Serializer for Sigstore Bundle v0.3 format.
/// </summary>
public static class SigstoreBundleSerializer
{
private static readonly JsonSerializerOptions s_serializeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private static readonly JsonSerializerOptions s_deserializeOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Serializes a Sigstore bundle to JSON string.
/// </summary>
/// <param name="bundle">The bundle to serialize.</param>
/// <returns>JSON string representation.</returns>
public static string Serialize(SigstoreBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return JsonSerializer.Serialize(bundle, s_serializeOptions);
}
/// <summary>
/// Serializes a Sigstore bundle to UTF-8 bytes.
/// </summary>
/// <param name="bundle">The bundle to serialize.</param>
/// <returns>UTF-8 encoded JSON bytes.</returns>
public static byte[] SerializeToUtf8Bytes(SigstoreBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return JsonSerializer.SerializeToUtf8Bytes(bundle, s_serializeOptions);
}
/// <summary>
/// Deserializes a Sigstore bundle from JSON string.
/// </summary>
/// <param name="json">JSON string to deserialize.</param>
/// <returns>Deserialized bundle.</returns>
/// <exception cref="SigstoreBundleException">Thrown when deserialization fails.</exception>
public static SigstoreBundle Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
try
{
var bundle = JsonSerializer.Deserialize<SigstoreBundle>(json, s_deserializeOptions);
if (bundle is null)
{
throw new SigstoreBundleException("Deserialization returned null");
}
ValidateBundle(bundle);
return bundle;
}
catch (JsonException ex)
{
throw new SigstoreBundleException("Failed to deserialize Sigstore bundle", ex);
}
}
/// <summary>
/// Deserializes a Sigstore bundle from UTF-8 bytes.
/// </summary>
/// <param name="utf8Json">UTF-8 encoded JSON bytes.</param>
/// <returns>Deserialized bundle.</returns>
/// <exception cref="SigstoreBundleException">Thrown when deserialization fails.</exception>
public static SigstoreBundle Deserialize(ReadOnlySpan<byte> utf8Json)
{
try
{
var bundle = JsonSerializer.Deserialize<SigstoreBundle>(utf8Json, s_deserializeOptions);
if (bundle is null)
{
throw new SigstoreBundleException("Deserialization returned null");
}
ValidateBundle(bundle);
return bundle;
}
catch (JsonException ex)
{
throw new SigstoreBundleException("Failed to deserialize Sigstore bundle", ex);
}
}
/// <summary>
/// Attempts to deserialize a Sigstore bundle from JSON string.
/// </summary>
/// <param name="json">JSON string to deserialize.</param>
/// <param name="bundle">Deserialized bundle if successful.</param>
/// <returns>True if deserialization succeeded.</returns>
public static bool TryDeserialize(string json, out SigstoreBundle? bundle)
{
bundle = null;
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
bundle = Deserialize(json);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Validates the structure of a deserialized bundle.
/// </summary>
private static void ValidateBundle(SigstoreBundle bundle)
{
if (string.IsNullOrEmpty(bundle.MediaType))
{
throw new SigstoreBundleException("Bundle mediaType is required");
}
if (bundle.VerificationMaterial is null)
{
throw new SigstoreBundleException("Bundle verificationMaterial is required");
}
if (bundle.DsseEnvelope is null)
{
throw new SigstoreBundleException("Bundle dsseEnvelope is required");
}
if (string.IsNullOrEmpty(bundle.DsseEnvelope.PayloadType))
{
throw new SigstoreBundleException("DSSE envelope payloadType is required");
}
if (string.IsNullOrEmpty(bundle.DsseEnvelope.Payload))
{
throw new SigstoreBundleException("DSSE envelope payload is required");
}
if (bundle.DsseEnvelope.Signatures is null || bundle.DsseEnvelope.Signatures.Count == 0)
{
throw new SigstoreBundleException("DSSE envelope must have at least one signature");
}
}
}
/// <summary>
/// Exception thrown for Sigstore bundle errors.
/// </summary>
public class SigstoreBundleException : Exception
{
public SigstoreBundleException(string message) : base(message) { }
public SigstoreBundleException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.Bundle</RootNamespace>
<Description>Sigstore Bundle v0.3 implementation for DSSE envelope packaging and offline verification.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,171 @@
// -----------------------------------------------------------------------------
// BundleVerificationResult.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-012 - Bundle verification result models
// Description: Result types for Sigstore bundle verification
// -----------------------------------------------------------------------------
namespace StellaOps.Attestor.Bundle.Verification;
/// <summary>
/// Result of Sigstore bundle verification.
/// </summary>
public sealed record BundleVerificationResult
{
/// <summary>
/// Whether the bundle passed all verification checks.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Verification errors, if any.
/// </summary>
public required IReadOnlyList<BundleVerificationError> Errors { get; init; }
/// <summary>
/// Individual check results.
/// </summary>
public required BundleCheckResults Checks { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static BundleVerificationResult Success(BundleCheckResults checks) =>
new()
{
IsValid = true,
Errors = Array.Empty<BundleVerificationError>(),
Checks = checks
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static BundleVerificationResult Failure(
IReadOnlyList<BundleVerificationError> errors,
BundleCheckResults checks) =>
new()
{
IsValid = false,
Errors = errors,
Checks = checks
};
}
/// <summary>
/// Individual verification check results.
/// </summary>
public sealed record BundleCheckResults
{
/// <summary>
/// DSSE signature verification result.
/// </summary>
public CheckResult DsseSignature { get; init; } = CheckResult.NotChecked;
/// <summary>
/// Certificate chain validation result.
/// </summary>
public CheckResult CertificateChain { get; init; } = CheckResult.NotChecked;
/// <summary>
/// Merkle inclusion proof verification result.
/// </summary>
public CheckResult InclusionProof { get; init; } = CheckResult.NotChecked;
/// <summary>
/// Transparency log entry verification result.
/// </summary>
public CheckResult TransparencyLog { get; init; } = CheckResult.NotChecked;
/// <summary>
/// Timestamp verification result.
/// </summary>
public CheckResult Timestamp { get; init; } = CheckResult.NotChecked;
}
/// <summary>
/// Result of an individual verification check.
/// </summary>
public enum CheckResult
{
/// <summary>Check was not performed.</summary>
NotChecked = 0,
/// <summary>Check passed.</summary>
Passed = 1,
/// <summary>Check failed.</summary>
Failed = 2,
/// <summary>Check was skipped (optional data not present).</summary>
Skipped = 3
}
/// <summary>
/// Verification error details.
/// </summary>
public sealed record BundleVerificationError
{
/// <summary>
/// Error code.
/// </summary>
public required BundleVerificationErrorCode Code { get; init; }
/// <summary>
/// Human-readable error message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Optional exception that caused the error.
/// </summary>
public Exception? Exception { get; init; }
}
/// <summary>
/// Bundle verification error codes.
/// </summary>
public enum BundleVerificationErrorCode
{
/// <summary>Unknown error.</summary>
Unknown = 0,
/// <summary>Bundle structure is invalid.</summary>
InvalidBundleStructure = 1,
/// <summary>DSSE envelope is missing.</summary>
MissingDsseEnvelope = 2,
/// <summary>DSSE signature verification failed.</summary>
DsseSignatureInvalid = 3,
/// <summary>Certificate is missing.</summary>
MissingCertificate = 4,
/// <summary>Certificate chain validation failed.</summary>
CertificateChainInvalid = 5,
/// <summary>Certificate has expired.</summary>
CertificateExpired = 6,
/// <summary>Certificate not yet valid.</summary>
CertificateNotYetValid = 7,
/// <summary>Transparency log entry is missing.</summary>
MissingTransparencyLogEntry = 8,
/// <summary>Inclusion proof verification failed.</summary>
InclusionProofInvalid = 9,
/// <summary>Merkle root hash mismatch.</summary>
RootHashMismatch = 10,
/// <summary>Timestamp verification failed.</summary>
TimestampInvalid = 11,
/// <summary>Signature algorithm not supported.</summary>
UnsupportedAlgorithm = 12,
/// <summary>Public key extraction failed.</summary>
PublicKeyExtractionFailed = 13
}

View File

@@ -0,0 +1,615 @@
// -----------------------------------------------------------------------------
// SigstoreBundleVerifier.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Tasks: BUNDLE-8200-012 to BUNDLE-8200-015 - Bundle verification
// Description: Offline verification of Sigstore bundles
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Extensions.Logging;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using StellaOps.Attestor.Bundle.Models;
namespace StellaOps.Attestor.Bundle.Verification;
/// <summary>
/// Verifies Sigstore bundles for offline verification scenarios.
/// </summary>
public sealed class SigstoreBundleVerifier
{
private readonly ILogger<SigstoreBundleVerifier>? _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SigstoreBundleVerifier"/> class.
/// </summary>
/// <param name="logger">Optional logger.</param>
public SigstoreBundleVerifier(ILogger<SigstoreBundleVerifier>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Verifies a Sigstore bundle.
/// </summary>
/// <param name="bundle">The bundle to verify.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<BundleVerificationResult> VerifyAsync(
SigstoreBundle bundle,
BundleVerificationOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
options ??= BundleVerificationOptions.Default;
var errors = new List<BundleVerificationError>();
var checks = new BundleCheckResults();
// Validate bundle structure
if (!ValidateBundleStructure(bundle, errors))
{
return BundleVerificationResult.Failure(errors, checks);
}
// Extract public key from certificate
byte[]? publicKeyBytes = null;
X509Certificate2? certificate = null;
if (bundle.VerificationMaterial.Certificate is not null)
{
try
{
var certBytes = Convert.FromBase64String(bundle.VerificationMaterial.Certificate.RawBytes);
certificate = X509CertificateLoader.LoadCertificate(certBytes);
publicKeyBytes = ExtractPublicKeyBytes(certificate);
// Verify certificate chain
var certResult = await VerifyCertificateChainAsync(
certificate, options, cancellationToken);
checks = checks with { CertificateChain = certResult.Result };
if (!certResult.IsValid)
{
errors.AddRange(certResult.Errors);
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to parse certificate from bundle");
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.PublicKeyExtractionFailed,
Message = "Failed to extract public key from certificate",
Exception = ex
});
checks = checks with { CertificateChain = CheckResult.Failed };
}
}
else if (bundle.VerificationMaterial.PublicKey is not null)
{
try
{
publicKeyBytes = Convert.FromBase64String(bundle.VerificationMaterial.PublicKey.RawBytes);
checks = checks with { CertificateChain = CheckResult.Skipped };
}
catch (Exception ex)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.PublicKeyExtractionFailed,
Message = "Failed to decode public key",
Exception = ex
});
}
}
// Verify DSSE signature
if (publicKeyBytes is not null && bundle.DsseEnvelope is not null)
{
var dsseResult = await VerifyDsseSignatureAsync(
bundle.DsseEnvelope, publicKeyBytes, certificate, cancellationToken);
checks = checks with { DsseSignature = dsseResult.Result };
if (!dsseResult.IsValid)
{
errors.AddRange(dsseResult.Errors);
}
}
else
{
checks = checks with { DsseSignature = CheckResult.Failed };
if (publicKeyBytes is null)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.MissingCertificate,
Message = "No certificate or public key available for signature verification"
});
}
}
// Verify inclusion proof
if (options.VerifyInclusionProof &&
bundle.VerificationMaterial.TlogEntries?.Count > 0)
{
var proofResult = await VerifyInclusionProofsAsync(
bundle.VerificationMaterial.TlogEntries, cancellationToken);
checks = checks with
{
InclusionProof = proofResult.Result,
TransparencyLog = proofResult.Result
};
if (!proofResult.IsValid)
{
errors.AddRange(proofResult.Errors);
}
}
else
{
checks = checks with
{
InclusionProof = CheckResult.Skipped,
TransparencyLog = CheckResult.Skipped
};
}
// Verify timestamps if present
if (options.VerifyTimestamps &&
bundle.VerificationMaterial.TimestampVerificationData?.Rfc3161Timestamps?.Count > 0)
{
checks = checks with { Timestamp = CheckResult.Skipped };
// RFC 3161 timestamp verification would require TSA certificate validation
// Mark as skipped for now - full implementation requires TSA trust roots
}
else
{
checks = checks with { Timestamp = CheckResult.Skipped };
}
var isValid = errors.Count == 0 &&
checks.DsseSignature == CheckResult.Passed &&
(checks.CertificateChain == CheckResult.Passed ||
checks.CertificateChain == CheckResult.Skipped);
return isValid
? BundleVerificationResult.Success(checks)
: BundleVerificationResult.Failure(errors, checks);
}
private bool ValidateBundleStructure(SigstoreBundle bundle, List<BundleVerificationError> errors)
{
var valid = true;
if (string.IsNullOrEmpty(bundle.MediaType))
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.InvalidBundleStructure,
Message = "Bundle mediaType is required"
});
valid = false;
}
if (bundle.DsseEnvelope is null)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.MissingDsseEnvelope,
Message = "Bundle dsseEnvelope is required"
});
valid = false;
}
if (bundle.VerificationMaterial is null)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.InvalidBundleStructure,
Message = "Bundle verificationMaterial is required"
});
valid = false;
}
else if (bundle.VerificationMaterial.Certificate is null &&
bundle.VerificationMaterial.PublicKey is null)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.MissingCertificate,
Message = "Either certificate or publicKey is required in verificationMaterial"
});
valid = false;
}
return valid;
}
private async Task<VerificationCheckResult> VerifyCertificateChainAsync(
X509Certificate2 certificate,
BundleVerificationOptions options,
CancellationToken cancellationToken)
{
await Task.CompletedTask; // Async for future extensibility
var errors = new List<BundleVerificationError>();
var now = options.VerificationTime ?? DateTimeOffset.UtcNow;
// Check certificate validity period
if (certificate.NotBefore > now)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.CertificateNotYetValid,
Message = $"Certificate not valid until {certificate.NotBefore:O}"
});
}
if (certificate.NotAfter < now)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.CertificateExpired,
Message = $"Certificate expired at {certificate.NotAfter:O}"
});
}
// For full chain validation, we would need to validate against Fulcio roots
// For offline verification, we trust the included certificate if timestamps prove
// the signature was made while the certificate was valid
if (errors.Count > 0)
{
return new VerificationCheckResult(false, CheckResult.Failed, errors);
}
return new VerificationCheckResult(true, CheckResult.Passed, errors);
}
private async Task<VerificationCheckResult> VerifyDsseSignatureAsync(
BundleDsseEnvelope envelope,
byte[] publicKeyBytes,
X509Certificate2? certificate,
CancellationToken cancellationToken)
{
await Task.CompletedTask; // Async for future extensibility
var errors = new List<BundleVerificationError>();
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
Message = "DSSE envelope has no signatures"
});
return new VerificationCheckResult(false, CheckResult.Failed, errors);
}
// Construct PAE (Pre-Authentication Encoding) for DSSE
var payloadBytes = Convert.FromBase64String(envelope.Payload);
var paeMessage = ConstructPae(envelope.PayloadType, payloadBytes);
// Verify at least one signature
var anyValid = false;
foreach (var sig in envelope.Signatures)
{
try
{
var signatureBytes = Convert.FromBase64String(sig.Sig);
var valid = VerifySignature(paeMessage, signatureBytes, publicKeyBytes, certificate);
if (valid)
{
anyValid = true;
break;
}
}
catch (Exception ex)
{
_logger?.LogDebug(ex, "Signature verification attempt failed");
}
}
if (!anyValid)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
Message = "No valid signature found in DSSE envelope"
});
return new VerificationCheckResult(false, CheckResult.Failed, errors);
}
return new VerificationCheckResult(true, CheckResult.Passed, errors);
}
private static byte[] ConstructPae(string payloadType, byte[] payload)
{
// PAE(type, payload) = "DSSEv1" + SP + len(type) + SP + type + SP + len(payload) + SP + payload
// where SP = space (0x20) and len() is the ASCII decimal length
const string DssePrefix = "DSSEv1";
const byte Space = 0x20;
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
typeBytes.Length + 1 + payloadLenBytes.Length + 1 + payload.Length;
var pae = new byte[totalLength];
var offset = 0;
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
offset += prefixBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
offset += typeLenBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
offset += typeBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
offset += payloadLenBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
return pae;
}
private bool VerifySignature(
byte[] message,
byte[] signature,
byte[] publicKeyBytes,
X509Certificate2? certificate)
{
// Try to verify using certificate's public key if available
if (certificate is not null)
{
var publicKey = certificate.GetECDsaPublicKey();
if (publicKey is not null)
{
try
{
return publicKey.VerifyData(message, signature, HashAlgorithmName.SHA256);
}
catch
{
// Fall through to try other methods
}
}
var rsaKey = certificate.GetRSAPublicKey();
if (rsaKey is not null)
{
try
{
return rsaKey.VerifyData(message, signature,
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
// Fall through to try other methods
}
}
}
// Try Ed25519 verification
if (publicKeyBytes.Length == 32)
{
try
{
var ed25519PublicKey = new Ed25519PublicKeyParameters(publicKeyBytes, 0);
var verifier = new Ed25519Signer();
verifier.Init(false, ed25519PublicKey);
verifier.BlockUpdate(message, 0, message.Length);
return verifier.VerifySignature(signature);
}
catch
{
// Not Ed25519 or verification failed
}
}
return false;
}
private async Task<VerificationCheckResult> VerifyInclusionProofsAsync(
IReadOnlyList<TransparencyLogEntry> tlogEntries,
CancellationToken cancellationToken)
{
await Task.CompletedTask; // Async for future extensibility
var errors = new List<BundleVerificationError>();
foreach (var entry in tlogEntries)
{
if (entry.InclusionProof is null)
{
// Skip entries without inclusion proofs
continue;
}
try
{
var valid = VerifyMerkleInclusionProof(entry);
if (!valid)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.InclusionProofInvalid,
Message = $"Merkle inclusion proof verification failed for log index {entry.LogIndex}"
});
}
}
catch (Exception ex)
{
errors.Add(new BundleVerificationError
{
Code = BundleVerificationErrorCode.InclusionProofInvalid,
Message = $"Failed to verify inclusion proof for log index {entry.LogIndex}",
Exception = ex
});
}
}
if (errors.Count > 0)
{
return new VerificationCheckResult(false, CheckResult.Failed, errors);
}
return new VerificationCheckResult(true, CheckResult.Passed, errors);
}
private bool VerifyMerkleInclusionProof(TransparencyLogEntry entry)
{
if (entry.InclusionProof is null)
{
return false;
}
var proof = entry.InclusionProof;
// Parse values
if (!long.TryParse(proof.LogIndex, out var leafIndex) ||
!long.TryParse(proof.TreeSize, out var treeSize))
{
return false;
}
if (leafIndex < 0 || leafIndex >= treeSize)
{
return false;
}
// Decode leaf hash from canonicalized body
var leafData = Convert.FromBase64String(entry.CanonicalizedBody);
var leafHash = ComputeLeafHash(leafData);
// Decode expected root hash
var expectedRoot = Convert.FromBase64String(proof.RootHash);
// Decode proof hashes
var hashes = proof.Hashes.Select(h => Convert.FromBase64String(h)).ToList();
// Verify Merkle path
var computedRoot = ComputeMerkleRoot(leafHash, leafIndex, treeSize, hashes);
return computedRoot.SequenceEqual(expectedRoot);
}
private static byte[] ComputeLeafHash(byte[] data)
{
// RFC 6962: leaf_hash = SHA-256(0x00 || data)
using var sha256 = SHA256.Create();
var prefixed = new byte[data.Length + 1];
prefixed[0] = 0x00;
Buffer.BlockCopy(data, 0, prefixed, 1, data.Length);
return sha256.ComputeHash(prefixed);
}
private static byte[] ComputeMerkleRoot(byte[] leafHash, long index, long treeSize, List<byte[]> proof)
{
using var sha256 = SHA256.Create();
var hash = leafHash;
var proofIndex = 0;
var n = treeSize;
var i = index;
while (n > 1)
{
if (proofIndex >= proof.Count)
{
break;
}
var sibling = proof[proofIndex++];
if (i % 2 == 1 || i + 1 == n)
{
// Left sibling: hash = H(0x01 || sibling || hash)
hash = HashNodes(sha256, sibling, hash);
i = i / 2;
}
else
{
// Right sibling: hash = H(0x01 || hash || sibling)
hash = HashNodes(sha256, hash, sibling);
i = i / 2;
}
n = (n + 1) / 2;
}
return hash;
}
private static byte[] HashNodes(SHA256 sha256, byte[] left, byte[] right)
{
// RFC 6962: node_hash = SHA-256(0x01 || left || right)
var combined = new byte[1 + left.Length + right.Length];
combined[0] = 0x01;
Buffer.BlockCopy(left, 0, combined, 1, left.Length);
Buffer.BlockCopy(right, 0, combined, 1 + left.Length, right.Length);
return sha256.ComputeHash(combined);
}
private static byte[]? ExtractPublicKeyBytes(X509Certificate2 certificate)
{
var ecdsaKey = certificate.GetECDsaPublicKey();
if (ecdsaKey is not null)
{
var parameters = ecdsaKey.ExportParameters(false);
// Return uncompressed point format: 0x04 || X || Y
var result = new byte[1 + parameters.Q.X!.Length + parameters.Q.Y!.Length];
result[0] = 0x04;
Buffer.BlockCopy(parameters.Q.X, 0, result, 1, parameters.Q.X.Length);
Buffer.BlockCopy(parameters.Q.Y, 0, result, 1 + parameters.Q.X.Length, parameters.Q.Y.Length);
return result;
}
return null;
}
private sealed record VerificationCheckResult(
bool IsValid,
CheckResult Result,
IReadOnlyList<BundleVerificationError> Errors);
}
/// <summary>
/// Options for bundle verification.
/// </summary>
public sealed record BundleVerificationOptions
{
/// <summary>
/// Default verification options.
/// </summary>
public static readonly BundleVerificationOptions Default = new();
/// <summary>
/// Whether to verify the Merkle inclusion proof.
/// </summary>
public bool VerifyInclusionProof { get; init; } = true;
/// <summary>
/// Whether to verify RFC 3161 timestamps.
/// </summary>
public bool VerifyTimestamps { get; init; } = false;
/// <summary>
/// Override verification time (for testing or historical verification).
/// </summary>
public DateTimeOffset? VerificationTime { get; init; }
/// <summary>
/// Trusted Fulcio root certificates for certificate chain validation.
/// </summary>
public IReadOnlyList<X509Certificate2>? TrustedRoots { get; init; }
}

View File

@@ -0,0 +1,178 @@
// -----------------------------------------------------------------------------
// BudgetCheckPredicate.cs
// Sprint: SPRINT_8200_0001_0006_budget_threshold_attestation
// Tasks: BUDGET-8200-001, BUDGET-8200-002, BUDGET-8200-003
// Description: Predicate capturing unknown budget enforcement at decision time.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.ProofChain.Predicates;
/// <summary>
/// Predicate capturing unknown budget enforcement at decision time.
/// Predicate type: https://stellaops.io/attestation/budget-check/v1
/// </summary>
/// <remarks>
/// This predicate enables auditors to verify what budget thresholds were applied
/// during policy evaluation. The ConfigHash provides determinism proof to ensure
/// reproducibility.
/// </remarks>
public sealed record BudgetCheckPredicate
{
/// <summary>
/// The predicate type URI for budget check attestations.
/// </summary>
public const string PredicateTypeUri = "https://stellaops.io/attestation/budget-check/v1";
/// <summary>
/// Environment for which the budget was evaluated (e.g., prod, stage, dev).
/// </summary>
[JsonPropertyName("environment")]
public required string Environment { get; init; }
/// <summary>
/// Budget configuration that was applied during evaluation.
/// </summary>
[JsonPropertyName("budgetConfig")]
public required BudgetConfig BudgetConfig { get; init; }
/// <summary>
/// Actual counts observed at evaluation time.
/// </summary>
[JsonPropertyName("actualCounts")]
public required BudgetActualCounts ActualCounts { get; init; }
/// <summary>
/// Budget check result: pass, warn, fail.
/// </summary>
[JsonPropertyName("result")]
public required BudgetCheckResult Result { get; init; }
/// <summary>
/// SHA-256 hash of budget configuration for determinism proof.
/// Format: sha256:{64 hex characters}
/// </summary>
[JsonPropertyName("configHash")]
public required string ConfigHash { get; init; }
/// <summary>
/// Timestamp when the budget was evaluated.
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Violations encountered, if any limits were exceeded.
/// </summary>
[JsonPropertyName("violations")]
public IReadOnlyList<BudgetViolation>? Violations { get; init; }
}
/// <summary>
/// Budget check result outcome.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum BudgetCheckResult
{
/// <summary>
/// Budget check passed - all limits satisfied.
/// </summary>
Pass,
/// <summary>
/// Budget limits exceeded but action is warn.
/// </summary>
Warn,
/// <summary>
/// Budget limits exceeded and action is fail/block.
/// </summary>
Fail
}
/// <summary>
/// Budget configuration applied during evaluation.
/// </summary>
public sealed record BudgetConfig
{
/// <summary>
/// Maximum number of unknowns allowed.
/// </summary>
[JsonPropertyName("maxUnknownCount")]
public int MaxUnknownCount { get; init; }
/// <summary>
/// Maximum cumulative uncertainty score allowed.
/// </summary>
[JsonPropertyName("maxCumulativeUncertainty")]
public double MaxCumulativeUncertainty { get; init; }
/// <summary>
/// Per-reason code limits (optional).
/// Key: reason code, Value: maximum allowed count.
/// </summary>
[JsonPropertyName("reasonLimits")]
public IReadOnlyDictionary<string, int>? ReasonLimits { get; init; }
/// <summary>
/// Action to take when budget is exceeded: warn, fail.
/// </summary>
[JsonPropertyName("action")]
public string Action { get; init; } = "warn";
}
/// <summary>
/// Actual counts observed at evaluation time.
/// </summary>
public sealed record BudgetActualCounts
{
/// <summary>
/// Total number of unknowns.
/// </summary>
[JsonPropertyName("total")]
public int Total { get; init; }
/// <summary>
/// Cumulative uncertainty score across all unknowns.
/// </summary>
[JsonPropertyName("cumulativeUncertainty")]
public double CumulativeUncertainty { get; init; }
/// <summary>
/// Breakdown by reason code.
/// Key: reason code, Value: count.
/// </summary>
[JsonPropertyName("byReason")]
public IReadOnlyDictionary<string, int>? ByReason { get; init; }
}
/// <summary>
/// Represents a budget limit violation.
/// </summary>
public sealed record BudgetViolation
{
/// <summary>
/// Type of violation: total, cumulative, reason.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// The limit that was exceeded.
/// </summary>
[JsonPropertyName("limit")]
public int Limit { get; init; }
/// <summary>
/// The actual value that exceeded the limit.
/// </summary>
[JsonPropertyName("actual")]
public int Actual { get; init; }
/// <summary>
/// Reason code, if this is a per-reason violation.
/// </summary>
[JsonPropertyName("reason")]
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// SigstoreBundleBuilderTests.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-019 - Add unit tests for bundle builder
// Description: Unit tests for Sigstore bundle builder
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Attestor.Bundle.Builder;
using StellaOps.Attestor.Bundle.Models;
using StellaOps.Attestor.Bundle.Serialization;
using Xunit;
namespace StellaOps.Attestor.Bundle.Tests;
public class SigstoreBundleBuilderTests
{
[Fact]
public void Build_WithAllComponents_CreatesBundleSuccessfully()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]));
// Act
var bundle = builder.Build();
// Assert
bundle.Should().NotBeNull();
bundle.MediaType.Should().Be(SigstoreBundleConstants.MediaTypeV03);
bundle.DsseEnvelope.Should().NotBeNull();
bundle.DsseEnvelope.PayloadType.Should().Be("application/vnd.in-toto+json");
bundle.VerificationMaterial.Should().NotBeNull();
bundle.VerificationMaterial.Certificate.Should().NotBeNull();
}
[Fact]
public void Build_WithPublicKeyInsteadOfCertificate_CreatesBundleSuccessfully()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithPublicKey(new byte[32], "test-hint");
// Act
var bundle = builder.Build();
// Assert
bundle.Should().NotBeNull();
bundle.VerificationMaterial.PublicKey.Should().NotBeNull();
bundle.VerificationMaterial.PublicKey!.Hint.Should().Be("test-hint");
bundle.VerificationMaterial.Certificate.Should().BeNull();
}
[Fact]
public void Build_WithRekorEntry_IncludesTlogEntry()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]))
.WithRekorEntry(
logIndex: "12345",
logIdKeyId: Convert.ToBase64String(new byte[32]),
integratedTime: "1703500000",
canonicalizedBody: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")));
// Act
var bundle = builder.Build();
// Assert
bundle.VerificationMaterial.TlogEntries.Should().HaveCount(1);
var entry = bundle.VerificationMaterial.TlogEntries![0];
entry.LogIndex.Should().Be("12345");
entry.KindVersion.Kind.Should().Be("dsse");
entry.KindVersion.Version.Should().Be("0.0.1");
}
[Fact]
public void Build_WithMultipleRekorEntries_IncludesAllEntries()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]))
.WithRekorEntry("1", Convert.ToBase64String(new byte[32]), "1000", Convert.ToBase64String(new byte[10]))
.WithRekorEntry("2", Convert.ToBase64String(new byte[32]), "2000", Convert.ToBase64String(new byte[10]));
// Act
var bundle = builder.Build();
// Assert
bundle.VerificationMaterial.TlogEntries.Should().HaveCount(2);
bundle.VerificationMaterial.TlogEntries![0].LogIndex.Should().Be("1");
bundle.VerificationMaterial.TlogEntries![1].LogIndex.Should().Be("2");
}
[Fact]
public void Build_WithInclusionProof_AddsToLastEntry()
{
// Arrange
var proof = new InclusionProof
{
LogIndex = "12345",
RootHash = Convert.ToBase64String(new byte[32]),
TreeSize = "100000",
Hashes = new[] { Convert.ToBase64String(new byte[32]) },
Checkpoint = new Checkpoint { Envelope = "checkpoint-data" }
};
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]))
.WithRekorEntry("12345", Convert.ToBase64String(new byte[32]), "1000", Convert.ToBase64String(new byte[10]))
.WithInclusionProof(proof);
// Act
var bundle = builder.Build();
// Assert
bundle.VerificationMaterial.TlogEntries![0].InclusionProof.Should().NotBeNull();
bundle.VerificationMaterial.TlogEntries![0].InclusionProof!.TreeSize.Should().Be("100000");
}
[Fact]
public void Build_WithTimestamps_IncludesTimestampData()
{
// Arrange
var timestamps = new[] { Convert.ToBase64String(new byte[100]), Convert.ToBase64String(new byte[100]) };
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]))
.WithTimestamps(timestamps);
// Act
var bundle = builder.Build();
// Assert
bundle.VerificationMaterial.TimestampVerificationData.Should().NotBeNull();
bundle.VerificationMaterial.TimestampVerificationData!.Rfc3161Timestamps.Should().HaveCount(2);
}
[Fact]
public void Build_WithCustomMediaType_UsesCustomType()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]))
.WithMediaType("application/vnd.dev.sigstore.bundle.v0.2+json");
// Act
var bundle = builder.Build();
// Assert
bundle.MediaType.Should().Be("application/vnd.dev.sigstore.bundle.v0.2+json");
}
[Fact]
public void Build_MissingDsseEnvelope_ThrowsSigstoreBundleException()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithCertificateBase64(Convert.ToBase64String(new byte[100]));
// Act
var act = () => builder.Build();
// Assert
act.Should().Throw<SigstoreBundleException>()
.WithMessage("*DSSE*");
}
[Fact]
public void Build_MissingCertificateAndPublicKey_ThrowsSigstoreBundleException()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } });
// Act
var act = () => builder.Build();
// Assert
act.Should().Throw<SigstoreBundleException>()
.WithMessage("*certificate*public key*");
}
[Fact]
public void WithInclusionProof_WithoutRekorEntry_ThrowsInvalidOperationException()
{
// Arrange
var proof = new InclusionProof
{
LogIndex = "12345",
RootHash = Convert.ToBase64String(new byte[32]),
TreeSize = "100000",
Hashes = new[] { Convert.ToBase64String(new byte[32]) },
Checkpoint = new Checkpoint { Envelope = "checkpoint-data" }
};
var builder = new SigstoreBundleBuilder();
// Act
var act = () => builder.WithInclusionProof(proof);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*Rekor entry*");
}
[Fact]
public void BuildJson_ReturnsSerializedBundle()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]));
// Act
var json = builder.BuildJson();
// Assert
json.Should().NotBeNullOrWhiteSpace();
json.Should().Contain("\"mediaType\"");
json.Should().Contain("\"dsseEnvelope\"");
}
[Fact]
public void BuildUtf8Bytes_ReturnsSerializedBytes()
{
// Arrange
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(new byte[100]));
// Act
var bytes = builder.BuildUtf8Bytes();
// Assert
bytes.Should().NotBeNullOrEmpty();
var json = System.Text.Encoding.UTF8.GetString(bytes);
json.Should().Contain("\"mediaType\"");
}
[Fact]
public void WithDsseEnvelope_FromObject_SetsEnvelopeCorrectly()
{
// Arrange
var envelope = new BundleDsseEnvelope
{
PayloadType = "custom/type",
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("test")),
Signatures = new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[32]) } }
};
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(envelope)
.WithCertificateBase64(Convert.ToBase64String(new byte[100]));
// Act
var bundle = builder.Build();
// Assert
bundle.DsseEnvelope.PayloadType.Should().Be("custom/type");
}
[Fact]
public void WithCertificate_FromBytes_SetsCertificateCorrectly()
{
// Arrange
var certBytes = new byte[] { 0x30, 0x82, 0x01, 0x00 };
var builder = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificate(certBytes);
// Act
var bundle = builder.Build();
// Assert
bundle.VerificationMaterial.Certificate.Should().NotBeNull();
var decoded = Convert.FromBase64String(bundle.VerificationMaterial.Certificate!.RawBytes);
decoded.Should().BeEquivalentTo(certBytes);
}
}

View File

@@ -0,0 +1,243 @@
// -----------------------------------------------------------------------------
// SigstoreBundleSerializerTests.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Task: BUNDLE-8200-019 - Add unit test: serialize → deserialize round-trip
// Description: Unit tests for Sigstore bundle serialization
// -----------------------------------------------------------------------------
using System.Text.Json;
using FluentAssertions;
using StellaOps.Attestor.Bundle.Builder;
using StellaOps.Attestor.Bundle.Models;
using StellaOps.Attestor.Bundle.Serialization;
using Xunit;
namespace StellaOps.Attestor.Bundle.Tests;
public class SigstoreBundleSerializerTests
{
[Fact]
public void Serialize_ValidBundle_ProducesValidJson()
{
// Arrange
var bundle = CreateValidBundle();
// Act
var json = SigstoreBundleSerializer.Serialize(bundle);
// Assert
json.Should().NotBeNullOrWhiteSpace();
json.Should().Contain("\"mediaType\"");
json.Should().Contain("\"verificationMaterial\"");
json.Should().Contain("\"dsseEnvelope\"");
}
[Fact]
public void SerializeToUtf8Bytes_ValidBundle_ProducesValidBytes()
{
// Arrange
var bundle = CreateValidBundle();
// Act
var bytes = SigstoreBundleSerializer.SerializeToUtf8Bytes(bundle);
// Assert
bytes.Should().NotBeNullOrEmpty();
var json = System.Text.Encoding.UTF8.GetString(bytes);
json.Should().Contain("\"mediaType\"");
}
[Fact]
public void Deserialize_ValidJson_ReturnsBundle()
{
// Arrange
var json = CreateValidBundleJson();
// Act
var bundle = SigstoreBundleSerializer.Deserialize(json);
// Assert
bundle.Should().NotBeNull();
bundle.MediaType.Should().Be(SigstoreBundleConstants.MediaTypeV03);
bundle.DsseEnvelope.Should().NotBeNull();
bundle.VerificationMaterial.Should().NotBeNull();
}
[Fact]
public void Deserialize_Utf8Bytes_ReturnsBundle()
{
// Arrange
var json = CreateValidBundleJson();
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
// Act
var bundle = SigstoreBundleSerializer.Deserialize(bytes);
// Assert
bundle.Should().NotBeNull();
bundle.MediaType.Should().Be(SigstoreBundleConstants.MediaTypeV03);
}
[Fact]
public void RoundTrip_SerializeDeserialize_PreservesData()
{
// Arrange
var original = CreateValidBundle();
// Act
var json = SigstoreBundleSerializer.Serialize(original);
var deserialized = SigstoreBundleSerializer.Deserialize(json);
// Assert
deserialized.MediaType.Should().Be(original.MediaType);
deserialized.DsseEnvelope.PayloadType.Should().Be(original.DsseEnvelope.PayloadType);
deserialized.DsseEnvelope.Payload.Should().Be(original.DsseEnvelope.Payload);
deserialized.DsseEnvelope.Signatures.Should().HaveCount(original.DsseEnvelope.Signatures.Count);
deserialized.VerificationMaterial.Certificate.Should().NotBeNull();
deserialized.VerificationMaterial.Certificate!.RawBytes
.Should().Be(original.VerificationMaterial.Certificate!.RawBytes);
}
[Fact]
public void RoundTrip_WithTlogEntries_PreservesEntries()
{
// Arrange
var original = CreateBundleWithTlogEntry();
// Act
var json = SigstoreBundleSerializer.Serialize(original);
var deserialized = SigstoreBundleSerializer.Deserialize(json);
// Assert
deserialized.VerificationMaterial.TlogEntries.Should().HaveCount(1);
var entry = deserialized.VerificationMaterial.TlogEntries![0];
entry.LogIndex.Should().Be("12345");
entry.LogId.KeyId.Should().NotBeNullOrEmpty();
entry.KindVersion.Kind.Should().Be("dsse");
}
[Fact]
public void TryDeserialize_ValidJson_ReturnsTrue()
{
// Arrange
var json = CreateValidBundleJson();
// Act
var result = SigstoreBundleSerializer.TryDeserialize(json, out var bundle);
// Assert
result.Should().BeTrue();
bundle.Should().NotBeNull();
}
[Fact]
public void TryDeserialize_InvalidJson_ReturnsFalse()
{
// Arrange
var json = "{ invalid json }";
// Act
var result = SigstoreBundleSerializer.TryDeserialize(json, out var bundle);
// Assert
result.Should().BeFalse();
bundle.Should().BeNull();
}
[Fact]
public void TryDeserialize_NullOrEmpty_ReturnsFalse()
{
// Act & Assert
SigstoreBundleSerializer.TryDeserialize(null!, out _).Should().BeFalse();
SigstoreBundleSerializer.TryDeserialize("", out _).Should().BeFalse();
SigstoreBundleSerializer.TryDeserialize(" ", out _).Should().BeFalse();
}
[Fact]
public void Deserialize_MissingMediaType_ThrowsSigstoreBundleException()
{
// Arrange - JSON that deserializes but fails validation
var json = """{"mediaType":"","verificationMaterial":{"certificate":{"rawBytes":"AAAA"}},"dsseEnvelope":{"payloadType":"test","payload":"e30=","signatures":[{"sig":"AAAA"}]}}""";
// Act
var act = () => SigstoreBundleSerializer.Deserialize(json);
// Assert - Validation catches empty mediaType
act.Should().Throw<SigstoreBundleException>()
.WithMessage("*mediaType*");
}
[Fact]
public void Deserialize_MissingDsseEnvelope_ThrowsSigstoreBundleException()
{
// Arrange - JSON with null dsseEnvelope
var json = """{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{"certificate":{"rawBytes":"AAAA"}},"dsseEnvelope":null}""";
// Act
var act = () => SigstoreBundleSerializer.Deserialize(json);
// Assert
act.Should().Throw<SigstoreBundleException>()
.WithMessage("*dsseEnvelope*");
}
[Fact]
public void Serialize_NullBundle_ThrowsArgumentNullException()
{
// Act
var act = () => SigstoreBundleSerializer.Serialize(null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
private static SigstoreBundle CreateValidBundle()
{
return new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(CreateTestCertificateBytes()))
.Build();
}
private static SigstoreBundle CreateBundleWithTlogEntry()
{
return new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(CreateTestCertificateBytes()))
.WithRekorEntry(
logIndex: "12345",
logIdKeyId: Convert.ToBase64String(new byte[32]),
integratedTime: "1703500000",
canonicalizedBody: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")))
.Build();
}
private static string CreateValidBundleJson()
{
var bundle = CreateValidBundle();
return SigstoreBundleSerializer.Serialize(bundle);
}
private static byte[] CreateTestCertificateBytes()
{
// Minimal DER-encoded certificate placeholder
// In real tests, use a proper test certificate
return new byte[]
{
0x30, 0x82, 0x01, 0x00, // SEQUENCE, length
0x30, 0x81, 0xB0, // TBSCertificate SEQUENCE
0x02, 0x01, 0x01, // Version
0x02, 0x01, 0x01, // Serial number
0x30, 0x0D, // Algorithm ID
0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B,
0x05, 0x00
// ... truncated for test purposes
};
}
}

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// SigstoreBundleVerifierTests.cs
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
// Tasks: BUNDLE-8200-020, BUNDLE-8200-021 - Bundle verification tests
// Description: Unit tests for Sigstore bundle verification
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using FluentAssertions;
using StellaOps.Attestor.Bundle.Builder;
using StellaOps.Attestor.Bundle.Models;
using StellaOps.Attestor.Bundle.Verification;
using Xunit;
namespace StellaOps.Attestor.Bundle.Tests;
public class SigstoreBundleVerifierTests
{
private readonly SigstoreBundleVerifier _verifier = new();
[Fact]
public async Task Verify_MissingDsseEnvelope_ReturnsFailed()
{
// Arrange
var bundle = new SigstoreBundle
{
MediaType = SigstoreBundleConstants.MediaTypeV03,
VerificationMaterial = new VerificationMaterial
{
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(new byte[32]) }
},
DsseEnvelope = null!
};
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.MissingDsseEnvelope);
}
[Fact]
public async Task Verify_MissingCertificateAndPublicKey_ReturnsFailed()
{
// Arrange
var bundle = new SigstoreBundle
{
MediaType = SigstoreBundleConstants.MediaTypeV03,
VerificationMaterial = new VerificationMaterial(),
DsseEnvelope = new BundleDsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures = new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } }
}
};
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.MissingCertificate);
}
[Fact]
public async Task Verify_EmptyMediaType_ReturnsFailed()
{
// Arrange
var bundle = new SigstoreBundle
{
MediaType = "",
VerificationMaterial = new VerificationMaterial
{
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(new byte[32]) }
},
DsseEnvelope = new BundleDsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures = new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } }
}
};
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.InvalidBundleStructure);
}
[Fact]
public async Task Verify_NoSignaturesInEnvelope_ReturnsFailed()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Array.Empty<BundleSignature>())
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
}
[Fact]
public async Task Verify_InvalidSignature_ReturnsFailed()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
"application/vnd.in-toto+json",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } })
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
}
[Fact]
public async Task Verify_ValidEcdsaSignature_ReturnsPassed()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
var payloadType = "application/vnd.in-toto+json";
// Create PAE message for signing
var paeMessage = ConstructPae(payloadType, payload);
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
payloadType,
Convert.ToBase64String(payload),
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeTrue();
result.Checks.DsseSignature.Should().Be(CheckResult.Passed);
}
[Fact]
public async Task Verify_TamperedPayload_ReturnsFailed()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var originalPayload = System.Text.Encoding.UTF8.GetBytes("{}");
var payloadType = "application/vnd.in-toto+json";
// Sign the original payload
var paeMessage = ConstructPae(payloadType, originalPayload);
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
// Build bundle with tampered payload
var tamperedPayload = System.Text.Encoding.UTF8.GetBytes("{\"tampered\":true}");
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
payloadType,
Convert.ToBase64String(tamperedPayload),
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
}
[Fact]
public async Task Verify_WithVerificationTimeInPast_ValidatesCertificate()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
var payloadType = "application/vnd.in-toto+json";
var paeMessage = ConstructPae(payloadType, payload);
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
payloadType,
Convert.ToBase64String(payload),
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
var options = new BundleVerificationOptions
{
VerificationTime = DateTimeOffset.UtcNow.AddYears(-10) // Before cert was valid
};
// Act
var result = await _verifier.VerifyAsync(bundle, options);
// Assert
result.Checks.CertificateChain.Should().Be(CheckResult.Failed);
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.CertificateNotYetValid);
}
[Fact]
public async Task Verify_SkipsInclusionProofWhenNotPresent()
{
// Arrange
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
var payloadType = "application/vnd.in-toto+json";
var paeMessage = ConstructPae(payloadType, payload);
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
var bundle = new SigstoreBundleBuilder()
.WithDsseEnvelope(
payloadType,
Convert.ToBase64String(payload),
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
.WithCertificateBase64(Convert.ToBase64String(certBytes))
.Build();
// Act
var result = await _verifier.VerifyAsync(bundle);
// Assert
result.Checks.InclusionProof.Should().Be(CheckResult.Skipped);
result.Checks.TransparencyLog.Should().Be(CheckResult.Skipped);
}
[Fact]
public async Task Verify_NullBundle_ThrowsArgumentNullException()
{
// Act
var act = async () => await _verifier.VerifyAsync(null!);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
private static byte[] ConstructPae(string payloadType, byte[] payload)
{
const string DssePrefix = "DSSEv1";
const byte Space = 0x20;
var typeBytes = System.Text.Encoding.UTF8.GetBytes(payloadType);
var typeLenBytes = System.Text.Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLenBytes = System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString());
var prefixBytes = System.Text.Encoding.UTF8.GetBytes(DssePrefix);
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
typeBytes.Length + 1 + payloadLenBytes.Length + 1 + payload.Length;
var pae = new byte[totalLength];
var offset = 0;
Buffer.BlockCopy(prefixBytes, 0, pae, offset, prefixBytes.Length);
offset += prefixBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(typeLenBytes, 0, pae, offset, typeLenBytes.Length);
offset += typeLenBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(typeBytes, 0, pae, offset, typeBytes.Length);
offset += typeBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(payloadLenBytes, 0, pae, offset, payloadLenBytes.Length);
offset += payloadLenBytes.Length;
pae[offset++] = Space;
Buffer.BlockCopy(payload, 0, pae, offset, payload.Length);
return pae;
}
private static byte[] CreateSelfSignedCertificateBytes(ECDsa ecdsa)
{
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest(
"CN=Test",
ecdsa,
HashAlgorithmName.SHA256);
using var cert = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(1));
return cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundle\StellaOps.Attestor.Bundle.csproj" />
</ItemGroup>
</Project>