Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -1,352 +0,0 @@
// -----------------------------------------------------------------------------
// DsseCosignCompatibilityTestFixture.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-013, DSSE-8200-014, DSSE-8200-015
// Description: Test fixture for cosign compatibility testing with mock Fulcio/Rekor
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Test fixture for cosign compatibility tests.
/// Provides mock Fulcio certificates and Rekor entries for offline testing.
/// </summary>
public sealed class DsseCosignCompatibilityTestFixture : IDisposable
{
private readonly ECDsa _signingKey;
private readonly X509Certificate2 _certificate;
private readonly string _keyId;
private bool _disposed;
/// <summary>
/// Creates a new fixture with mock Fulcio-style certificate.
/// </summary>
public DsseCosignCompatibilityTestFixture()
{
_signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
_keyId = $"cosign-test-{Guid.NewGuid():N}";
_certificate = CreateMockFulcioCertificate(_signingKey);
}
/// <summary>
/// Gets the mock Fulcio certificate.
/// </summary>
public X509Certificate2 Certificate => _certificate;
/// <summary>
/// Gets the signing key.
/// </summary>
public ECDsa SigningKey => _signingKey;
/// <summary>
/// Gets the key ID.
/// </summary>
public string KeyId => _keyId;
// DSSE-8200-014: Mock Fulcio certificate generation
/// <summary>
/// Creates a mock certificate mimicking Fulcio's structure for testing.
/// </summary>
public static X509Certificate2 CreateMockFulcioCertificate(
ECDsa key,
string subject = "test@example.com",
string issuer = "https://oauth2.sigstore.dev/auth",
DateTimeOffset? validFrom = null,
DateTimeOffset? validTo = null)
{
validFrom ??= DateTimeOffset.UtcNow.AddMinutes(-5);
validTo ??= DateTimeOffset.UtcNow.AddMinutes(15); // Fulcio certs are short-lived (~20 min)
var request = new CertificateRequest(
new X500DistinguishedName($"CN={subject}"),
key,
HashAlgorithmName.SHA256);
// Add extensions similar to Fulcio
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature,
critical: true));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.3") }, // Code Signing
critical: false));
// Add Subject Alternative Name (SAN) for identity
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddEmailAddress(subject);
request.CertificateExtensions.Add(sanBuilder.Build());
// Create self-signed cert (in real Fulcio this would be CA-signed)
return request.CreateSelfSigned(validFrom.Value, validTo.Value);
}
// DSSE-8200-013: Cosign-compatible envelope creation
/// <summary>
/// Signs a payload and creates a cosign-compatible DSSE envelope.
/// </summary>
public DsseEnvelope SignCosignCompatible(
ReadOnlySpan<byte> payload,
string payloadType = "application/vnd.in-toto+json")
{
// Build PAE (Pre-Authentication Encoding)
var pae = BuildPae(payloadType, payload);
// Sign with EC key (ES256 - what cosign uses)
var signatureBytes = _signingKey.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
// Base64 encode signature as cosign expects
var signatureBase64 = Convert.ToBase64String(signatureBytes);
var signature = new DsseSignature(signatureBase64, _keyId);
return new DsseEnvelope(payloadType, payload.ToArray(), [signature]);
}
/// <summary>
/// Creates a Sigstore bundle structure for testing.
/// </summary>
public CosignCompatibilityBundle CreateBundle(DsseEnvelope envelope, bool includeRekorEntry = false)
{
var certPem = ExportCertificateToPem(_certificate);
var certChain = new List<string> { certPem };
MockRekorEntry? rekorEntry = null;
if (includeRekorEntry)
{
rekorEntry = CreateMockRekorEntry(envelope);
}
return new CosignCompatibilityBundle(
envelope,
certChain,
rekorEntry);
}
// DSSE-8200-015: Mock Rekor entry for offline verification
/// <summary>
/// Creates a mock Rekor transparency log entry for testing.
/// </summary>
public MockRekorEntry CreateMockRekorEntry(
DsseEnvelope envelope,
long logIndex = 12345678,
long? treeSize = null)
{
treeSize ??= logIndex + 1000;
// Serialize envelope to get canonicalized body
var serializationResult = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false
});
var canonicalizedBody = serializationResult.CompactJson ?? [];
var bodyBase64 = Convert.ToBase64String(canonicalizedBody);
// Compute leaf hash (SHA256 of the canonicalized body)
var leafHash = SHA256.HashData(canonicalizedBody);
// Generate synthetic Merkle proof
var (proofHashes, rootHash) = GenerateSyntheticMerkleProof(leafHash, logIndex, treeSize.Value);
var integratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return new MockRekorEntry(
LogIndex: logIndex,
LogId: "rekor.sigstore.dev",
IntegratedTime: integratedTime,
CanonicalizedBody: bodyBase64,
InclusionProof: new MockInclusionProof(
LogIndex: logIndex,
TreeSize: treeSize.Value,
RootHash: Convert.ToBase64String(rootHash),
Hashes: proofHashes.ConvertAll(h => Convert.ToBase64String(h)),
Checkpoint: $"rekor.sigstore.dev - {treeSize}\n{Convert.ToBase64String(rootHash)}"));
}
/// <summary>
/// Validates that an envelope has the structure expected by cosign.
/// </summary>
public static CosignStructureValidationResult ValidateCosignStructure(DsseEnvelope envelope)
{
var errors = new List<string>();
// Check payload type
if (string.IsNullOrEmpty(envelope.PayloadType))
{
errors.Add("payloadType is required");
}
// Check payload is present
if (envelope.Payload.Length == 0)
{
errors.Add("payload is required");
}
// Check signatures
if (envelope.Signatures.Count == 0)
{
errors.Add("at least one signature is required");
}
foreach (var sig in envelope.Signatures)
{
// Signature should be base64-encoded
if (string.IsNullOrEmpty(sig.Signature))
{
errors.Add("signature value is required");
}
else if (!IsValidBase64(sig.Signature))
{
errors.Add($"signature is not valid base64: {sig.Signature[..Math.Min(20, sig.Signature.Length)]}...");
}
}
return new CosignStructureValidationResult(errors.Count == 0, errors);
}
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
{
// PAE = "DSSEv1" || SP || len(type) || SP || type || SP || len(payload) || SP || payload
const string prefix = "DSSEv1 ";
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var buffer = new List<byte>();
buffer.AddRange(Encoding.UTF8.GetBytes(prefix));
buffer.AddRange(Encoding.UTF8.GetBytes(typeBytes.Length.ToString()));
buffer.Add((byte)' ');
buffer.AddRange(typeBytes);
buffer.Add((byte)' ');
buffer.AddRange(Encoding.UTF8.GetBytes(payload.Length.ToString()));
buffer.Add((byte)' ');
buffer.AddRange(payload.ToArray());
return buffer.ToArray();
}
private static string ExportCertificateToPem(X509Certificate2 cert)
{
var certBytes = cert.Export(X509ContentType.Cert);
var base64 = Convert.ToBase64String(certBytes);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN CERTIFICATE-----");
for (var i = 0; i < base64.Length; i += 64)
{
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
}
sb.AppendLine("-----END CERTIFICATE-----");
return sb.ToString();
}
private static (List<byte[]> proofHashes, byte[] rootHash) GenerateSyntheticMerkleProof(
byte[] leafHash,
long logIndex,
long treeSize)
{
// Generate a synthetic but valid Merkle proof structure
var proofHashes = new List<byte[]>();
var currentHash = leafHash;
// Compute tree height
var height = (int)Math.Ceiling(Math.Log2(Math.Max(treeSize, 2)));
// Generate sibling hashes for each level
var random = new Random((int)(logIndex % int.MaxValue)); // Deterministic from logIndex
var siblingBytes = new byte[32];
for (var level = 0; level < height; level++)
{
random.NextBytes(siblingBytes);
proofHashes.Add((byte[])siblingBytes.Clone());
// Compute parent hash (simplified - real Merkle tree would be more complex)
var combined = new byte[64];
if ((logIndex >> level) % 2 == 0)
{
currentHash.CopyTo(combined, 0);
siblingBytes.CopyTo(combined, 32);
}
else
{
siblingBytes.CopyTo(combined, 0);
currentHash.CopyTo(combined, 32);
}
currentHash = SHA256.HashData(combined);
}
return (proofHashes, currentHash);
}
private static bool IsValidBase64(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}
try
{
Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
return false;
}
}
public void Dispose()
{
if (!_disposed)
{
_signingKey.Dispose();
_certificate.Dispose();
_disposed = true;
}
}
}
/// <summary>
/// Result of cosign structure validation.
/// </summary>
public sealed record CosignStructureValidationResult(bool IsValid, List<string> Errors);
/// <summary>
/// Test bundle with Fulcio certificate chain for cosign compatibility testing.
/// </summary>
public sealed record CosignCompatibilityBundle(
DsseEnvelope Envelope,
List<string> CertificateChain,
MockRekorEntry? RekorEntry);
/// <summary>
/// Mock Rekor transparency log entry for testing.
/// </summary>
public sealed record MockRekorEntry(
long LogIndex,
string LogId,
long IntegratedTime,
string CanonicalizedBody,
MockInclusionProof InclusionProof);
/// <summary>
/// Mock Merkle inclusion proof for testing.
/// </summary>
public sealed record MockInclusionProof(
long LogIndex,
long TreeSize,
string RootHash,
List<string> Hashes,
string Checkpoint);

