334 lines
13 KiB
C#
334 lines
13 KiB
C#
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Org.BouncyCastle.Crypto.Generators;
|
|
using Org.BouncyCastle.Crypto.Parameters;
|
|
using Org.BouncyCastle.Security;
|
|
using StellaOps.Attestor.Envelope;
|
|
using StellaOps.Canonical.Json;
|
|
using StellaOps.Scanner.Reachability.Witnesses;
|
|
using Xunit;
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.Reachability.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="WitnessDsseSigner"/>.
|
|
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
|
|
/// Golden fixture tests for DSSE sign/verify.
|
|
/// </summary>
|
|
public class WitnessDsseSignerTests
|
|
{
|
|
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
|
|
/// <summary>
|
|
/// Creates a deterministic Ed25519 key pair for testing.
|
|
/// </summary>
|
|
private static (byte[] privateKey, byte[] publicKey) CreateTestKeyPair()
|
|
{
|
|
// Use a fixed seed for deterministic tests
|
|
var generator = new Ed25519KeyPairGenerator();
|
|
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom(new FixedRandomGenerator())));
|
|
var keyPair = generator.GenerateKeyPair();
|
|
|
|
var privateParams = (Ed25519PrivateKeyParameters)keyPair.Private;
|
|
var publicParams = (Ed25519PublicKeyParameters)keyPair.Public;
|
|
|
|
// Ed25519 private key = 32-byte seed + 32-byte public key
|
|
var privateKey = new byte[64];
|
|
privateParams.Encode(privateKey, 0);
|
|
var publicKey = publicParams.GetEncoded();
|
|
|
|
// Append public key to make 64-byte expanded form
|
|
Array.Copy(publicKey, 0, privateKey, 32, 32);
|
|
|
|
return (privateKey, publicKey);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void SignWitness_WithValidKey_ReturnsSuccess()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
// Act
|
|
var result = signer.SignWitness(witness, key, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(result.IsSuccess, result.Error);
|
|
Assert.NotNull(result.Envelope);
|
|
Assert.Equal(WitnessSchema.DssePayloadType, result.Envelope.PayloadType);
|
|
Assert.Single(result.Envelope.Signatures);
|
|
Assert.NotEmpty(result.PayloadBytes!);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void VerifyWitness_WithValidSignature_ReturnsSuccess()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
// Sign the witness
|
|
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
|
Assert.True(signResult.IsSuccess, signResult.Error);
|
|
|
|
// Create public key for verification
|
|
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
|
|
|
// Act
|
|
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(verifyResult.IsSuccess, verifyResult.Error);
|
|
Assert.NotNull(verifyResult.Witness);
|
|
Assert.Equal(witness.WitnessId, verifyResult.Witness.WitnessId);
|
|
Assert.Equal(witness.Vuln.Id, verifyResult.Witness.Vuln.Id);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void VerifyWitness_WithWrongKey_ReturnsFails()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
// Sign the witness
|
|
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
|
Assert.True(signResult.IsSuccess, signResult.Error);
|
|
|
|
// Create a different key for verification (different keyId)
|
|
var generator = new Ed25519KeyPairGenerator();
|
|
generator.Init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
|
var wrongKeyPair = generator.GenerateKeyPair();
|
|
var wrongPublicKey = ((Ed25519PublicKeyParameters)wrongKeyPair.Public).GetEncoded();
|
|
var wrongKey = EnvelopeKey.CreateEd25519Verifier(wrongPublicKey);
|
|
|
|
// Act - verify with wrong key (keyId won't match)
|
|
var verifyResult = signer.VerifyWitness(signResult.Envelope!, wrongKey, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(verifyResult.IsSuccess);
|
|
Assert.Contains("No signature found for key ID", verifyResult.Error);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void SignWitness_ProducesDeterministicPayload()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var key = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
// Act
|
|
var result1 = signer.SignWitness(witness, key, TestCancellationToken);
|
|
var result2 = signer.SignWitness(witness, key, TestCancellationToken);
|
|
|
|
// Assert: payloads should be identical (deterministic serialization)
|
|
Assert.True(result1.IsSuccess);
|
|
Assert.True(result2.IsSuccess);
|
|
Assert.Equal(result1.PayloadBytes, result2.PayloadBytes);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void SignWitness_UsesCanonicalPayloadAndDssePae()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner(new EnvelopeSignatureService());
|
|
|
|
var options = new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
Encoder = JavaScriptEncoder.Default
|
|
};
|
|
|
|
// Act
|
|
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(signResult.IsSuccess, signResult.Error);
|
|
Assert.NotNull(signResult.Envelope);
|
|
Assert.NotNull(signResult.PayloadBytes);
|
|
|
|
var payloadBytes = signResult.PayloadBytes!;
|
|
var expectedPayload = CanonJson.Canonicalize(witness, options);
|
|
Assert.Equal(expectedPayload, payloadBytes);
|
|
|
|
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
|
var signatureBytes = Convert.FromBase64String(signResult.Envelope!.Signatures[0].Signature);
|
|
var envelopeSignature = new EnvelopeSignature(signingKey.KeyId, signingKey.AlgorithmId, signatureBytes);
|
|
var verifyResult = new EnvelopeSignatureService().VerifyDsse(
|
|
WitnessSchema.DssePayloadType,
|
|
payloadBytes,
|
|
envelopeSignature,
|
|
verifyKey,
|
|
TestCancellationToken);
|
|
|
|
Assert.True(verifyResult.IsSuccess);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void VerifyWitness_WithInvalidPayloadType_ReturnsFails()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
|
Assert.True(signResult.IsSuccess);
|
|
|
|
// Create envelope with wrong payload type
|
|
var wrongEnvelope = new DsseEnvelope(
|
|
payloadType: "application/wrong-type",
|
|
payload: signResult.Envelope!.Payload,
|
|
signatures: signResult.Envelope.Signatures);
|
|
|
|
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
|
|
|
// Act
|
|
var verifyResult = signer.VerifyWitness(wrongEnvelope, verifyKey, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.False(verifyResult.IsSuccess);
|
|
Assert.Contains("Invalid payload type", verifyResult.Error);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void RoundTrip_PreservesAllWitnessFields()
|
|
{
|
|
// Arrange
|
|
var witness = CreateTestWitness();
|
|
var (privateKey, publicKey) = CreateTestKeyPair();
|
|
var signingKey = EnvelopeKey.CreateEd25519Signer(privateKey, publicKey);
|
|
var verifyKey = EnvelopeKey.CreateEd25519Verifier(publicKey);
|
|
var signer = new WitnessDsseSigner();
|
|
|
|
// Act
|
|
var signResult = signer.SignWitness(witness, signingKey, TestCancellationToken);
|
|
var verifyResult = signer.VerifyWitness(signResult.Envelope!, verifyKey, TestCancellationToken);
|
|
|
|
// Assert
|
|
Assert.True(signResult.IsSuccess);
|
|
Assert.True(verifyResult.IsSuccess);
|
|
|
|
var roundTripped = verifyResult.Witness!;
|
|
Assert.Equal(witness.WitnessSchema, roundTripped.WitnessSchema);
|
|
Assert.Equal(witness.WitnessId, roundTripped.WitnessId);
|
|
Assert.Equal(witness.Artifact.SbomDigest, roundTripped.Artifact.SbomDigest);
|
|
Assert.Equal(witness.Artifact.ComponentPurl, roundTripped.Artifact.ComponentPurl);
|
|
Assert.Equal(witness.Vuln.Id, roundTripped.Vuln.Id);
|
|
Assert.Equal(witness.Vuln.Source, roundTripped.Vuln.Source);
|
|
Assert.Equal(witness.Entrypoint.Kind, roundTripped.Entrypoint.Kind);
|
|
Assert.Equal(witness.Entrypoint.Name, roundTripped.Entrypoint.Name);
|
|
Assert.Equal(witness.Entrypoint.SymbolId, roundTripped.Entrypoint.SymbolId);
|
|
Assert.Equal(witness.Sink.Symbol, roundTripped.Sink.Symbol);
|
|
Assert.Equal(witness.Sink.SymbolId, roundTripped.Sink.SymbolId);
|
|
Assert.Equal(witness.Sink.SinkType, roundTripped.Sink.SinkType);
|
|
Assert.Equal(witness.Path.Count, roundTripped.Path.Count);
|
|
Assert.Equal(witness.Evidence.CallgraphDigest, roundTripped.Evidence.CallgraphDigest);
|
|
}
|
|
|
|
private static PathWitness CreateTestWitness()
|
|
{
|
|
return new PathWitness
|
|
{
|
|
WitnessId = "wit:sha256:abc123def456",
|
|
Artifact = new WitnessArtifact
|
|
{
|
|
SbomDigest = "sha256:sbom123456",
|
|
ComponentPurl = "pkg:nuget/Newtonsoft.Json@12.0.3"
|
|
},
|
|
Vuln = new WitnessVuln
|
|
{
|
|
Id = "CVE-2024-12345",
|
|
Source = "NVD",
|
|
AffectedRange = "<=12.0.3"
|
|
},
|
|
Entrypoint = new WitnessEntrypoint
|
|
{
|
|
Kind = "http",
|
|
Name = "GET /api/users",
|
|
SymbolId = "sym:entry:001"
|
|
},
|
|
Path = new List<PathStep>
|
|
{
|
|
new PathStep
|
|
{
|
|
Symbol = "UserController.GetUsers",
|
|
SymbolId = "sym:step:001",
|
|
File = "Controllers/UserController.cs",
|
|
Line = 42
|
|
},
|
|
new PathStep
|
|
{
|
|
Symbol = "JsonConvert.DeserializeObject",
|
|
SymbolId = "sym:step:002",
|
|
File = null,
|
|
Line = null
|
|
}
|
|
},
|
|
Sink = new WitnessSink
|
|
{
|
|
Symbol = "JsonConvert.DeserializeObject<T>",
|
|
SymbolId = "sym:sink:001",
|
|
SinkType = "deserialization"
|
|
},
|
|
Evidence = new WitnessEvidence
|
|
{
|
|
CallgraphDigest = "blake3:graph123456",
|
|
SurfaceDigest = "sha256:surface789",
|
|
BuildId = "build:xyz123"
|
|
},
|
|
ObservedAt = new DateTimeOffset(2025, 12, 19, 12, 0, 0, TimeSpan.Zero)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fixed random generator for deterministic key generation in tests.
|
|
/// </summary>
|
|
private sealed class FixedRandomGenerator : Org.BouncyCastle.Crypto.Prng.IRandomGenerator
|
|
{
|
|
private byte _value = 0x42;
|
|
|
|
public void AddSeedMaterial(byte[] seed) { }
|
|
public void AddSeedMaterial(ReadOnlySpan<byte> seed) { }
|
|
public void AddSeedMaterial(long seed) { }
|
|
public void NextBytes(byte[] bytes) => NextBytes(bytes, 0, bytes.Length);
|
|
public void NextBytes(byte[] bytes, int start, int len)
|
|
{
|
|
for (int i = start; i < start + len; i++)
|
|
{
|
|
bytes[i] = _value++;
|
|
}
|
|
}
|
|
public void NextBytes(Span<byte> bytes)
|
|
{
|
|
for (int i = 0; i < bytes.Length; i++)
|
|
{
|
|
bytes[i] = _value++;
|
|
}
|
|
}
|
|
}
|
|
}
|