sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

@@ -0,0 +1,354 @@
// -----------------------------------------------------------------------------
// 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

@@ -0,0 +1,364 @@
// -----------------------------------------------------------------------------
// 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

@@ -0,0 +1,503 @@
// -----------------------------------------------------------------------------
// 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

@@ -0,0 +1,381 @@
// -----------------------------------------------------------------------------
// 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

@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.GraphRoot.Models;
using StellaOps.Canonical.Json;
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Implementation of graph root attestation service.
/// Creates and verifies DSSE-signed in-toto statements for graph roots.
/// </summary>
public sealed class GraphRootAttestor : IGraphRootAttestor
{
private const string ToolName = "stellaops/attestor/graph-root";
private const string PayloadType = "application/vnd.in-toto+json";
private static readonly string _toolVersion = GetToolVersion();
private readonly IMerkleRootComputer _merkleComputer;
private readonly EnvelopeSignatureService _signatureService;
private readonly Func<string?, EnvelopeKey?> _keyResolver;
private readonly ILogger<GraphRootAttestor> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GraphRootAttestor"/> class.
/// </summary>
/// <param name="merkleComputer">Service for computing Merkle roots.</param>
/// <param name="signatureService">Service for signing envelopes.</param>
/// <param name="keyResolver">Function to resolve signing keys by ID.</param>
/// <param name="logger">Logger instance.</param>
public GraphRootAttestor(
IMerkleRootComputer merkleComputer,
EnvelopeSignatureService signatureService,
Func<string?, EnvelopeKey?> keyResolver,
ILogger<GraphRootAttestor> logger)
{
_merkleComputer = merkleComputer ?? throw new ArgumentNullException(nameof(merkleComputer));
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
_keyResolver = keyResolver ?? throw new ArgumentNullException(nameof(keyResolver));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<GraphRootAttestationResult> AttestAsync(
GraphRootAttestationRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ct.ThrowIfCancellationRequested();
_logger.LogDebug(
"Creating graph root attestation for {GraphType} with {NodeCount} nodes and {EdgeCount} edges",
request.GraphType,
request.NodeIds.Count,
request.EdgeIds.Count);
// 1. Sort node and edge IDs lexicographically for determinism
var sortedNodeIds = request.NodeIds
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
var sortedEdgeIds = request.EdgeIds
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
var sortedEvidenceIds = request.EvidenceIds
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
// 2. Build leaf data for Merkle tree
var leaves = BuildLeaves(
sortedNodeIds,
sortedEdgeIds,
request.PolicyDigest,
request.FeedsDigest,
request.ToolchainDigest,
request.ParamsDigest);
// 3. Compute Merkle root
var rootBytes = _merkleComputer.ComputeRoot(leaves);
var rootHex = Convert.ToHexStringLower(rootBytes);
var rootHash = $"{_merkleComputer.Algorithm}:{rootHex}";
_logger.LogDebug("Computed Merkle root: {RootHash}", rootHash);
// 4. Build in-toto statement
var computedAt = DateTimeOffset.UtcNow;
var attestation = BuildAttestation(
request,
sortedNodeIds,
sortedEdgeIds,
sortedEvidenceIds,
rootHash,
rootHex,
computedAt);
// 5. Canonicalize the attestation
var payload = CanonJson.CanonicalizeVersioned(attestation);
// 6. Sign the payload
var key = _keyResolver(request.SigningKeyId);
if (key is null)
{
throw new InvalidOperationException(
$"Unable to resolve signing key: {request.SigningKeyId ?? "(default)"}");
}
var signResult = _signatureService.Sign(payload, key, ct);
if (!signResult.IsSuccess)
{
throw new InvalidOperationException(
$"Signing failed: {signResult.Error?.Message}");
}
var dsseSignature = DsseSignature.FromBytes(signResult.Value!.Value.Span, signResult.Value.KeyId);
var envelope = new DsseEnvelope(PayloadType, payload, [dsseSignature]);
_logger.LogInformation(
"Created graph root attestation with root {RootHash} for {GraphType}",
rootHash,
request.GraphType);
// Note: Rekor publishing would be handled by a separate service
// that accepts the envelope after creation
return new GraphRootAttestationResult
{
RootHash = rootHash,
Envelope = envelope,
RekorLogIndex = null, // Would be set by Rekor service
NodeCount = sortedNodeIds.Count,
EdgeCount = sortedEdgeIds.Count
};
}
/// <inheritdoc />
public async Task<GraphRootVerificationResult> VerifyAsync(
DsseEnvelope envelope,
IReadOnlyList<GraphNodeData> nodes,
IReadOnlyList<GraphEdgeData> edges,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(nodes);
ArgumentNullException.ThrowIfNull(edges);
ct.ThrowIfCancellationRequested();
_logger.LogDebug(
"Verifying graph root attestation with {NodeCount} nodes and {EdgeCount} edges",
nodes.Count,
edges.Count);
// 1. Deserialize attestation from envelope payload
GraphRootAttestation? attestation;
try
{
attestation = JsonSerializer.Deserialize<GraphRootAttestation>(envelope.Payload.Span);
}
catch (JsonException ex)
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = $"Failed to deserialize attestation: {ex.Message}"
};
}
if (attestation?.Predicate is null)
{
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = "Attestation or predicate is null"
};
}
// 2. Sort and recompute
var recomputedNodeIds = nodes
.Select(n => n.NodeId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
var recomputedEdgeIds = edges
.Select(e => e.EdgeId)
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
// 3. Build leaves using the same inputs from the attestation
var leaves = BuildLeaves(
recomputedNodeIds,
recomputedEdgeIds,
attestation.Predicate.Inputs.PolicyDigest,
attestation.Predicate.Inputs.FeedsDigest,
attestation.Predicate.Inputs.ToolchainDigest,
attestation.Predicate.Inputs.ParamsDigest);
// 4. Compute Merkle root
var recomputedRootBytes = _merkleComputer.ComputeRoot(leaves);
var recomputedRootHex = Convert.ToHexStringLower(recomputedRootBytes);
var recomputedRootHash = $"{_merkleComputer.Algorithm}:{recomputedRootHex}";
// 5. Compare roots
if (!string.Equals(recomputedRootHash, attestation.Predicate.RootHash, StringComparison.Ordinal))
{
_logger.LogWarning(
"Graph root mismatch: expected {Expected}, computed {Computed}",
attestation.Predicate.RootHash,
recomputedRootHash);
return new GraphRootVerificationResult
{
IsValid = false,
FailureReason = $"Root mismatch: expected {attestation.Predicate.RootHash}, got {recomputedRootHash}",
ExpectedRoot = attestation.Predicate.RootHash,
ComputedRoot = recomputedRootHash,
NodeCount = recomputedNodeIds.Count,
EdgeCount = recomputedEdgeIds.Count
};
}
_logger.LogDebug("Graph root verification succeeded: {RootHash}", recomputedRootHash);
return new GraphRootVerificationResult
{
IsValid = true,
ExpectedRoot = attestation.Predicate.RootHash,
ComputedRoot = recomputedRootHash,
NodeCount = recomputedNodeIds.Count,
EdgeCount = recomputedEdgeIds.Count
};
}
private static List<ReadOnlyMemory<byte>> BuildLeaves(
IReadOnlyList<string> sortedNodeIds,
IReadOnlyList<string> sortedEdgeIds,
string policyDigest,
string feedsDigest,
string toolchainDigest,
string paramsDigest)
{
var leaves = new List<ReadOnlyMemory<byte>>(
sortedNodeIds.Count + sortedEdgeIds.Count + 4);
// Add node IDs
foreach (var nodeId in sortedNodeIds)
{
leaves.Add(Encoding.UTF8.GetBytes(nodeId));
}
// Add edge IDs
foreach (var edgeId in sortedEdgeIds)
{
leaves.Add(Encoding.UTF8.GetBytes(edgeId));
}
// Add input digests (deterministic order)
leaves.Add(Encoding.UTF8.GetBytes(policyDigest));
leaves.Add(Encoding.UTF8.GetBytes(feedsDigest));
leaves.Add(Encoding.UTF8.GetBytes(toolchainDigest));
leaves.Add(Encoding.UTF8.GetBytes(paramsDigest));
return leaves;
}
private static GraphRootAttestation BuildAttestation(
GraphRootAttestationRequest request,
IReadOnlyList<string> sortedNodeIds,
IReadOnlyList<string> sortedEdgeIds,
IReadOnlyList<string> sortedEvidenceIds,
string rootHash,
string rootHex,
DateTimeOffset computedAt)
{
var subjects = new List<GraphRootSubject>
{
// Primary subject: the graph root itself
new GraphRootSubject
{
Name = rootHash,
Digest = new Dictionary<string, string> { ["sha256"] = rootHex }
}
};
// Add artifact subject if provided
if (!string.IsNullOrEmpty(request.ArtifactDigest))
{
subjects.Add(new GraphRootSubject
{
Name = request.ArtifactDigest,
Digest = ParseDigest(request.ArtifactDigest)
});
}
return new GraphRootAttestation
{
Subject = subjects,
Predicate = new GraphRootPredicate
{
GraphType = request.GraphType.ToString(),
RootHash = rootHash,
RootAlgorithm = "sha256",
NodeCount = sortedNodeIds.Count,
EdgeCount = sortedEdgeIds.Count,
NodeIds = sortedNodeIds,
EdgeIds = sortedEdgeIds,
Inputs = new GraphInputDigests
{
PolicyDigest = request.PolicyDigest,
FeedsDigest = request.FeedsDigest,
ToolchainDigest = request.ToolchainDigest,
ParamsDigest = request.ParamsDigest
},
EvidenceIds = sortedEvidenceIds,
CanonVersion = CanonVersion.Current,
ComputedAt = computedAt,
ComputedBy = ToolName,
ComputedByVersion = _toolVersion
}
};
}
private static Dictionary<string, string> ParseDigest(string digest)
{
var colonIndex = digest.IndexOf(':');
if (colonIndex > 0 && colonIndex < digest.Length - 1)
{
var algorithm = digest[..colonIndex];
var value = digest[(colonIndex + 1)..];
return new Dictionary<string, string> { [algorithm] = value };
}
// Assume sha256 if no algorithm prefix
return new Dictionary<string, string> { ["sha256"] = digest };
}
private static string GetToolVersion()
{
var assembly = typeof(GraphRootAttestor).Assembly;
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "1.0.0";
return version;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Extension methods for registering graph root attestation services.
/// </summary>
public static class GraphRootServiceCollectionExtensions
{
/// <summary>
/// Adds graph root attestation services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGraphRootAttestation(this IServiceCollection services)
{
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
services.TryAddSingleton<EnvelopeSignatureService>();
services.TryAddSingleton<IGraphRootAttestor, GraphRootAttestor>();
return services;
}
/// <summary>
/// Adds graph root attestation services with a custom key resolver.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="keyResolver">Function to resolve signing keys by ID.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGraphRootAttestation(
this IServiceCollection services,
Func<IServiceProvider, Func<string?, EnvelopeKey?>> keyResolver)
{
ArgumentNullException.ThrowIfNull(keyResolver);
services.TryAddSingleton<IMerkleRootComputer, Sha256MerkleRootComputer>();
services.TryAddSingleton<EnvelopeSignatureService>();
services.AddSingleton<IGraphRootAttestor>(sp =>
{
var merkleComputer = sp.GetRequiredService<IMerkleRootComputer>();
var signatureService = sp.GetRequiredService<EnvelopeSignatureService>();
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<GraphRootAttestor>>();
var resolver = keyResolver(sp);
return new GraphRootAttestor(merkleComputer, signatureService, resolver, logger);
});
return services;
}
}