View File

@@ -1,423 +0,0 @@
// -----------------------------------------------------------------------------
// DsseCosignCompatibilityTests.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-013, DSSE-8200-014, DSSE-8200-015
// Description: Cosign compatibility tests with mock Fulcio/Rekor (no CLI required)
// -----------------------------------------------------------------------------
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Xunit;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Tests for cosign compatibility without requiring external cosign CLI.
/// Validates envelope structure, Fulcio certificate handling, and Rekor entry format.
/// </summary>
public sealed class DsseCosignCompatibilityTests : IDisposable
{
private readonly DsseCosignCompatibilityTestFixture _fixture;
public DsseCosignCompatibilityTests()
{
_fixture = new DsseCosignCompatibilityTestFixture();
}
// ==========================================================================
// DSSE-8200-013: Cosign-compatible envelope structure tests
// ==========================================================================
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnvelopeStructure_HasRequiredFields_ForCosignVerification()
{
// Arrange
var payload = CreateTestInTotoStatement();
// Act
var envelope = _fixture.SignCosignCompatible(payload);
// Assert - Validate cosign-expected structure
var result = DsseCosignCompatibilityTestFixture.ValidateCosignStructure(envelope);
Assert.True(result.IsValid, $"Structure validation failed: {string.Join(", ", result.Errors)}");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnvelopePayload_IsBase64Encoded_InSerializedForm()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true
});
var json = JsonDocument.Parse(serialized.CompactJson!);
// Assert - payload should be base64-encoded in the JSON
var payloadField = json.RootElement.GetProperty("payload").GetString();
Assert.NotNull(payloadField);
Assert.DoesNotContain("\n", payloadField); // No newlines in base64
// Verify it decodes back to original
var decoded = Convert.FromBase64String(payloadField);
Assert.Equal(payload, decoded);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnvelopeSignature_IsBase64Encoded_InSerializedForm()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true
});
var json = JsonDocument.Parse(serialized.CompactJson!);
// Assert - signatures array exists with valid base64
var signatures = json.RootElement.GetProperty("signatures");
Assert.Equal(JsonValueKind.Array, signatures.ValueKind);
Assert.True(signatures.GetArrayLength() >= 1);
var firstSig = signatures[0];
var sigValue = firstSig.GetProperty("sig").GetString();
Assert.NotNull(sigValue);
// Verify it's valid base64
var sigBytes = Convert.FromBase64String(sigValue);
Assert.True(sigBytes.Length > 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnvelopePayloadType_IsCorrectMimeType_ForInToto()
{
// Arrange
var payload = CreateTestInTotoStatement();
// Act
var envelope = _fixture.SignCosignCompatible(payload, "application/vnd.in-toto+json");
// Assert
Assert.Equal("application/vnd.in-toto+json", envelope.PayloadType);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EnvelopeSerialization_ProducesValidJson_WithoutWhitespace()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var serialized = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true
});
var json = Encoding.UTF8.GetString(serialized.CompactJson!);
// Assert - compact JSON should not have unnecessary whitespace
Assert.DoesNotContain("\n", json);
Assert.DoesNotContain(" ", json); // No double spaces
}
// ==========================================================================
// DSSE-8200-014: Fulcio certificate chain tests
// ==========================================================================
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FulcioCertificate_HasCodeSigningEku()
{
// Arrange & Act
var cert = _fixture.Certificate;
// Assert - Certificate should have Code Signing EKU
var hasCodeSigning = false;
foreach (var ext in cert.Extensions)
{
if (ext is X509EnhancedKeyUsageExtension eku)
{
foreach (var oid in eku.EnhancedKeyUsages)
{
if (oid.Value == "1.3.6.1.5.5.7.3.3") // Code Signing
{
hasCodeSigning = true;
break;
}
}
}
}
Assert.True(hasCodeSigning, "Certificate should have Code Signing EKU");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FulcioCertificate_HasDigitalSignatureKeyUsage()
{
// Arrange & Act
var cert = _fixture.Certificate;
// Assert
var keyUsage = cert.Extensions["2.5.29.15"] as X509KeyUsageExtension;
Assert.NotNull(keyUsage);
Assert.True(keyUsage.KeyUsages.HasFlag(X509KeyUsageFlags.DigitalSignature));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FulcioCertificate_IsShortLived()
{
// Arrange - Fulcio certs are typically valid for ~20 minutes
// Act
var cert = _fixture.Certificate;
var validity = cert.NotAfter - cert.NotBefore;
// Assert - Should be less than 24 hours (Fulcio's short-lived nature)
Assert.True(validity.TotalHours <= 24, $"Certificate validity ({validity.TotalHours}h) should be <= 24 hours");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleWithCertificate_HasValidPemFormat()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var bundle = _fixture.CreateBundle(envelope);
// Assert
Assert.NotEmpty(bundle.CertificateChain);
var certPem = bundle.CertificateChain[0];
Assert.StartsWith("-----BEGIN CERTIFICATE-----", certPem);
Assert.Contains("-----END CERTIFICATE-----", certPem);
}
// ==========================================================================
// DSSE-8200-015: Rekor transparency log offline verification tests
// ==========================================================================
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_HasValidLogIndex()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
// Assert
Assert.True(rekorEntry.LogIndex >= 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_HasValidIntegratedTime()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
var integratedTime = DateTimeOffset.FromUnixTimeSeconds(rekorEntry.IntegratedTime);
// Assert - Should be within reasonable range
var now = DateTimeOffset.UtcNow;
Assert.True(integratedTime <= now.AddMinutes(1), "Integrated time should not be in the future");
Assert.True(integratedTime >= now.AddHours(-1), "Integrated time should not be too old");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_HasValidInclusionProof()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope, logIndex: 12345);
// Assert
Assert.NotNull(rekorEntry.InclusionProof);
Assert.Equal(12345, rekorEntry.InclusionProof.LogIndex);
Assert.True(rekorEntry.InclusionProof.TreeSize > rekorEntry.InclusionProof.LogIndex);
Assert.NotEmpty(rekorEntry.InclusionProof.RootHash);
Assert.NotEmpty(rekorEntry.InclusionProof.Hashes);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_CanonicalizedBody_IsBase64Encoded()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
// Assert
Assert.NotEmpty(rekorEntry.CanonicalizedBody);
var decoded = Convert.FromBase64String(rekorEntry.CanonicalizedBody);
Assert.True(decoded.Length > 0);
// Should be valid JSON
var json = JsonDocument.Parse(decoded);
Assert.NotNull(json);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_InclusionProof_HashesAreBase64()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
// Assert
foreach (var hash in rekorEntry.InclusionProof.Hashes)
{
var decoded = Convert.FromBase64String(hash);
Assert.Equal(32, decoded.Length); // SHA-256 hash length
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BundleWithRekor_ContainsValidTransparencyEntry()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var bundle = _fixture.CreateBundle(envelope, includeRekorEntry: true);
// Assert
Assert.NotNull(bundle.RekorEntry);
Assert.NotEmpty(bundle.RekorEntry.LogId);
Assert.True(bundle.RekorEntry.LogIndex >= 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RekorEntry_CheckpointFormat_IsValid()
{
// Arrange
var payload = CreateTestInTotoStatement();
var envelope = _fixture.SignCosignCompatible(payload);
// Act
var rekorEntry = _fixture.CreateMockRekorEntry(envelope);
// Assert - Checkpoint should contain log ID and root hash
Assert.NotEmpty(rekorEntry.InclusionProof.Checkpoint);
Assert.Contains("rekor.sigstore.dev", rekorEntry.InclusionProof.Checkpoint);
}
// ==========================================================================
// Integration tests
// ==========================================================================
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FullBundle_SignVerifyRoundtrip_Succeeds()
{
// Arrange
var payload = CreateTestInTotoStatement();
// Act - Create complete bundle
var envelope = _fixture.SignCosignCompatible(payload);
var bundle = _fixture.CreateBundle(envelope, includeRekorEntry: true);
// Assert - All components present and valid
Assert.NotNull(bundle.Envelope);
Assert.NotEmpty(bundle.CertificateChain);
Assert.NotNull(bundle.RekorEntry);
// Verify envelope structure
var structureResult = DsseCosignCompatibilityTestFixture.ValidateCosignStructure(envelope);
Assert.True(structureResult.IsValid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DeterministicSigning_SamePayload_ProducesConsistentEnvelope()
{
// Arrange
var payload = CreateTestInTotoStatement();
// Act - Sign same payload twice with same key
var envelope1 = _fixture.SignCosignCompatible(payload);
var envelope2 = _fixture.SignCosignCompatible(payload);
// Assert - Payload type and payload should be identical
Assert.Equal(envelope1.PayloadType, envelope2.PayloadType);
Assert.Equal(envelope1.Payload.ToArray(), envelope2.Payload.ToArray());
// Note: Signatures may differ if using randomized ECDSA
// (which is the default for security), so we only verify structure
Assert.Equal(envelope1.Signatures.Count, envelope2.Signatures.Count);
using StellaOps.TestKit;
}
// ==========================================================================
// Helpers
// ==========================================================================
private static byte[] CreateTestInTotoStatement()
{
var statement = new
{
_type = "https://in-toto.io/Statement/v0.1",
predicateType = "https://stellaops.io/attestations/reachability/v1",
subject = new[]
{
new { name = "test-artifact", digest = new { sha256 = "abc123" } }
},
predicate = new
{
graphType = "reachability",
nodeCount = 100,
edgeCount = 250,
timestamp = DateTimeOffset.UtcNow.ToString("O")
}
};
return JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
{
WriteIndented = false
});
}
public void Dispose()
{
_fixture.Dispose();
}
}

View File

@@ -1,61 +0,0 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
using EnvelopeModel = StellaOps.Attestor.Envelope;
using StellaOps.TestKit;
namespace StellaOps.Attestor.Envelope.Tests;
public sealed class DsseEnvelopeSerializerTests
{
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("deterministic-dsse-payload");
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Serialize_ProducesDeterministicCompactJson_ForSignaturePermutations()
{
var signatures = new[]
{
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("0A1B2C3D4E5F60718293A4B5C6D7E8F9"), "tenant-z"),
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"), null),
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("00112233445566778899AABBCCDDEEFF"), "tenant-a"),
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("1234567890ABCDEF1234567890ABCDEF"), "tenant-b")
};
var baselineEnvelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, signatures);
var baseline = EnvelopeModel.DsseEnvelopeSerializer.Serialize(baselineEnvelope);
baseline.CompactJson.Should().NotBeNull();
var baselineJson = Encoding.UTF8.GetString(baseline.CompactJson!);
var rng = new Random(12345);
for (var iteration = 0; iteration < 32; iteration++)
{
var shuffled = signatures.OrderBy(_ => rng.Next()).ToArray();
var envelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, shuffled);
var result = EnvelopeModel.DsseEnvelopeSerializer.Serialize(envelope);
result.CompactJson.Should().NotBeNull();
var json = Encoding.UTF8.GetString(result.CompactJson!);
json.Should().Be(baselineJson, "canonical JSON must be deterministic regardless of signature insertion order");
result.PayloadSha256.Should().Be(
Convert.ToHexString(SHA256.HashData(SamplePayload)).ToLowerInvariant(),
"payload hash must reflect the raw payload bytes");
using var document = JsonDocument.Parse(result.CompactJson!);
using StellaOps.TestKit;
var keyIds = document.RootElement
.GetProperty("signatures")
.EnumerateArray()
.Select(element => element.TryGetProperty("keyid", out var key) ? key.GetString() : null)
.ToArray();
keyIds.Should().Equal(new string?[] { null, "tenant-a", "tenant-b", "tenant-z" },
"signatures must be ordered by key identifier (null first) for canonical output");
}
}
}

