sprints work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user