View File

@@ -0,0 +1,62 @@
// <copyright file="GraphType.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// </copyright>
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Types of graphs that can have their roots attested.
/// </summary>
public enum GraphType
{
/// <summary>
/// Unknown or unspecified graph type.
/// </summary>
Unknown = 0,
/// <summary>
/// Call graph showing function/method invocation relationships.
/// Used for reachability analysis.
/// </summary>
CallGraph = 1,
/// <summary>
/// Dependency graph showing package/library dependencies.
/// </summary>
DependencyGraph = 2,
/// <summary>
/// SBOM component graph with artifact relationships.
/// </summary>
SbomGraph = 3,
/// <summary>
/// Evidence graph linking vulnerabilities to evidence records.
/// </summary>
EvidenceGraph = 4,
/// <summary>
/// Policy evaluation graph showing rule evaluation paths.
/// </summary>
PolicyGraph = 5,
/// <summary>
/// Proof spine graph representing the chain of evidence segments.
/// </summary>
ProofSpine = 6,
/// <summary>
/// Combined reachability graph (call graph + dependency graph).
/// </summary>
ReachabilityGraph = 7,
/// <summary>
/// VEX observation linkage graph.
/// </summary>
VexLinkageGraph = 8,
/// <summary>
/// Custom/user-defined graph type.
/// </summary>
Custom = 100
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.GraphRoot.Models;
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Service for creating and verifying graph root attestations.
/// Graph root attestations bind a Merkle root computed from sorted node/edge IDs
/// and input digests to a signed DSSE envelope with an in-toto statement.
/// </summary>
public interface IGraphRootAttestor
{
/// <summary>
/// Create a graph root attestation.
/// </summary>
/// <param name="request">The attestation request containing graph data and signing options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation result containing the root hash and signed envelope.</returns>
Task<GraphRootAttestationResult> AttestAsync(
GraphRootAttestationRequest request,
CancellationToken ct = default);
/// <summary>
/// Verify a graph root attestation against provided graph data.
/// </summary>
/// <param name="envelope">The DSSE envelope to verify.</param>
/// <param name="nodes">The graph nodes to verify against.</param>
/// <param name="edges">The graph edges to verify against.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The verification result.</returns>
Task<GraphRootVerificationResult> VerifyAsync(
DsseEnvelope envelope,
IReadOnlyList<GraphNodeData> nodes,
IReadOnlyList<GraphEdgeData> edges,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Service for computing Merkle tree roots from leaf data.
/// </summary>
public interface IMerkleRootComputer
{
/// <summary>
/// Compute a Merkle root from the given leaves.
/// </summary>
/// <param name="leaves">The leaf data in order.</param>
/// <returns>The computed root hash bytes.</returns>
byte[] ComputeRoot(IReadOnlyList<ReadOnlyMemory<byte>> leaves);
/// <summary>
/// The hash algorithm used for Merkle computation.
/// </summary>
string Algorithm { get; }
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.GraphRoot.Models;
/// <summary>
/// In-toto statement for graph root attestation.
/// PredicateType: "https://stella-ops.org/attestation/graph-root/v1"
/// </summary>
public sealed record GraphRootAttestation
{
/// <summary>
/// In-toto statement type URI.
/// </summary>
[JsonPropertyName("_type")]
public string Type { get; init; } = "https://in-toto.io/Statement/v1";
/// <summary>
/// Subjects: the graph root hash and artifact it describes.
/// </summary>
[JsonPropertyName("subject")]
public required IReadOnlyList<GraphRootSubject> Subject { get; init; }
/// <summary>
/// Predicate type for graph root attestations.
/// </summary>
[JsonPropertyName("predicateType")]
public string PredicateType { get; init; } = GraphRootPredicateTypes.GraphRootV1;
/// <summary>
/// Graph root predicate payload.
/// </summary>
[JsonPropertyName("predicate")]
public required GraphRootPredicate Predicate { get; init; }
}
/// <summary>
/// Subject in an in-toto statement, representing an artifact or root hash.
/// </summary>
public sealed record GraphRootSubject
{
/// <summary>
/// The name or identifier of the subject.
/// For graph roots, this is typically the root hash.
/// For artifacts, this is the artifact reference.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Digests of the subject in algorithm:hex format.
/// </summary>
[JsonPropertyName("digest")]
public required IReadOnlyDictionary<string, string> Digest { get; init; }
}
/// <summary>
/// Well-known predicate type URIs for graph root attestations.
/// </summary>
public static class GraphRootPredicateTypes
{
/// <summary>
/// Graph root attestation predicate type v1.
/// </summary>
public const string GraphRootV1 = "https://stella-ops.org/attestation/graph-root/v1";
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.GraphRoot.Models;
/// <summary>
/// Request to create a graph root attestation.
/// The attestation binds a Merkle root computed from sorted node/edge IDs
/// and input digests to a DSSE envelope with in-toto statement.
/// </summary>
public sealed record GraphRootAttestationRequest
{
/// <summary>
/// Type of graph being attested.
/// </summary>
public required GraphType GraphType { get; init; }
/// <summary>
/// Node IDs to include in the root computation.
/// Will be sorted lexicographically for deterministic ordering.
/// </summary>
public required IReadOnlyList<string> NodeIds { get; init; }
/// <summary>
/// Edge IDs to include in the root computation.
/// Will be sorted lexicographically for deterministic ordering.
/// </summary>
public required IReadOnlyList<string> EdgeIds { get; init; }
/// <summary>
/// Policy bundle digest used during graph computation.
/// </summary>
public required string PolicyDigest { get; init; }
/// <summary>
/// Feed snapshot digest used during graph computation.
/// </summary>
public required string FeedsDigest { get; init; }
/// <summary>
/// Toolchain digest (scanner versions, analyzers, etc.).
/// </summary>
public required string ToolchainDigest { get; init; }
/// <summary>
/// Evaluation parameters digest (config, thresholds, etc.).
/// </summary>
public required string ParamsDigest { get; init; }
/// <summary>
/// Artifact digest this graph describes (container image, SBOM, etc.).
/// </summary>
public required string ArtifactDigest { get; init; }
/// <summary>
/// Linked evidence IDs referenced by this graph.
/// </summary>
public IReadOnlyList<string> EvidenceIds { get; init; } = [];
/// <summary>
/// Whether to publish the attestation to a Rekor transparency log.
/// </summary>
public bool PublishToRekor { get; init; } = false;
/// <summary>
/// Signing key ID to use for the DSSE envelope.
/// If null, the default signing key will be used.
/// </summary>
public string? SigningKeyId { get; init; }
}

View File

@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.GraphRoot.Models;
/// <summary>
/// Predicate for graph root attestations.
/// Contains the computed Merkle root and all inputs needed for reproducibility.
/// </summary>
public sealed record GraphRootPredicate
{
/// <summary>
/// Type of graph that was attested.
/// </summary>
[JsonPropertyName("graphType")]
public required string GraphType { get; init; }
/// <summary>
/// Merkle root hash in algorithm:hex format.
/// </summary>
[JsonPropertyName("rootHash")]
public required string RootHash { get; init; }
/// <summary>
/// Hash algorithm used (e.g., "sha256").
/// </summary>
[JsonPropertyName("rootAlgorithm")]
public string RootAlgorithm { get; init; } = "sha256";
/// <summary>
/// Number of nodes included in the root computation.
/// </summary>
[JsonPropertyName("nodeCount")]
public required int NodeCount { get; init; }
/// <summary>
/// Number of edges included in the root computation.
/// </summary>
[JsonPropertyName("edgeCount")]
public required int EdgeCount { get; init; }
/// <summary>
/// Sorted node IDs for deterministic verification.
/// </summary>
[JsonPropertyName("nodeIds")]
public required IReadOnlyList<string> NodeIds { get; init; }
/// <summary>
/// Sorted edge IDs for deterministic verification.
/// </summary>
[JsonPropertyName("edgeIds")]
public required IReadOnlyList<string> EdgeIds { get; init; }
/// <summary>
/// Input digests for reproducibility verification.
/// </summary>
[JsonPropertyName("inputs")]
public required GraphInputDigests Inputs { get; init; }
/// <summary>
/// Linked evidence IDs referenced by this graph.
/// </summary>
[JsonPropertyName("evidenceIds")]
public IReadOnlyList<string> EvidenceIds { get; init; } = [];
/// <summary>
/// Canonicalizer version used for serialization.
/// </summary>
[JsonPropertyName("canonVersion")]
public required string CanonVersion { get; init; }
/// <summary>
/// When the root was computed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("computedAt")]
public required DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Tool that computed the root.
/// </summary>
[JsonPropertyName("computedBy")]
public required string ComputedBy { get; init; }
/// <summary>
/// Tool version.
/// </summary>
[JsonPropertyName("computedByVersion")]
public required string ComputedByVersion { get; init; }
}
/// <summary>
/// Input digests for graph computation, enabling reproducibility verification.
/// </summary>
public sealed record GraphInputDigests
{
/// <summary>
/// Policy bundle digest used during graph computation.
/// </summary>
[JsonPropertyName("policyDigest")]
public required string PolicyDigest { get; init; }
/// <summary>
/// Feed snapshot digest used during graph computation.
/// </summary>
[JsonPropertyName("feedsDigest")]
public required string FeedsDigest { get; init; }
/// <summary>
/// Toolchain digest (scanner versions, analyzers, etc.).
/// </summary>
[JsonPropertyName("toolchainDigest")]
public required string ToolchainDigest { get; init; }
/// <summary>
/// Evaluation parameters digest (config, thresholds, etc.).
/// </summary>
[JsonPropertyName("paramsDigest")]
public required string ParamsDigest { get; init; }
}

View File

@@ -0,0 +1,107 @@
using StellaOps.Attestor.Envelope;
namespace StellaOps.Attestor.GraphRoot.Models;
/// <summary>
/// Result of creating a graph root attestation.
/// </summary>
public sealed record GraphRootAttestationResult
{
/// <summary>
/// Computed Merkle root hash in algorithm:hex format.
/// </summary>
public required string RootHash { get; init; }
/// <summary>
/// Signed DSSE envelope containing the in-toto statement.
/// </summary>
public required DsseEnvelope Envelope { get; init; }
/// <summary>
/// Rekor log index if the attestation was published to transparency log.
/// </summary>
public string? RekorLogIndex { get; init; }
/// <summary>
/// Number of nodes included in the root computation.
/// </summary>
public required int NodeCount { get; init; }
/// <summary>
/// Number of edges included in the root computation.
/// </summary>
public required int EdgeCount { get; init; }
}
/// <summary>
/// Result of verifying a graph root attestation.
/// </summary>
public sealed record GraphRootVerificationResult
{
/// <summary>
/// Whether the verification passed.
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Failure reason if verification failed.
/// </summary>
public string? FailureReason { get; init; }
/// <summary>
/// Expected root hash from the attestation.
/// </summary>
public string? ExpectedRoot { get; init; }
/// <summary>
/// Recomputed root hash from the provided graph data.
/// </summary>
public string? ComputedRoot { get; init; }
/// <summary>
/// Number of nodes verified.
/// </summary>
public int? NodeCount { get; init; }
/// <summary>
/// Number of edges verified.
/// </summary>
public int? EdgeCount { get; init; }
}
/// <summary>
/// Node data for verification.
/// </summary>
public sealed record GraphNodeData
{
/// <summary>
/// Node identifier.
/// </summary>
public required string NodeId { get; init; }
/// <summary>
/// Optional node content for extended verification.
/// </summary>
public string? Content { get; init; }
}
/// <summary>
/// Edge data for verification.
/// </summary>
public sealed record GraphEdgeData
{
/// <summary>
/// Edge identifier.
/// </summary>
public required string EdgeId { get; init; }
/// <summary>
/// Source node identifier.
/// </summary>
public string? SourceNodeId { get; init; }
/// <summary>
/// Target node identifier.
/// </summary>
public string? TargetNodeId { get; init; }
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
namespace StellaOps.Attestor.GraphRoot;
/// <summary>
/// Default SHA-256 Merkle root computer using binary tree construction.
/// </summary>
public sealed class Sha256MerkleRootComputer : IMerkleRootComputer
{
/// <inheritdoc />
public string Algorithm => "sha256";
/// <inheritdoc />
public byte[] ComputeRoot(IReadOnlyList<ReadOnlyMemory<byte>> leaves)
{
ArgumentNullException.ThrowIfNull(leaves);
if (leaves.Count == 0)
{
throw new ArgumentException("At least one leaf is required to compute a Merkle root.", nameof(leaves));
}
// Hash each leaf to create the initial level
var currentLevel = new List<byte[]>(leaves.Count);
foreach (var leaf in leaves)
{
currentLevel.Add(SHA256.HashData(leaf.Span));
}
// Build tree bottom-up
while (currentLevel.Count > 1)
{
var nextLevel = new List<byte[]>((currentLevel.Count + 1) / 2);
for (var i = 0; i < currentLevel.Count; i += 2)
{
var left = currentLevel[i];
// If odd number of nodes, duplicate the last one
var right = i + 1 < currentLevel.Count ? currentLevel[i + 1] : left;
// Combine and hash
var combined = new byte[left.Length + right.Length];
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
nextLevel.Add(SHA256.HashData(combined));
}
currentLevel = nextLevel;
}
return currentLevel[0];
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Attestor.GraphRoot</RootNamespace>
<Description>Graph root attestation service for creating and verifying DSSE attestations of Merkle graph roots.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Core\StellaOps.Evidence.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Attestor.Envelope;
using StellaOps.Attestor.GraphRoot.Models;
using Xunit;
namespace StellaOps.Attestor.GraphRoot.Tests;
public class GraphRootAttestorTests
{
private readonly Mock<IMerkleRootComputer> _merkleComputerMock;
private readonly EnvelopeSignatureService _signatureService;
private readonly GraphRootAttestor _attestor;
private readonly EnvelopeKey _testKey;
public GraphRootAttestorTests()
{
_merkleComputerMock = new Mock<IMerkleRootComputer>();
_merkleComputerMock.Setup(m => m.Algorithm).Returns("sha256");
_merkleComputerMock
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Returns(new byte[32]); // 32-byte hash
// Create a real test key for signing (need both private and public for Ed25519)
var privateKey = new byte[64]; // Ed25519 expanded private key is 64 bytes
var publicKey = new byte[32];
Random.Shared.NextBytes(privateKey);
Random.Shared.NextBytes(publicKey);
_testKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey, "test-key-id");
_signatureService = new EnvelopeSignatureService();
_attestor = new GraphRootAttestor(
_merkleComputerMock.Object,
_signatureService,
_ => _testKey,
NullLogger<GraphRootAttestor>.Instance);
}
[Fact]
public async Task AttestAsync_ValidRequest_ReturnsResult()
{
// Arrange
var request = CreateValidRequest();
// Act
var result = await _attestor.AttestAsync(request);
// Assert
Assert.NotNull(result);
Assert.NotNull(result.Envelope);
Assert.StartsWith("sha256:", result.RootHash);
Assert.Equal(3, result.NodeCount);
Assert.Equal(2, result.EdgeCount);
}
[Fact]
public async Task AttestAsync_SortsNodeIds()
{
// Arrange
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = new[] { "z-node", "a-node", "m-node" },
EdgeIds = Array.Empty<string>(),
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr",
ArtifactDigest = "sha256:a"
};
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
_merkleComputerMock
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
.Returns(new byte[32]);
// Act
await _attestor.AttestAsync(request);
// Assert
Assert.NotNull(capturedLeaves);
// First three leaves should be node IDs in sorted order
var firstNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[0].Span);
var secondNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[1].Span);
var thirdNodeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[2].Span);
Assert.Equal("a-node", firstNodeId);
Assert.Equal("m-node", secondNodeId);
Assert.Equal("z-node", thirdNodeId);
}
[Fact]
public async Task AttestAsync_SortsEdgeIds()
{
// Arrange
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = Array.Empty<string>(),
EdgeIds = new[] { "z-edge", "a-edge" },
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr",
ArtifactDigest = "sha256:a"
};
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
_merkleComputerMock
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
.Returns(new byte[32]);
// Act
await _attestor.AttestAsync(request);
// Assert
Assert.NotNull(capturedLeaves);
// First two leaves should be edge IDs in sorted order
var firstEdgeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[0].Span);
var secondEdgeId = System.Text.Encoding.UTF8.GetString(capturedLeaves[1].Span);
Assert.Equal("a-edge", firstEdgeId);
Assert.Equal("z-edge", secondEdgeId);
}
[Fact]
public async Task AttestAsync_IncludesInputDigestsInLeaves()
{
// Arrange
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = Array.Empty<string>(),
EdgeIds = Array.Empty<string>(),
PolicyDigest = "sha256:policy",
FeedsDigest = "sha256:feeds",
ToolchainDigest = "sha256:toolchain",
ParamsDigest = "sha256:params",
ArtifactDigest = "sha256:artifact"
};
IReadOnlyList<ReadOnlyMemory<byte>>? capturedLeaves = null;
_merkleComputerMock
.Setup(m => m.ComputeRoot(It.IsAny<IReadOnlyList<ReadOnlyMemory<byte>>>()))
.Callback<IReadOnlyList<ReadOnlyMemory<byte>>>(leaves => capturedLeaves = leaves)
.Returns(new byte[32]);
// Act
await _attestor.AttestAsync(request);
// Assert
Assert.NotNull(capturedLeaves);
Assert.Equal(4, capturedLeaves.Count); // Just the 4 input digests
var digestStrings = capturedLeaves.Select(l => System.Text.Encoding.UTF8.GetString(l.Span)).ToList();
Assert.Contains("sha256:policy", digestStrings);
Assert.Contains("sha256:feeds", digestStrings);
Assert.Contains("sha256:toolchain", digestStrings);
Assert.Contains("sha256:params", digestStrings);
}
[Fact]
public async Task AttestAsync_NullRequest_ThrowsArgumentNullException()
{
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() => _attestor.AttestAsync(null!));
}
[Fact]
public async Task AttestAsync_KeyResolverReturnsNull_ThrowsInvalidOperationException()
{
// Arrange
var attestorWithNullKey = new GraphRootAttestor(
_merkleComputerMock.Object,
_signatureService,
_ => null,
NullLogger<GraphRootAttestor>.Instance);
var request = CreateValidRequest();
// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => attestorWithNullKey.AttestAsync(request));
Assert.Contains("Unable to resolve signing key", ex.Message);
}
[Fact]
public async Task AttestAsync_CancellationRequested_ThrowsOperationCanceledException()
{
// Arrange
var request = CreateValidRequest();
var cts = new CancellationTokenSource();
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(() => _attestor.AttestAsync(request, cts.Token));
}
[Fact]
public async Task AttestAsync_ReturnsCorrectGraphType()
{
// Arrange
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.ReachabilityGraph,
NodeIds = new[] { "n1" },
EdgeIds = Array.Empty<string>(),
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr",
ArtifactDigest = "sha256:a"
};
// Act
var result = await _attestor.AttestAsync(request);
// Assert
var attestation = JsonSerializer.Deserialize<GraphRootAttestation>(result.Envelope.Payload.Span);
Assert.NotNull(attestation);
Assert.Equal("ReachabilityGraph", attestation.Predicate.GraphType);
}
private static GraphRootAttestationRequest CreateValidRequest()
{
return new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = new[] { "node-1", "node-2", "node-3" },
EdgeIds = new[] { "edge-1", "edge-2" },
PolicyDigest = "sha256:policy123",
FeedsDigest = "sha256:feeds456",
ToolchainDigest = "sha256:tools789",
ParamsDigest = "sha256:params012",
ArtifactDigest = "sha256:artifact345"
};
}
}