View File

@@ -1,354 +0,0 @@
// -----------------------------------------------------------------------------
// DsseNegativeTests.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-016, DSSE-8200-017, DSSE-8200-018
// Description: DSSE negative/error handling tests
// -----------------------------------------------------------------------------
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Negative tests for DSSE envelope verification.
/// Validates error handling for expired certs, wrong keys, and malformed data.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Category", "DsseNegative")]
public sealed class DsseNegativeTests : IDisposable
{
private readonly DsseRoundtripTestFixture _fixture;
public DsseNegativeTests()
{
_fixture = new DsseRoundtripTestFixture();
}
// DSSE-8200-016: Expired certificate → verify fails with clear error
// Note: Testing certificate expiry requires X.509 certificate infrastructure.
// These tests use simulated scenarios or self-signed certs.
[Fact]
public void Verify_WithExpiredCertificateSimulation_FailsGracefully()
{
// Arrange - Sign with the fixture (simulates current key)
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Simulate "expired" by creating a verification with a different key
// In production, certificate expiry would be checked by the verifier
using var expiredFixture = new DsseRoundtripTestFixture();
// Act - Verify with "expired" key (different fixture)
var verified = expiredFixture.Verify(envelope);
var detailedResult = expiredFixture.VerifyDetailed(envelope);
// Assert
verified.Should().BeFalse("verification with different key should fail");
detailedResult.IsValid.Should().BeFalse();
detailedResult.SignatureResults.Should().Contain(r => !r.IsValid);
}
[Fact]
public void Verify_SignatureFromRevokedKey_FailsWithDetailedError()
{
// Arrange - Create envelope with one key
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
using var originalFixture = new DsseRoundtripTestFixture();
var envelope = originalFixture.Sign(payload);
// Act - Try to verify with different key (simulates key revocation scenario)
using var differentFixture = new DsseRoundtripTestFixture();
var result = differentFixture.VerifyDetailed(envelope);
// Assert
result.IsValid.Should().BeFalse();
result.SignatureResults.Should().HaveCount(1);
result.SignatureResults[0].IsValid.Should().BeFalse();
result.SignatureResults[0].FailureReason.Should().NotBeNullOrEmpty();
}
// DSSE-8200-017: Wrong key type → verify fails
[Fact]
public void Verify_WithWrongKeyType_Fails()
{
// Arrange - Sign with P-256
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Try to verify with P-384 key (wrong curve)
using var wrongCurveKey = ECDsa.Create(ECCurve.NamedCurves.nistP384);
using var wrongCurveFixture = new DsseRoundtripTestFixture(wrongCurveKey, "p384-key");
var verified = wrongCurveFixture.Verify(envelope);
// Assert
verified.Should().BeFalse("verification with wrong curve should fail");
}
[Fact]
public void Verify_WithMismatchedKeyId_SkipsSignature()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Create fixture with different key ID
using var differentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var differentIdFixture = new DsseRoundtripTestFixture(differentKey, "completely-different-key-id");
var result = differentIdFixture.VerifyDetailed(envelope);
// Assert - Should skip due to key ID mismatch (unless keyId is null)
result.IsValid.Should().BeFalse();
}
[Fact]
public void Verify_WithNullKeyId_MatchesAnyKey()
{
// Arrange - Create signature with null key ID
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var pae = BuildPae("application/vnd.in-toto+json", payload);
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var signatureBytes = key.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
var signature = DsseSignature.FromBytes(signatureBytes, null); // null key ID
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [signature]);
// Act - Verify with same key but different fixture (null keyId should still match)
using var verifyFixture = new DsseRoundtripTestFixture(key, "any-key-id");
var verified = verifyFixture.Verify(envelope);
// Assert - null keyId in signature should be attempted with any verifying key
verified.Should().BeTrue("null keyId should allow verification attempt");
}
// DSSE-8200-018: Truncated/malformed envelope → parse fails gracefully
[Fact]
public void Deserialize_TruncatedJson_ThrowsJsonException()
{
// Arrange
var validJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[{"sig":"YWJj""";
// Act & Assert
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(validJson));
act.Should().Throw<JsonException>();
}
[Fact]
public void Deserialize_MissingPayloadType_ThrowsKeyNotFoundException()
{
// Arrange
var invalidJson = """{"payload":"dGVzdA==","signatures":[{"sig":"YWJj"}]}""";
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<KeyNotFoundException>();
}
[Fact]
public void Deserialize_MissingPayload_ThrowsKeyNotFoundException()
{
// Arrange
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"YWJj"}]}""";
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<KeyNotFoundException>();
}
[Fact]
public void Deserialize_MissingSignatures_ThrowsKeyNotFoundException()
{
// Arrange
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA=="}""";
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<KeyNotFoundException>();
}
[Fact]
public void Deserialize_EmptySignaturesArray_ThrowsArgumentException()
{
// Arrange
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[]}""";
// Act & Assert
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<ArgumentException>()
.WithMessage("*signature*");
}
[Fact]
public void Deserialize_InvalidBase64Payload_ThrowsFormatException()
{
// Arrange
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"not-valid-base64!!!","signatures":[{"sig":"YWJj"}]}""";
// Act & Assert
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<FormatException>();
}
[Fact]
public void Deserialize_MissingSignatureInSignature_ThrowsKeyNotFoundException()
{
// Arrange
var invalidJson = """{"payloadType":"application/vnd.in-toto+json","payload":"dGVzdA==","signatures":[{"keyid":"key-1"}]}""";
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
var act = () => DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(invalidJson));
act.Should().Throw<KeyNotFoundException>();
}
[Fact]
public void Deserialize_EmptyPayload_Succeeds()
{
// Arrange - Empty payload is technically valid base64
var validJson = """{"payloadType":"application/vnd.in-toto+json","payload":"","signatures":[{"sig":"YWJj"}]}""";
// Act
var envelope = DsseRoundtripTestFixture.DeserializeFromBytes(Encoding.UTF8.GetBytes(validJson));
// Assert
envelope.Payload.Length.Should().Be(0);
}
[Fact]
public void Verify_InvalidBase64Signature_ReturnsFalse()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var invalidSig = new DsseSignature("not-valid-base64!!!", _fixture.KeyId);
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [invalidSig]);
// Act
var verified = _fixture.Verify(envelope);
// Assert
verified.Should().BeFalse("invalid base64 signature should not verify");
}
[Fact]
public void Verify_MalformedSignatureBytes_ReturnsFalse()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var malformedSig = DsseSignature.FromBytes([0x01, 0x02, 0x03], _fixture.KeyId); // Too short for ECDSA
var envelope = new DsseEnvelope("application/vnd.in-toto+json", payload, [malformedSig]);
// Act
var verified = _fixture.Verify(envelope);
// Assert
verified.Should().BeFalse("malformed signature bytes should not verify");
}
// Bundle negative tests
[Fact]
public void BundleDeserialize_TruncatedJson_ThrowsJsonException()
{
// Arrange
var truncated = """{"mediaType":"application/vnd.dev.sigstore""";
// Act & Assert
var act = () => SigstoreTestBundle.Deserialize(Encoding.UTF8.GetBytes(truncated));
act.Should().Throw<JsonException>();
}
[Fact]
public void BundleDeserialize_MissingDsseEnvelope_ThrowsKeyNotFoundException()
{
// Arrange
var missingEnvelope = """{"mediaType":"test","verificationMaterial":{"publicKey":{"hint":"k","rawBytes":"YWJj"},"algorithm":"ES256"}}""";
// Act & Assert - GetProperty throws KeyNotFoundException when key is missing
var act = () => SigstoreTestBundle.Deserialize(Encoding.UTF8.GetBytes(missingEnvelope));
act.Should().Throw<KeyNotFoundException>();
}
// Edge cases
[Fact]
public void Sign_EmptyPayload_FailsValidation()
{
// Arrange
var emptyPayload = Array.Empty<byte>();
// Act & Assert - DsseEnvelope allows empty payload (technically), but signing behavior depends on PAE
// Note: Empty payload is unusual but not necessarily invalid in DSSE spec
var envelope = _fixture.Sign(emptyPayload);
var verified = _fixture.Verify(envelope);
envelope.Payload.Length.Should().Be(0);
verified.Should().BeTrue("empty payload is valid DSSE");
}
[Fact]
public void Verify_ModifiedPayloadType_Fails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Create new envelope with modified payloadType
var modifiedEnvelope = new DsseEnvelope(
"application/vnd.different-type+json", // Different type
envelope.Payload,
envelope.Signatures);
// Assert
_fixture.Verify(modifiedEnvelope).Should().BeFalse("modified payloadType changes PAE and invalidates signature");
}
// Helper methods
private static byte[] BuildPae(string payloadType, byte[] payload)
{
const string preamble = "DSSEv1 ";
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLength = preamble.Length
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
+ payloadLenStr.Length + 1 + payload.Length;
var pae = new byte[totalLength];
var offset = 0;
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
offset += preamble.Length;
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
offset += payloadTypeLenStr.Length;
pae[offset++] = (byte)' ';
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
offset += payloadTypeBytes.Length;
pae[offset++] = (byte)' ';
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
offset += payloadLenStr.Length;
pae[offset++] = (byte)' ';
payload.CopyTo(pae.AsSpan(offset));
return pae;
}
public void Dispose()
{
_fixture.Dispose();
}
}

