Add determinism tests for verdict artifact generation and update SHA256 sums script
- Implemented comprehensive tests for verdict artifact generation to ensure deterministic outputs across various scenarios, including identical inputs, parallel execution, and change ordering. - Created helper methods for generating sample verdict inputs and computing canonical hashes. - Added tests to validate the stability of canonical hashes, proof spine ordering, and summary statistics. - Introduced a new PowerShell script to update SHA256 sums for files, ensuring accurate hash generation and file integrity checks.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalPayloadDeterminismTests.cs
|
||||
// Sprint: SPRINT_5100_0009_0006_signer_tests
|
||||
// Tasks: SIGNER-5100-001, SIGNER-5100-002, SIGNER-5100-003
|
||||
// Description: Model L0 tests for canonical payload and digest determinism
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Signer.Core;
|
||||
using StellaOps.Signer.Tests.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signer.Tests.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for canonical payload bytes and deterministic hash computation.
|
||||
/// Implements Model L0 test requirements:
|
||||
/// - SIGNER-5100-001: Canonical payload bytes snapshot tests for DSSE/in-toto envelopes
|
||||
/// - SIGNER-5100-002: Stable digest computation tests: same input -> same SHA-256 hash
|
||||
/// - SIGNER-5100-003: Determinism test: canonical payload hash stable across runs
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Category", "CanonicalPayload")]
|
||||
public sealed class CanonicalPayloadDeterminismTests
|
||||
{
|
||||
// SIGNER-5100-001: Canonical payload bytes snapshot tests
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_CanonicalBytes_MatchesExpectedSnapshot()
|
||||
{
|
||||
// Arrange - Create a deterministic in-toto statement
|
||||
var statement = CreateDeterministicInTotoStatement();
|
||||
|
||||
// Act
|
||||
var canonicalBytes = CanonJson.Canonicalize(statement);
|
||||
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
|
||||
|
||||
// Assert - Keys should be sorted, no whitespace
|
||||
canonicalJson.Should().NotContain("\n", "canonical JSON should have no newlines");
|
||||
canonicalJson.Should().NotContain(" ", "canonical JSON should have no extra spaces");
|
||||
|
||||
// Verify key ordering (alphabetical)
|
||||
var predicateIndex = canonicalJson.IndexOf("\"predicate\"", StringComparison.Ordinal);
|
||||
var predicateTypeIndex = canonicalJson.IndexOf("\"predicateType\"", StringComparison.Ordinal);
|
||||
var subjectIndex = canonicalJson.IndexOf("\"subject\"", StringComparison.Ordinal);
|
||||
var typeIndex = canonicalJson.IndexOf("\"_type\"", StringComparison.Ordinal);
|
||||
|
||||
typeIndex.Should().BeLessThan(predicateIndex, "_type should come before predicate (alphabetical)");
|
||||
predicateIndex.Should().BeLessThan(predicateTypeIndex, "predicate should come before predicateType");
|
||||
predicateTypeIndex.Should().BeLessThan(subjectIndex, "predicateType should come before subject");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_DifferentKeyOrder_ProducesSameCanonicalBytes()
|
||||
{
|
||||
// Arrange - Create same data with different key order in source JSON
|
||||
var json1 = """{"predicateType":"https://slsa.dev/provenance/v1","_type":"https://in-toto.io/Statement/v1","predicate":{"builder":{"id":"test"}},"subject":[{"name":"artifact","digest":{"sha256":"abc123"}}]}""";
|
||||
var json2 = """{"_type":"https://in-toto.io/Statement/v1","subject":[{"name":"artifact","digest":{"sha256":"abc123"}}],"predicateType":"https://slsa.dev/provenance/v1","predicate":{"builder":{"id":"test"}}}""";
|
||||
|
||||
// Act
|
||||
var bytes1 = CanonJson.CanonicalizeParsedJson(Encoding.UTF8.GetBytes(json1));
|
||||
var bytes2 = CanonJson.CanonicalizeParsedJson(Encoding.UTF8.GetBytes(json2));
|
||||
|
||||
// Assert
|
||||
bytes1.Should().BeEquivalentTo(bytes2, "canonical bytes should be identical regardless of input key order");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_CanonicalBytes_PayloadTypePreserved()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = CreateDeterministicDsseEnvelope();
|
||||
|
||||
// Act
|
||||
var canonicalBytes = CanonJson.Canonicalize(envelope);
|
||||
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
|
||||
|
||||
// Assert
|
||||
canonicalJson.Should().Contain("\"payloadType\":\"application/vnd.in-toto+json\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DsseEnvelope_CanonicalBytes_SignaturesArrayPreserved()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = CreateDeterministicDsseEnvelope();
|
||||
|
||||
// Act
|
||||
var canonicalBytes = CanonJson.Canonicalize(envelope);
|
||||
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
|
||||
|
||||
// Assert
|
||||
canonicalJson.Should().Contain("\"signatures\":[");
|
||||
canonicalJson.Should().Contain("\"keyId\":\"test-key-id\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InTotoStatement_MultipleSubjects_CanonicalOrderPreserved()
|
||||
{
|
||||
// Arrange - Statement with multiple subjects
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = "https://slsa.dev/provenance/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "artifact-b", digest = new { sha256 = "def456" } },
|
||||
new { name = "artifact-a", digest = new { sha256 = "abc123" } },
|
||||
new { name = "artifact-c", digest = new { sha256 = "ghi789" } }
|
||||
},
|
||||
predicate = new { builder = new { id = "test-builder" } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var canonicalBytes = CanonJson.Canonicalize(statement);
|
||||
var canonicalJson = Encoding.UTF8.GetString(canonicalBytes);
|
||||
|
||||
// Assert - Array order should be preserved (not sorted)
|
||||
var indexB = canonicalJson.IndexOf("artifact-b", StringComparison.Ordinal);
|
||||
var indexA = canonicalJson.IndexOf("artifact-a", StringComparison.Ordinal);
|
||||
var indexC = canonicalJson.IndexOf("artifact-c", StringComparison.Ordinal);
|
||||
|
||||
indexB.Should().BeLessThan(indexA, "array order should be preserved");
|
||||
indexA.Should().BeLessThan(indexC, "array order should be preserved");
|
||||
}
|
||||
|
||||
// SIGNER-5100-002: Stable digest computation tests
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_SameInput_ProducesIdenticalHash()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateDeterministicInTotoStatement();
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "same input should produce same hash");
|
||||
hash1.Length.Should().Be(64, "SHA-256 hash should be 64 hex characters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_LowercaseHex_Format()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateDeterministicInTotoStatement();
|
||||
|
||||
// Act
|
||||
var hash = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash.Should().MatchRegex("^[0-9a-f]{64}$", "hash should be lowercase hex");
|
||||
hash.Should().NotMatchRegex("[A-F]", "hash should not contain uppercase letters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_Prefixed_HasCorrectPrefix()
|
||||
{
|
||||
// Arrange
|
||||
var statement = CreateDeterministicInTotoStatement();
|
||||
|
||||
// Act
|
||||
var prefixedHash = CanonJson.HashPrefixed(statement);
|
||||
|
||||
// Assert
|
||||
prefixedHash.Should().StartWith("sha256:");
|
||||
prefixedHash.Length.Should().Be(71, "sha256: prefix (7) + hash (64) = 71");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_DifferentInputs_ProduceDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var statement1 = new { type = "test", value = "input1" };
|
||||
var statement2 = new { type = "test", value = "input2" };
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement1);
|
||||
var hash2 = CanonJson.Hash(statement2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2, "different inputs should produce different hashes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_EmptyObject_ProducesConsistentHash()
|
||||
{
|
||||
// Arrange
|
||||
var emptyObject = new { };
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(emptyObject);
|
||||
var hash2 = CanonJson.Hash(emptyObject);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
hash1.Should().Be("44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"empty object {} should have deterministic hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha256Hash_NestedObject_ProducesConsistentHash()
|
||||
{
|
||||
// Arrange
|
||||
var nested = new
|
||||
{
|
||||
level1 = new
|
||||
{
|
||||
level2 = new
|
||||
{
|
||||
level3 = new
|
||||
{
|
||||
value = "deep"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(nested);
|
||||
var hash2 = CanonJson.Hash(nested);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "nested objects should produce consistent hashes");
|
||||
}
|
||||
|
||||
// SIGNER-5100-003: Determinism test - hash stable across runs
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_HashStableAcrossMultipleRuns()
|
||||
{
|
||||
// Arrange - Create identical statements multiple times
|
||||
var hashes = new HashSet<string>();
|
||||
|
||||
// Act - Generate hash 100 times
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var statement = CreateDeterministicInTotoStatement();
|
||||
var hash = CanonJson.Hash(statement);
|
||||
hashes.Add(hash);
|
||||
}
|
||||
|
||||
// Assert - All hashes should be identical
|
||||
hashes.Should().HaveCount(1, "all 100 runs should produce the same hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_StableWithTimestampField()
|
||||
{
|
||||
// Arrange - Fixed timestamp for determinism
|
||||
var fixedTimestamp = DeterministicTestData.FixedTimestamp;
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
timestamp = fixedTimestamp.ToString("O"),
|
||||
subject = new[] { new { name = "test", digest = new { sha256 = "abc" } } }
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "fixed timestamp should produce stable hash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_DeterministicWithSigningSubjects()
|
||||
{
|
||||
// Arrange - Use DeterministicTestData for subjects
|
||||
var subjects = DeterministicTestData.CreateDefaultSubjects();
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(subjects);
|
||||
var hash2 = CanonJson.Hash(subjects);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "signing subjects should hash deterministically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_DeterministicWithMultipleSubjects()
|
||||
{
|
||||
// Arrange
|
||||
var subjects = DeterministicTestData.CreateMultipleSubjects();
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(subjects);
|
||||
var hash2 = CanonJson.Hash(subjects);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "multiple subjects should hash deterministically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_UnicodeCharacters_HashDeterministically()
|
||||
{
|
||||
// Arrange - Statement with Unicode characters
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new { name = "artifact-\u4e2d\u6587", digest = new { sha256 = "abc123" } }, // Chinese characters
|
||||
new { name = "artifact-\u00e9\u00e8\u00ea", digest = new { sha256 = "def456" } } // French accents
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "Unicode characters should hash deterministically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_NumbersPreserved_HashDeterministically()
|
||||
{
|
||||
// Arrange - Statement with various number types
|
||||
var statement = new
|
||||
{
|
||||
integer = 42,
|
||||
negative = -17,
|
||||
floating = 3.14159,
|
||||
scientific = 1.5e-10,
|
||||
large = 9007199254740992L
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "numbers should hash deterministically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalPayload_BooleanAndNull_HashDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new
|
||||
{
|
||||
active = true,
|
||||
disabled = false,
|
||||
missing = (string?)null
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2, "boolean and null values should hash deterministically");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DssePayload_Base64Url_DeterministicEncoding()
|
||||
{
|
||||
// Arrange - Create statement that would have base64url special chars
|
||||
var statement = new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
binary = Convert.ToBase64String(new byte[] { 0xFB, 0xFF, 0xFE })
|
||||
};
|
||||
|
||||
// Act
|
||||
var hash1 = CanonJson.Hash(statement);
|
||||
var hash2 = CanonJson.Hash(statement);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private static object CreateDeterministicInTotoStatement()
|
||||
{
|
||||
return new
|
||||
{
|
||||
_type = "https://in-toto.io/Statement/v1",
|
||||
predicateType = "https://slsa.dev/provenance/v1",
|
||||
subject = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = DeterministicTestData.DefaultSubjectName,
|
||||
digest = new { sha256 = DeterministicTestData.DefaultSubjectDigest }
|
||||
}
|
||||
},
|
||||
predicate = new
|
||||
{
|
||||
builder = new { id = "https://github.com/stellaops/scanner-action@v2" },
|
||||
buildType = "https://slsa.dev/container-build/v0.1",
|
||||
invocation = new
|
||||
{
|
||||
configSource = new { uri = "git+https://github.com/stellaops/example@refs/heads/main" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static DsseEnvelope CreateDeterministicDsseEnvelope()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(CanonJson.Serialize(CreateDeterministicInTotoStatement()));
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
|
||||
return new DsseEnvelope(
|
||||
Payload: payloadBase64,
|
||||
PayloadType: "application/vnd.in-toto+json",
|
||||
Signatures: new[]
|
||||
{
|
||||
new DsseSignature(
|
||||
Signature: "MEUCIQD_test_signature_base64url",
|
||||
KeyId: "test-key-id")
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,5 +27,6 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user