View File

@@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using StellaOps.Attestor.GraphRoot.Models;
using Xunit;
namespace StellaOps.Attestor.GraphRoot.Tests;
public class GraphRootModelsTests
{
[Fact]
public void GraphRootAttestationRequest_RequiredProperties_Set()
{
// Arrange & Act
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.DependencyGraph,
NodeIds = new[] { "node-1", "node-2" },
EdgeIds = new[] { "edge-1" },
PolicyDigest = "sha256:abc123",
FeedsDigest = "sha256:def456",
ToolchainDigest = "sha256:ghi789",
ParamsDigest = "sha256:jkl012",
ArtifactDigest = "sha256:artifact123"
};
// Assert
Assert.Equal(GraphType.DependencyGraph, request.GraphType);
Assert.Equal(2, request.NodeIds.Count);
Assert.Single(request.EdgeIds);
Assert.Equal("sha256:abc123", request.PolicyDigest);
Assert.False(request.PublishToRekor);
Assert.Null(request.SigningKeyId);
Assert.Empty(request.EvidenceIds);
}
[Fact]
public void GraphRootAttestationRequest_OptionalProperties_HaveDefaults()
{
// Arrange & Act
var request = new GraphRootAttestationRequest
{
GraphType = GraphType.CallGraph,
NodeIds = Array.Empty<string>(),
EdgeIds = Array.Empty<string>(),
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr",
ArtifactDigest = "sha256:a"
};
// Assert
Assert.False(request.PublishToRekor);
Assert.Null(request.SigningKeyId);
Assert.Empty(request.EvidenceIds);
}
[Fact]
public void GraphRootPredicate_RequiredProperties_Set()
{
// Arrange & Act
var predicate = new GraphRootPredicate
{
GraphType = "DependencyGraph",
RootHash = "sha256:abc123",
NodeCount = 10,
EdgeCount = 15,
NodeIds = new[] { "n1", "n2" },
EdgeIds = new[] { "e1" },
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr"
},
CanonVersion = "stella:canon:v1",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test",
ComputedByVersion = "1.0.0"
};
// Assert
Assert.Equal("DependencyGraph", predicate.GraphType);
Assert.Equal("sha256:abc123", predicate.RootHash);
Assert.Equal("sha256", predicate.RootAlgorithm);
Assert.Equal(10, predicate.NodeCount);
Assert.Equal(15, predicate.EdgeCount);
}
[Fact]
public void GraphRootAttestation_HasCorrectDefaults()
{
// Arrange & Act
var attestation = new GraphRootAttestation
{
Subject = new[]
{
new GraphRootSubject
{
Name = "sha256:root",
Digest = new Dictionary<string, string> { ["sha256"] = "root" }
}
},
Predicate = new GraphRootPredicate
{
GraphType = "Test",
RootHash = "sha256:root",
NodeCount = 1,
EdgeCount = 0,
NodeIds = Array.Empty<string>(),
EdgeIds = Array.Empty<string>(),
Inputs = new GraphInputDigests
{
PolicyDigest = "sha256:p",
FeedsDigest = "sha256:f",
ToolchainDigest = "sha256:t",
ParamsDigest = "sha256:pr"
},
CanonVersion = "v1",
ComputedAt = DateTimeOffset.UtcNow,
ComputedBy = "test",
ComputedByVersion = "1.0"
}
};
// Assert
Assert.Equal("https://in-toto.io/Statement/v1", attestation.Type);
Assert.Equal(GraphRootPredicateTypes.GraphRootV1, attestation.PredicateType);
}
[Fact]
public void GraphRootPredicateTypes_HasCorrectValue()
{
Assert.Equal("https://stella-ops.org/attestation/graph-root/v1", GraphRootPredicateTypes.GraphRootV1);
}
[Fact]
public void GraphRootVerificationResult_ValidResult()
{
// Arrange & Act
var result = new GraphRootVerificationResult
{
IsValid = true,
ExpectedRoot = "sha256:abc",
ComputedRoot = "sha256:abc",
NodeCount = 5,
EdgeCount = 3
};
// Assert
Assert.True(result.IsValid);
Assert.Null(result.FailureReason);
Assert.Equal("sha256:abc", result.ExpectedRoot);
Assert.Equal(5, result.NodeCount);
}
[Fact]
public void GraphRootVerificationResult_InvalidResult_HasReason()
{
// Arrange & Act
var result = new GraphRootVerificationResult
{
IsValid = false,
FailureReason = "Root mismatch",
ExpectedRoot = "sha256:abc",
ComputedRoot = "sha256:xyz"
};
// Assert
Assert.False(result.IsValid);
Assert.Equal("Root mismatch", result.FailureReason);
Assert.NotEqual(result.ExpectedRoot, result.ComputedRoot);
}
[Fact]
public void GraphNodeData_RequiredProperty()
{
// Arrange & Act
var node = new GraphNodeData
{
NodeId = "node-123",
Content = "optional content"
};
// Assert
Assert.Equal("node-123", node.NodeId);
Assert.Equal("optional content", node.Content);
}
[Fact]
public void GraphEdgeData_AllProperties()
{
// Arrange & Act
var edge = new GraphEdgeData
{
EdgeId = "edge-1",
SourceNodeId = "source-node",
TargetNodeId = "target-node"
};
// Assert
Assert.Equal("edge-1", edge.EdgeId);
Assert.Equal("source-node", edge.SourceNodeId);
Assert.Equal("target-node", edge.TargetNodeId);
}
[Fact]
public void GraphInputDigests_AllDigests()
{
// Arrange & Act
var digests = new GraphInputDigests
{
PolicyDigest = "sha256:policy",
FeedsDigest = "sha256:feeds",
ToolchainDigest = "sha256:toolchain",
ParamsDigest = "sha256:params"
};
// Assert
Assert.Equal("sha256:policy", digests.PolicyDigest);
Assert.Equal("sha256:feeds", digests.FeedsDigest);
Assert.Equal("sha256:toolchain", digests.ToolchainDigest);
Assert.Equal("sha256:params", digests.ParamsDigest);
}
}