View File

@@ -1,364 +0,0 @@
// -----------------------------------------------------------------------------
// DsseRebundleTests.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-007, DSSE-8200-008, DSSE-8200-009
// Description: DSSE re-bundling verification tests
// -----------------------------------------------------------------------------
using System;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Tests for DSSE envelope re-bundling operations.
/// Validates sign → bundle → extract → re-bundle → verify cycles.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Category", "DsseRebundle")]
public sealed class DsseRebundleTests : IDisposable
{
private readonly DsseRoundtripTestFixture _fixture;
public DsseRebundleTests()
{
_fixture = new DsseRoundtripTestFixture();
}
// DSSE-8200-007: Full round-trip through bundle
[Fact]
public void SignBundleExtractRebundleVerify_FullRoundTrip_Succeeds()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
_fixture.Verify(envelope).Should().BeTrue("original envelope should verify");
// Act - Bundle
var bundle1 = _fixture.CreateSigstoreBundle(envelope);
var bundleBytes = bundle1.Serialize();
// Act - Extract
var extractedBundle = SigstoreTestBundle.Deserialize(bundleBytes);
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
// Act - Re-bundle
var rebundle = _fixture.CreateSigstoreBundle(extractedEnvelope);
var rebundleBytes = rebundle.Serialize();
// Act - Extract again and verify
var finalBundle = SigstoreTestBundle.Deserialize(rebundleBytes);
var finalEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(finalBundle);
var finalVerified = _fixture.Verify(finalEnvelope);
// Assert
finalVerified.Should().BeTrue("re-bundled envelope should verify");
finalEnvelope.Payload.ToArray().Should().BeEquivalentTo(envelope.Payload.ToArray());
finalEnvelope.PayloadType.Should().Be(envelope.PayloadType);
}
[Fact]
public void SignBundleExtractRebundleVerify_WithBundleKey_Succeeds()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Bundle with embedded key
var bundle = _fixture.CreateSigstoreBundle(envelope);
// Act - Extract and verify using bundle's embedded key
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
var verifiedWithBundleKey = DsseRoundtripTestFixture.VerifyWithBundleKey(extractedEnvelope, bundle);
// Assert
verifiedWithBundleKey.Should().BeTrue("envelope should verify with bundle's embedded key");
}
[Fact]
public void Bundle_PreservesEnvelopeIntegrity()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
var originalBytes = DsseRoundtripTestFixture.SerializeToBytes(envelope);
// Act
var bundle = _fixture.CreateSigstoreBundle(envelope);
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
var extractedBytes = DsseRoundtripTestFixture.SerializeToBytes(extractedEnvelope);
// Assert - Envelope bytes should be identical
extractedBytes.Should().BeEquivalentTo(originalBytes, "bundling should not modify envelope");
}
// DSSE-8200-008: Archive to tar.gz → extract → verify
[Fact]
public async Task SignBundleArchiveExtractVerify_ThroughGzipArchive_Succeeds()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
var bundle = _fixture.CreateSigstoreBundle(envelope);
var bundleBytes = bundle.Serialize();
var archivePath = Path.Combine(Path.GetTempPath(), $"dsse-archive-{Guid.NewGuid():N}.tar.gz");
var extractPath = Path.Combine(Path.GetTempPath(), $"dsse-extract-{Guid.NewGuid():N}");
try
{
// Act - Archive to gzip file
await using (var fileStream = File.Create(archivePath))
await using (var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal))
{
await gzipStream.WriteAsync(bundleBytes);
}
// Act - Extract from gzip file
Directory.CreateDirectory(extractPath);
await using (var fileStream = File.OpenRead(archivePath))
await using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress))
await using (var memoryStream = new MemoryStream())
{
await gzipStream.CopyToAsync(memoryStream);
var extractedBundleBytes = memoryStream.ToArray();
// Act - Deserialize and verify
var extractedBundle = SigstoreTestBundle.Deserialize(extractedBundleBytes);
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
var verified = _fixture.Verify(extractedEnvelope);
// Assert
verified.Should().BeTrue("envelope should verify after archive round-trip");
}
}
finally
{
try { File.Delete(archivePath); } catch { }
try { Directory.Delete(extractPath, true); } catch { }
}
}
[Fact]
public async Task SignBundleArchiveExtractVerify_ThroughMultipleFiles_PreservesIntegrity()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
var bundle = _fixture.CreateSigstoreBundle(envelope);
var tempDir = Path.Combine(Path.GetTempPath(), $"dsse-multi-{Guid.NewGuid():N}");
try
{
Directory.CreateDirectory(tempDir);
// Act - Save envelope and bundle as separate files
var envelopePath = Path.Combine(tempDir, "envelope.json");
var bundlePath = Path.Combine(tempDir, "bundle.json");
await File.WriteAllBytesAsync(envelopePath, DsseRoundtripTestFixture.SerializeToBytes(envelope));
await File.WriteAllBytesAsync(bundlePath, bundle.Serialize());
// Act - Reload both
var reloadedEnvelopeBytes = await File.ReadAllBytesAsync(envelopePath);
var reloadedBundleBytes = await File.ReadAllBytesAsync(bundlePath);
var reloadedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(reloadedEnvelopeBytes);
var reloadedBundle = SigstoreTestBundle.Deserialize(reloadedBundleBytes);
var extractedFromBundle = DsseRoundtripTestFixture.ExtractFromBundle(reloadedBundle);
// Assert - Both should verify and be equivalent
_fixture.Verify(reloadedEnvelope).Should().BeTrue("reloaded envelope should verify");
_fixture.Verify(extractedFromBundle).Should().BeTrue("extracted envelope should verify");
reloadedEnvelope.Payload.ToArray().Should().BeEquivalentTo(extractedFromBundle.Payload.ToArray());
}
finally
{
try { Directory.Delete(tempDir, true); } catch { }
}
}
// DSSE-8200-009: Multi-signature envelope round-trip
[Fact]
public void MultiSignatureEnvelope_BundleExtractVerify_AllSignaturesPreserved()
{
// Arrange - Create envelope with multiple signatures
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var key3 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var sig1 = CreateSignature(key1, payload, "key-1");
var sig2 = CreateSignature(key2, payload, "key-2");
var sig3 = CreateSignature(key3, payload, "key-3");
var multiSigEnvelope = new DsseEnvelope(
"application/vnd.in-toto+json",
payload,
[sig1, sig2, sig3]);
// Act - Bundle
var bundle = _fixture.CreateSigstoreBundle(multiSigEnvelope);
var bundleBytes = bundle.Serialize();
// Act - Extract
var extractedBundle = SigstoreTestBundle.Deserialize(bundleBytes);
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(extractedBundle);
// Assert - All signatures preserved
extractedEnvelope.Signatures.Should().HaveCount(3);
extractedEnvelope.Signatures.Select(s => s.KeyId)
.Should().BeEquivalentTo(["key-1", "key-2", "key-3"]);
}
[Fact]
public void MultiSignatureEnvelope_SignatureOrderIsCanonical()
{
// Arrange - Create signatures in non-alphabetical order
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
using var keyZ = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var keyA = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var keyM = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var sigZ = CreateSignature(keyZ, payload, "z-key");
var sigA = CreateSignature(keyA, payload, "a-key");
var sigM = CreateSignature(keyM, payload, "m-key");
// Act - Create envelope with out-of-order signatures
var envelope1 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigZ, sigA, sigM]);
var envelope2 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigA, sigM, sigZ]);
var envelope3 = new DsseEnvelope("application/vnd.in-toto+json", payload, [sigM, sigZ, sigA]);
// Assert - All should have canonical (alphabetical) signature order
var expectedOrder = new[] { "a-key", "m-key", "z-key" };
envelope1.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
envelope2.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
envelope3.Signatures.Select(s => s.KeyId).Should().Equal(expectedOrder);
}
[Fact]
public void MultiSignatureEnvelope_SerializationIsDeterministic()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var sig1 = CreateSignature(key1, payload, "key-1");
var sig2 = CreateSignature(key2, payload, "key-2");
// Act - Create envelopes with different signature order
var envelopeA = new DsseEnvelope("application/vnd.in-toto+json", payload, [sig1, sig2]);
var envelopeB = new DsseEnvelope("application/vnd.in-toto+json", payload, [sig2, sig1]);
var bytesA = DsseRoundtripTestFixture.SerializeToBytes(envelopeA);
var bytesB = DsseRoundtripTestFixture.SerializeToBytes(envelopeB);
// Assert - Serialization should be identical due to canonical ordering
bytesA.Should().BeEquivalentTo(bytesB, "canonical ordering should produce identical serialization");
}
// Bundle integrity tests
[Fact]
public void Bundle_TamperingDetected_VerificationFails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
var bundle = _fixture.CreateSigstoreBundle(envelope);
// Act - Extract and tamper with envelope
var extractedEnvelope = DsseRoundtripTestFixture.ExtractFromBundle(bundle);
var tamperedPayload = extractedEnvelope.Payload.ToArray();
tamperedPayload[0] ^= 0xFF;
var tamperedEnvelope = new DsseEnvelope(
extractedEnvelope.PayloadType,
tamperedPayload,
extractedEnvelope.Signatures);
// Assert - Tampered envelope should not verify with bundle key
var verifiedWithBundleKey = DsseRoundtripTestFixture.VerifyWithBundleKey(tamperedEnvelope, bundle);
verifiedWithBundleKey.Should().BeFalse("tampered envelope should not verify");
}
[Fact]
public void Bundle_DifferentKey_VerificationFails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
var bundle = _fixture.CreateSigstoreBundle(envelope);
// Act - Create a different fixture with different key
using var differentFixture = new DsseRoundtripTestFixture();
var differentBundle = differentFixture.CreateSigstoreBundle(envelope);
// Assert - Original envelope should not verify with different key
var verified = DsseRoundtripTestFixture.VerifyWithBundleKey(envelope, differentBundle);
verified.Should().BeFalse("envelope should not verify with wrong key");
}
// Helper methods
private static DsseSignature CreateSignature(ECDsa key, byte[] payload, string keyId)
{
var pae = BuildPae("application/vnd.in-toto+json", payload);
var signatureBytes = key.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return DsseSignature.FromBytes(signatureBytes, keyId);
}
private static byte[] BuildPae(string payloadType, byte[] payload)
{
const string preamble = "DSSEv1 ";
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLength = preamble.Length
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
+ payloadLenStr.Length + 1 + payload.Length;
var pae = new byte[totalLength];
var offset = 0;
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
offset += preamble.Length;
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
offset += payloadTypeLenStr.Length;
pae[offset++] = (byte)' ';
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
offset += payloadTypeBytes.Length;
pae[offset++] = (byte)' ';
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
offset += payloadLenStr.Length;
pae[offset++] = (byte)' ';
payload.CopyTo(pae.AsSpan(offset));
return pae;
}
public void Dispose()
{
_fixture.Dispose();
}
}

View File

@@ -1,503 +0,0 @@
// -----------------------------------------------------------------------------
// DsseRoundtripTestFixture.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-001, DSSE-8200-002, DSSE-8200-003
// Description: Test fixture providing DSSE signing, verification, and round-trip helpers
// -----------------------------------------------------------------------------
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Test fixture for DSSE round-trip verification tests.
/// Provides key generation, signing, verification, and serialization helpers.
/// </summary>
public sealed class DsseRoundtripTestFixture : IDisposable
{
private readonly ECDsa _signingKey;
private readonly string _keyId;
private bool _disposed;
/// <summary>
/// Creates a new test fixture with a fresh ECDSA P-256 key pair.
/// </summary>
public DsseRoundtripTestFixture()
: this(ECDsa.Create(ECCurve.NamedCurves.nistP256), $"test-key-{Guid.NewGuid():N}")
{
}
/// <summary>
/// Creates a test fixture with a specified key and key ID.
/// </summary>
public DsseRoundtripTestFixture(ECDsa signingKey, string keyId)
{
_signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey));
_keyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
}
/// <summary>
/// Gets the key ID associated with the signing key.
/// </summary>
public string KeyId => _keyId;
/// <summary>
/// Gets the public key bytes in X.509 SubjectPublicKeyInfo format.
/// </summary>
public ReadOnlyMemory<byte> PublicKeyBytes => _signingKey.ExportSubjectPublicKeyInfo();
// DSSE-8200-001: Core signing and verification helpers
/// <summary>
/// Signs a payload and creates a DSSE envelope.
/// Uses ECDSA P-256 with SHA-256 (ES256).
/// </summary>
public DsseEnvelope Sign(ReadOnlySpan<byte> payload, string payloadType = "application/vnd.in-toto+json")
{
// Build PAE (Pre-Authentication Encoding) as per DSSE spec
// PAE = "DSSEv1" || len(payloadType) || payloadType || len(payload) || payload
var pae = BuildPae(payloadType, payload);
// Sign the PAE
var signatureBytes = _signingKey.SignData(pae, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
var signature = DsseSignature.FromBytes(signatureBytes, _keyId);
return new DsseEnvelope(payloadType, payload.ToArray(), [signature]);
}
/// <summary>
/// Signs a JSON-serializable payload and creates a DSSE envelope.
/// </summary>
public DsseEnvelope SignJson<T>(T payload, string payloadType = "application/vnd.in-toto+json")
{
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
return Sign(payloadBytes, payloadType);
}
/// <summary>
/// Verifies a DSSE envelope signature using the fixture's public key.
/// Returns true if at least one signature verifies.
/// </summary>
public bool Verify(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
foreach (var sig in envelope.Signatures)
{
// Match by key ID if specified
if (sig.KeyId != null && sig.KeyId != _keyId)
{
continue;
}
try
{
var signatureBytes = Convert.FromBase64String(sig.Signature);
if (_signingKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
{
return true;
}
}
catch (FormatException)
{
// Invalid base64, skip
}
catch (CryptographicException)
{
// Invalid signature format, skip
}
}
return false;
}
/// <summary>
/// Creates a verification result with detailed information.
/// </summary>
public DsseVerificationResult VerifyDetailed(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
var results = new List<SignatureVerificationResult>();
foreach (var sig in envelope.Signatures)
{
var result = VerifySingleSignature(sig, pae);
results.Add(result);
}
var anyValid = results.Exists(r => r.IsValid);
return new DsseVerificationResult(anyValid, results);
}
// DSSE-8200-002: Serialization and persistence helpers
/// <summary>
/// Serializes a DSSE envelope to canonical JSON bytes.
/// </summary>
public static byte[] SerializeToBytes(DsseEnvelope envelope)
{
var result = DsseEnvelopeSerializer.Serialize(envelope, new DsseEnvelopeSerializationOptions
{
EmitCompactJson = true,
EmitExpandedJson = false
});
return result.CompactJson ?? throw new InvalidOperationException("Serialization failed to produce compact JSON.");
}
/// <summary>
/// Deserializes a DSSE envelope from canonical JSON bytes.
/// </summary>
public static DsseEnvelope DeserializeFromBytes(ReadOnlySpan<byte> json)
{
using var doc = JsonDocument.Parse(json.ToArray());
var root = doc.RootElement;
var payloadType = root.GetProperty("payloadType").GetString()
?? throw new JsonException("Missing payloadType");
var payloadBase64 = root.GetProperty("payload").GetString()
?? throw new JsonException("Missing payload");
var payload = Convert.FromBase64String(payloadBase64);
var signatures = new List<DsseSignature>();
foreach (var sigElement in root.GetProperty("signatures").EnumerateArray())
{
var sig = sigElement.GetProperty("sig").GetString()
?? throw new JsonException("Missing sig in signature");
sigElement.TryGetProperty("keyid", out var keyIdElement);
var keyId = keyIdElement.ValueKind == JsonValueKind.String ? keyIdElement.GetString() : null;
signatures.Add(new DsseSignature(sig, keyId));
}
return new DsseEnvelope(payloadType, payload, signatures);
}
/// <summary>
/// Persists a DSSE envelope to a file.
/// </summary>
public static async Task SaveToFileAsync(DsseEnvelope envelope, string filePath, CancellationToken cancellationToken = default)
{
var bytes = SerializeToBytes(envelope);
await File.WriteAllBytesAsync(filePath, bytes, cancellationToken);
}
/// <summary>
/// Loads a DSSE envelope from a file.
/// </summary>
public static async Task<DsseEnvelope> LoadFromFileAsync(string filePath, CancellationToken cancellationToken = default)
{
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
return DeserializeFromBytes(bytes);
}
/// <summary>
/// Performs a full round-trip: serialize to file, reload, deserialize.
/// </summary>
public static async Task<DsseEnvelope> RoundtripThroughFileAsync(
DsseEnvelope envelope,
string? tempPath = null,
CancellationToken cancellationToken = default)
{
tempPath ??= Path.Combine(Path.GetTempPath(), $"dsse-roundtrip-{Guid.NewGuid():N}.json");
try
{
await SaveToFileAsync(envelope, tempPath, cancellationToken);
return await LoadFromFileAsync(tempPath, cancellationToken);
}
finally
{
try { File.Delete(tempPath); } catch { /* Best effort cleanup */ }
}
}
// DSSE-8200-003: Sigstore bundle wrapper helpers
/// <summary>
/// Creates a minimal Sigstore-compatible bundle containing the DSSE envelope.
/// This is a simplified version for testing; production bundles need additional metadata.
/// </summary>
public SigstoreTestBundle CreateSigstoreBundle(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
var envelopeJson = SerializeToBytes(envelope);
var publicKeyDer = _signingKey.ExportSubjectPublicKeyInfo();
return new SigstoreTestBundle(
MediaType: "application/vnd.dev.sigstore.bundle.v0.3+json",
DsseEnvelope: envelopeJson,
PublicKey: publicKeyDer,
KeyId: _keyId,
Algorithm: "ES256");
}
/// <summary>
/// Extracts a DSSE envelope from a Sigstore test bundle.
/// </summary>
public static DsseEnvelope ExtractFromBundle(SigstoreTestBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return DeserializeFromBytes(bundle.DsseEnvelope);
}
/// <summary>
/// Verifies a DSSE envelope using the public key embedded in a bundle.
/// </summary>
public static bool VerifyWithBundleKey(DsseEnvelope envelope, SigstoreTestBundle bundle)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(bundle);
using var publicKey = ECDsa.Create();
publicKey.ImportSubjectPublicKeyInfo(bundle.PublicKey, out _);
var pae = BuildPae(envelope.PayloadType, envelope.Payload.Span);
foreach (var sig in envelope.Signatures)
{
if (sig.KeyId != null && sig.KeyId != bundle.KeyId)
{
continue;
}
try
{
var signatureBytes = Convert.FromBase64String(sig.Signature);
if (publicKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
{
return true;
}
}
catch
{
// Continue to next signature
}
}
return false;
}
// Payload creation helpers for tests
/// <summary>
/// Creates a minimal in-toto statement payload for testing.
/// </summary>
public static byte[] CreateInTotoPayload(
string predicateType = "https://slsa.dev/provenance/v1",
string subjectName = "test-artifact",
string subjectDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
{
var statement = new
{
_type = "https://in-toto.io/Statement/v1",
subject = new[]
{
new
{
name = subjectName,
digest = new { sha256 = subjectDigest.Replace("sha256:", "") }
}
},
predicateType,
predicate = new { }
};
return JsonSerializer.SerializeToUtf8Bytes(statement, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
}
/// <summary>
/// Creates a deterministic test payload with specified content.
/// </summary>
public static byte[] CreateTestPayload(string content = "deterministic-test-payload")
{
return Encoding.UTF8.GetBytes(content);
}
// Private helpers
private static byte[] BuildPae(string payloadType, ReadOnlySpan<byte> payload)
{
// PAE(payloadType, payload) = "DSSEv1" + SP + len(payloadType) + SP + payloadType + SP + len(payload) + SP + payload
// Where SP is ASCII space (0x20)
const string preamble = "DSSEv1 ";
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var payloadTypeLenStr = payloadTypeBytes.Length.ToString();
var payloadLenStr = payload.Length.ToString();
var totalLength = preamble.Length
+ payloadTypeLenStr.Length + 1 + payloadTypeBytes.Length + 1
+ payloadLenStr.Length + 1 + payload.Length;
var pae = new byte[totalLength];
var offset = 0;
// "DSSEv1 "
Encoding.UTF8.GetBytes(preamble, pae.AsSpan(offset));
offset += preamble.Length;
// len(payloadType) + SP
Encoding.UTF8.GetBytes(payloadTypeLenStr, pae.AsSpan(offset));
offset += payloadTypeLenStr.Length;
pae[offset++] = (byte)' ';
// payloadType + SP
payloadTypeBytes.CopyTo(pae.AsSpan(offset));
offset += payloadTypeBytes.Length;
pae[offset++] = (byte)' ';
// len(payload) + SP
Encoding.UTF8.GetBytes(payloadLenStr, pae.AsSpan(offset));
offset += payloadLenStr.Length;
pae[offset++] = (byte)' ';
// payload
payload.CopyTo(pae.AsSpan(offset));
return pae;
}
private SignatureVerificationResult VerifySingleSignature(DsseSignature sig, byte[] pae)
{
var keyMatches = sig.KeyId == null || sig.KeyId == _keyId;
if (!keyMatches)
{
return new SignatureVerificationResult(sig.KeyId, false, "Key ID mismatch");
}
try
{
var signatureBytes = Convert.FromBase64String(sig.Signature);
var isValid = _signingKey.VerifyData(pae, signatureBytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new SignatureVerificationResult(sig.KeyId, isValid, isValid ? null : "Signature verification failed");
}
catch (FormatException)
{
return new SignatureVerificationResult(sig.KeyId, false, "Invalid base64 signature format");
}
catch (CryptographicException ex)
{
return new SignatureVerificationResult(sig.KeyId, false, $"Cryptographic error: {ex.Message}");
}
}
public void Dispose()
{
if (!_disposed)
{
_signingKey.Dispose();
_disposed = true;
}
}
}
/// <summary>
/// Result of DSSE envelope verification with detailed per-signature results.
/// </summary>
public sealed record DsseVerificationResult(
bool IsValid,
IReadOnlyList<SignatureVerificationResult> SignatureResults);
/// <summary>
/// Result of verifying a single signature.
/// </summary>
public sealed record SignatureVerificationResult(
string? KeyId,
bool IsValid,
string? FailureReason);
/// <summary>
/// Minimal Sigstore-compatible bundle for testing DSSE round-trips.
/// </summary>
public sealed record SigstoreTestBundle(
string MediaType,
byte[] DsseEnvelope,
byte[] PublicKey,
string KeyId,
string Algorithm)
{
/// <summary>
/// Serializes the bundle to JSON bytes.
/// </summary>
public byte[] Serialize()
{
var bundle = new
{
mediaType = MediaType,
dsseEnvelope = Convert.ToBase64String(DsseEnvelope),
verificationMaterial = new
{
publicKey = new
{
hint = KeyId,
rawBytes = Convert.ToBase64String(PublicKey)
},
algorithm = Algorithm
}
};
return JsonSerializer.SerializeToUtf8Bytes(bundle, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
});
}
/// <summary>
/// Deserializes a bundle from JSON bytes.
/// </summary>
public static SigstoreTestBundle Deserialize(ReadOnlySpan<byte> json)
{
using var doc = JsonDocument.Parse(json.ToArray());
var root = doc.RootElement;
var mediaType = root.GetProperty("mediaType").GetString()
?? throw new JsonException("Missing mediaType");
var dsseEnvelopeBase64 = root.GetProperty("dsseEnvelope").GetString()
?? throw new JsonException("Missing dsseEnvelope");
var verificationMaterial = root.GetProperty("verificationMaterial");
var publicKeyElement = verificationMaterial.GetProperty("publicKey");
var keyId = publicKeyElement.GetProperty("hint").GetString()
?? throw new JsonException("Missing hint (keyId)");
var publicKeyBase64 = publicKeyElement.GetProperty("rawBytes").GetString()
?? throw new JsonException("Missing rawBytes");
var algorithm = verificationMaterial.GetProperty("algorithm").GetString()
?? throw new JsonException("Missing algorithm");
return new SigstoreTestBundle(
mediaType,
Convert.FromBase64String(dsseEnvelopeBase64),
Convert.FromBase64String(publicKeyBase64),
keyId,
algorithm);
}
}

View File

@@ -1,381 +0,0 @@
// -----------------------------------------------------------------------------
// DsseRoundtripTests.cs
// Sprint: SPRINT_8200_0001_0002_dsse_roundtrip_testing
// Tasks: DSSE-8200-004, DSSE-8200-005, DSSE-8200-006, DSSE-8200-010, DSSE-8200-011, DSSE-8200-012
// Description: DSSE round-trip verification tests
// -----------------------------------------------------------------------------
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using Xunit;
namespace StellaOps.Attestor.Envelope.Tests;
/// <summary>
/// Tests for DSSE envelope round-trip verification.
/// Validates sign → serialize → deserialize → verify cycles and determinism.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Category", "DsseRoundtrip")]
public sealed class DsseRoundtripTests : IDisposable
{
private readonly DsseRoundtripTestFixture _fixture;
public DsseRoundtripTests()
{
_fixture = new DsseRoundtripTestFixture();
}
// DSSE-8200-004: Basic sign → serialize → deserialize → verify
[Fact]
public void SignSerializeDeserializeVerify_HappyPath_Succeeds()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
// Act - Sign
var originalEnvelope = _fixture.Sign(payload);
var originalVerified = _fixture.Verify(originalEnvelope);
// Act - Serialize
var serializedBytes = DsseRoundtripTestFixture.SerializeToBytes(originalEnvelope);
// Act - Deserialize
var deserializedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(serializedBytes);
// Act - Verify deserialized
var deserializedVerified = _fixture.Verify(deserializedEnvelope);
// Assert
originalVerified.Should().BeTrue("original envelope should verify");
deserializedVerified.Should().BeTrue("deserialized envelope should verify");
deserializedEnvelope.PayloadType.Should().Be(originalEnvelope.PayloadType);
deserializedEnvelope.Payload.ToArray().Should().BeEquivalentTo(originalEnvelope.Payload.ToArray());
deserializedEnvelope.Signatures.Should().HaveCount(originalEnvelope.Signatures.Count);
}
[Fact]
public void SignSerializeDeserializeVerify_WithJsonPayload_PreservesContent()
{
// Arrange
var testData = new
{
_type = "https://in-toto.io/Statement/v1",
subject = new[] { new { name = "test", digest = new { sha256 = "abc123" } } },
predicateType = "https://slsa.dev/provenance/v1",
predicate = new { buildType = "test" }
};
// Act
var envelope = _fixture.SignJson(testData);
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var deserialized = DsseRoundtripTestFixture.DeserializeFromBytes(serialized);
// Assert
_fixture.Verify(deserialized).Should().BeTrue();
var originalPayload = Encoding.UTF8.GetString(envelope.Payload.Span);
var deserializedPayload = Encoding.UTF8.GetString(deserialized.Payload.Span);
deserializedPayload.Should().Be(originalPayload);
}
[Fact]
public async Task SignSerializeDeserializeVerify_ThroughFile_PreservesIntegrity()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Full round-trip through file system
var roundtrippedEnvelope = await DsseRoundtripTestFixture.RoundtripThroughFileAsync(envelope);
// Assert
_fixture.Verify(roundtrippedEnvelope).Should().BeTrue();
roundtrippedEnvelope.Payload.ToArray().Should().BeEquivalentTo(envelope.Payload.ToArray());
}
// DSSE-8200-005: Tamper detection - modified payload
[Fact]
public void Verify_WithModifiedPayload_Fails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
_fixture.Verify(envelope).Should().BeTrue("unmodified envelope should verify");
// Act - Tamper with payload
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var tamperedJson = TamperWithPayload(serialized);
var tamperedEnvelope = DsseRoundtripTestFixture.DeserializeFromBytes(tamperedJson);
// Assert
_fixture.Verify(tamperedEnvelope).Should().BeFalse("tampered payload should not verify");
}
[Fact]
public void Verify_WithSingleBytePayloadChange_Fails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateTestPayload("original-content-here");
var envelope = _fixture.Sign(payload);
// Act - Modify a single byte in payload
var modifiedPayload = payload.ToArray();
modifiedPayload[10] ^= 0x01; // Flip one bit in the middle
var tamperedEnvelope = new DsseEnvelope(
envelope.PayloadType,
modifiedPayload,
envelope.Signatures);
// Assert
_fixture.Verify(tamperedEnvelope).Should().BeFalse("single bit change should invalidate signature");
}
// DSSE-8200-006: Tamper detection - modified signature
[Fact]
public void Verify_WithModifiedSignature_Fails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
_fixture.Verify(envelope).Should().BeTrue("unmodified envelope should verify");
// Act - Tamper with signature
var originalSig = envelope.Signatures[0];
var tamperedSigBytes = Convert.FromBase64String(originalSig.Signature);
tamperedSigBytes[0] ^= 0xFF; // Corrupt first byte
var tamperedSig = new DsseSignature(Convert.ToBase64String(tamperedSigBytes), originalSig.KeyId);
var tamperedEnvelope = new DsseEnvelope(
envelope.PayloadType,
envelope.Payload,
[tamperedSig]);
// Assert
_fixture.Verify(tamperedEnvelope).Should().BeFalse("tampered signature should not verify");
}
[Fact]
public void Verify_WithTruncatedSignature_Fails()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Truncate signature
var originalSig = envelope.Signatures[0];
var truncatedSigBytes = Convert.FromBase64String(originalSig.Signature).AsSpan(0, 10).ToArray();
var truncatedSig = new DsseSignature(Convert.ToBase64String(truncatedSigBytes), originalSig.KeyId);
var tamperedEnvelope = new DsseEnvelope(
envelope.PayloadType,
envelope.Payload,
[truncatedSig]);
// Assert
_fixture.Verify(tamperedEnvelope).Should().BeFalse("truncated signature should not verify");
}
// DSSE-8200-010: Determinism - same payload signed twice produces identical envelope bytes
[Fact]
public void Sign_SamePayloadTwice_WithSameKey_ProducesConsistentPayloadAndSignatureFormat()
{
// Arrange - Use the same key instance to sign twice
var payload = DsseRoundtripTestFixture.CreateTestPayload("deterministic-payload");
// Act - Sign the same payload twice with the same key
var envelope1 = _fixture.Sign(payload);
var envelope2 = _fixture.Sign(payload);
// Assert - Payloads should be identical
envelope1.Payload.ToArray().Should().BeEquivalentTo(envelope2.Payload.ToArray());
envelope1.PayloadType.Should().Be(envelope2.PayloadType);
// Key ID should be the same
envelope1.Signatures[0].KeyId.Should().Be(envelope2.Signatures[0].KeyId);
// Note: ECDSA signatures may differ due to random k value, but they should both verify
_fixture.Verify(envelope1).Should().BeTrue();
_fixture.Verify(envelope2).Should().BeTrue();
}
[Fact]
public void Sign_DifferentPayloads_ProducesDifferentSignatures()
{
// Arrange
var payload1 = DsseRoundtripTestFixture.CreateTestPayload("payload-1");
var payload2 = DsseRoundtripTestFixture.CreateTestPayload("payload-2");
// Act
var envelope1 = _fixture.Sign(payload1);
var envelope2 = _fixture.Sign(payload2);
// Assert
envelope1.Signatures[0].Signature.Should().NotBe(envelope2.Signatures[0].Signature);
}
// DSSE-8200-011: Serialization is canonical (key order, no whitespace variance)
[Fact]
public void Serialize_ProducesCanonicalJson_NoWhitespaceVariance()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act - Serialize multiple times
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var bytes3 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
// Assert - All serializations should be byte-for-byte identical
bytes2.Should().BeEquivalentTo(bytes1);
bytes3.Should().BeEquivalentTo(bytes1);
}
[Fact]
public void Serialize_OrdersKeysConsistently()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act
var serialized = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var json = Encoding.UTF8.GetString(serialized);
// Assert - Verify key order in JSON
var payloadTypeIndex = json.IndexOf("\"payloadType\"");
var payloadIndex = json.IndexOf("\"payload\"");
var signaturesIndex = json.IndexOf("\"signatures\"");
payloadTypeIndex.Should().BeLessThan(payloadIndex, "payloadType should come before payload");
payloadIndex.Should().BeLessThan(signaturesIndex, "payload should come before signatures");
}
// DSSE-8200-012: Property test - serialize → deserialize → serialize produces identical bytes
[Theory]
[InlineData("simple-text-payload")]
[InlineData("")]
[InlineData("unicode: 你好世界 🔐")]
[InlineData("{\"key\":\"value\",\"nested\":{\"array\":[1,2,3]}}")]
public void SerializeDeserializeSerialize_ProducesIdenticalBytes(string payloadContent)
{
// Arrange
var payload = Encoding.UTF8.GetBytes(payloadContent);
if (payload.Length == 0)
{
// Empty payload needs at least one byte for valid DSSE
payload = Encoding.UTF8.GetBytes("{}");
}
var envelope = _fixture.Sign(payload);
// Act - Triple round-trip
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var deserialized1 = DsseRoundtripTestFixture.DeserializeFromBytes(bytes1);
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(deserialized1);
var deserialized2 = DsseRoundtripTestFixture.DeserializeFromBytes(bytes2);
var bytes3 = DsseRoundtripTestFixture.SerializeToBytes(deserialized2);
// Assert - All serializations should be identical
bytes2.Should().BeEquivalentTo(bytes1, "first round-trip should be stable");
bytes3.Should().BeEquivalentTo(bytes1, "second round-trip should be stable");
}
[Fact]
public void SerializeDeserializeSerialize_LargePayload_ProducesIdenticalBytes()
{
// Arrange - Create a large payload
var largeContent = new string('X', 100_000);
var payload = Encoding.UTF8.GetBytes($"{{\"large\":\"{largeContent}\"}}");
var envelope = _fixture.Sign(payload);
// Act
var bytes1 = DsseRoundtripTestFixture.SerializeToBytes(envelope);
var deserialized = DsseRoundtripTestFixture.DeserializeFromBytes(bytes1);
var bytes2 = DsseRoundtripTestFixture.SerializeToBytes(deserialized);
// Assert
bytes2.Should().BeEquivalentTo(bytes1);
_fixture.Verify(deserialized).Should().BeTrue();
}
// Verification result tests
[Fact]
public void VerifyDetailed_ValidEnvelope_ReturnsSuccessResult()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Act
var result = _fixture.VerifyDetailed(envelope);
// Assert
result.IsValid.Should().BeTrue();
result.SignatureResults.Should().HaveCount(1);
result.SignatureResults[0].IsValid.Should().BeTrue();
result.SignatureResults[0].FailureReason.Should().BeNull();
}
[Fact]
public void VerifyDetailed_InvalidSignature_ReturnsFailureReason()
{
// Arrange
var payload = DsseRoundtripTestFixture.CreateInTotoPayload();
var envelope = _fixture.Sign(payload);
// Tamper with payload
var tamperedPayload = payload.ToArray();
tamperedPayload[0] ^= 0xFF;
var tamperedEnvelope = new DsseEnvelope(
envelope.PayloadType,
tamperedPayload,
envelope.Signatures);
// Act
var result = _fixture.VerifyDetailed(tamperedEnvelope);
// Assert
result.IsValid.Should().BeFalse();
result.SignatureResults.Should().HaveCount(1);
result.SignatureResults[0].IsValid.Should().BeFalse();
result.SignatureResults[0].FailureReason.Should().NotBeNullOrEmpty();
}
// Helper methods
private static byte[] TamperWithPayload(byte[] serializedEnvelope)
{
var json = Encoding.UTF8.GetString(serializedEnvelope);
using var doc = JsonDocument.Parse(json);
var payloadBase64 = doc.RootElement.GetProperty("payload").GetString()!;
var payloadBytes = Convert.FromBase64String(payloadBase64);
// Modify payload content
payloadBytes[0] ^= 0xFF;
var tamperedPayloadBase64 = Convert.ToBase64String(payloadBytes);
// Reconstruct JSON with tampered payload
json = json.Replace(payloadBase64, tamperedPayloadBase64);
return Encoding.UTF8.GetBytes(json);
}
public void Dispose()
{
_fixture.Dispose();
}
}