View File

@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using Xunit;
namespace StellaOps.Attestor.GraphRoot.Tests;
public class Sha256MerkleRootComputerTests
{
private readonly Sha256MerkleRootComputer _computer = new();
[Fact]
public void Algorithm_ReturnsSha256()
{
Assert.Equal("sha256", _computer.Algorithm);
}
[Fact]
public void ComputeRoot_SingleLeaf_ReturnsHash()
{
// Arrange
var leaf = "test-node-1"u8.ToArray();
var leaves = new List<ReadOnlyMemory<byte>> { leaf };
// Act
var root = _computer.ComputeRoot(leaves);
// Assert
Assert.NotNull(root);
Assert.Equal(32, root.Length); // SHA-256 produces 32 bytes
}
[Fact]
public void ComputeRoot_TwoLeaves_CombinesCorrectly()
{
// Arrange
var leaf1 = "node-1"u8.ToArray();
var leaf2 = "node-2"u8.ToArray();
var leaves = new List<ReadOnlyMemory<byte>> { leaf1, leaf2 };
// Act
var root = _computer.ComputeRoot(leaves);
// Assert
Assert.NotNull(root);
Assert.Equal(32, root.Length);
}
[Fact]
public void ComputeRoot_OddLeaves_DuplicatesLast()
{
// Arrange
var leaves = new List<ReadOnlyMemory<byte>>
{
"node-1"u8.ToArray(),
"node-2"u8.ToArray(),
"node-3"u8.ToArray()
};
// Act
var root = _computer.ComputeRoot(leaves);
// Assert
Assert.NotNull(root);
Assert.Equal(32, root.Length);
}
[Fact]
public void ComputeRoot_Deterministic_SameInputSameOutput()
{
// Arrange
var leaves = new List<ReadOnlyMemory<byte>>
{
"node-a"u8.ToArray(),
"node-b"u8.ToArray(),
"edge-1"u8.ToArray(),
"edge-2"u8.ToArray()
};
// Act
var root1 = _computer.ComputeRoot(leaves);
var root2 = _computer.ComputeRoot(leaves);
// Assert
Assert.Equal(root1, root2);
}
[Fact]
public void ComputeRoot_DifferentInputs_DifferentOutputs()
{
// Arrange
var leaves1 = new List<ReadOnlyMemory<byte>> { "node-1"u8.ToArray() };
var leaves2 = new List<ReadOnlyMemory<byte>> { "node-2"u8.ToArray() };
// Act
var root1 = _computer.ComputeRoot(leaves1);
var root2 = _computer.ComputeRoot(leaves2);
// Assert
Assert.NotEqual(root1, root2);
}
[Fact]
public void ComputeRoot_OrderMatters()
{
// Arrange
var leavesAB = new List<ReadOnlyMemory<byte>>
{
"node-a"u8.ToArray(),
"node-b"u8.ToArray()
};
var leavesBA = new List<ReadOnlyMemory<byte>>
{
"node-b"u8.ToArray(),
"node-a"u8.ToArray()
};
// Act
var rootAB = _computer.ComputeRoot(leavesAB);
var rootBA = _computer.ComputeRoot(leavesBA);
// Assert - order should matter for Merkle trees
Assert.NotEqual(rootAB, rootBA);
}
[Fact]
public void ComputeRoot_EmptyList_ThrowsArgumentException()
{
// Arrange
var leaves = new List<ReadOnlyMemory<byte>>();
// Act & Assert
Assert.Throws<ArgumentException>(() => _computer.ComputeRoot(leaves));
}
[Fact]
public void ComputeRoot_NullInput_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => _computer.ComputeRoot(null!));
}
[Fact]
public void ComputeRoot_LargeTree_HandlesCorrectly()
{
// Arrange - create 100 leaves
var leaves = new List<ReadOnlyMemory<byte>>();
for (var i = 0; i < 100; i++)
{
leaves.Add(System.Text.Encoding.UTF8.GetBytes($"node-{i:D4}"));
}
// Act
var root = _computer.ComputeRoot(leaves);
// Assert
Assert.NotNull(root);
Assert.Equal(32, root.Length);
}
[Fact]
public void ComputeRoot_PowerOfTwo_HandlesCorrectly()
{
// Arrange - 8 leaves (power of 2)
var leaves = new List<ReadOnlyMemory<byte>>();
for (var i = 0; i < 8; i++)
{
leaves.Add(System.Text.Encoding.UTF8.GetBytes($"node-{i}"));
}
// Act
var root = _computer.ComputeRoot(leaves);
// Assert
Assert.NotNull(root);
Assert.Equal(32, root.Length);
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Attestor.GraphRoot.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.GraphRoot\StellaOps.Attestor.GraphRoot.csproj" />
</ItemGroup>
</Project>