View File

@@ -1,159 +0,0 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Attestor.Envelope.Tests;
public sealed class EnvelopeSignatureServiceTests
{
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("stella-ops-deterministic");
private static readonly byte[] Ed25519Seed =
Convert.FromHexString("9D61B19DEFFD5A60BA844AF492EC2CC4" +
"4449C5697B326919703BAC031CAE7F60D75A980182B10AB7D54BFED3C964073A" +
"0EE172F3DAA62325AF021A68F707511A");
private static readonly byte[] Ed25519Public =
Convert.FromHexString("D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A");
private readonly EnvelopeSignatureService service = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignAndVerify_Ed25519_Succeeds()
{
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
var signResult = service.Sign(SamplePayload, signingKey);
signResult.IsSuccess.Should().BeTrue();
signResult.Value.AlgorithmId.Should().Be(SignatureAlgorithms.Ed25519);
signResult.Value.KeyId.Should().Be(signingKey.KeyId);
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
verifyResult.IsSuccess.Should().BeTrue();
verifyResult.Value.Should().BeTrue();
var expectedKeyId = ComputeExpectedEd25519KeyId(Ed25519Public);
signingKey.KeyId.Should().Be(expectedKeyId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_Ed25519_InvalidSignature_ReturnsError()
{
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
var signResult = service.Sign(SamplePayload, signingKey);
signResult.IsSuccess.Should().BeTrue();
var tamperedBytes = signResult.Value.Value.ToArray();
tamperedBytes[0] ^= 0xFF;
var tamperedSignature = new EnvelopeSignature(signResult.Value.KeyId, signResult.Value.AlgorithmId, tamperedBytes);
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
var verifyResult = service.Verify(SamplePayload, tamperedSignature, verifyKey);
verifyResult.IsSuccess.Should().BeFalse();
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.SignatureInvalid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SignAndVerify_EcdsaEs256_Succeeds()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
var verifyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
var signResult = service.Sign(SamplePayload, signingKey);
signResult.IsSuccess.Should().BeTrue();
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
verifyResult.IsSuccess.Should().BeTrue();
verifyResult.Value.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Sign_WithVerificationOnlyKey_ReturnsMissingPrivateKey()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
var verifyOnlyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
var signResult = service.Sign(SamplePayload, verifyOnlyKey);
signResult.IsSuccess.Should().BeFalse();
signResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.MissingPrivateKey);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_WithMismatchedKeyId_ReturnsError()
{
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
var signResult = service.Sign(SamplePayload, signingKey);
signResult.IsSuccess.Should().BeTrue();
var alternateKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public, "sha256:alternate");
var verifyResult = service.Verify(SamplePayload, signResult.Value, alternateKey);
verifyResult.IsSuccess.Should().BeFalse();
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.KeyIdMismatch);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_WithInvalidSignatureLength_ReturnsFormatError()
{
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
var invalidSignature = new EnvelopeSignature(verifyKey.KeyId, verifyKey.AlgorithmId, new byte[16]);
var verifyResult = service.Verify(SamplePayload, invalidSignature, verifyKey);
verifyResult.IsSuccess.Should().BeFalse();
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.InvalidSignatureFormat);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_WithAlgorithmMismatch_ReturnsError()
{
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
var signResult = service.Sign(SamplePayload, signingKey);
signResult.IsSuccess.Should().BeTrue();
var mismatchKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es384, in publicParameters, signResult.Value.KeyId);
var verifyResult = service.Verify(SamplePayload, signResult.Value, mismatchKey);
verifyResult.IsSuccess.Should().BeFalse();
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.AlgorithmMismatch);
}
private static string ComputeExpectedEd25519KeyId(byte[] publicKey)
{
var jwk = $"{{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"{ToBase64Url(publicKey)}\"}}";
using var sha = SHA256.Create();
using StellaOps.TestKit;
var digest = sha.ComputeHash(Encoding.UTF8.GetBytes(jwk));
return $"sha256:{ToBase64Url(digest)}";
}
private static string ToBase64Url(byte[] bytes)
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="BouncyCastle.Cryptography" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
@@ -114,7 +114,6 @@ public sealed class DsseEnvelopeSerializerTests
Assert.NotNull(result.ExpandedJson);
using var expanded = JsonDocument.Parse(result.ExpandedJson!);
using StellaOps.TestKit;
var detached = expanded.RootElement.GetProperty("detachedPayload");
Assert.Equal(reference.Uri, detached.GetProperty("uri").GetString());

View File

@@ -7,16 +7,19 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FsCheck.Xunit" Version="3.3.1" />
<PackageReference Include="FsCheck" Version="3.3.1" />
<PackageReference Include="FsCheck.Xunit" />
<PackageReference Include="FsCheck" />
<PackageReference Include="xunit.runner.visualstudio" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -27,5 +30,4 @@
<ProjectReference Include="..\\..\\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>