save progress
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using StellaOps.Attestation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestation.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeExtensionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FromBase64_RoundTripsPayloadAndSignature()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var signatureBase64 = Convert.ToBase64String(Convert.FromHexString("deadbeef"));
|
||||
|
||||
var envelope = DsseEnvelopeExtensions.FromBase64(
|
||||
"example/type",
|
||||
payloadBase64,
|
||||
new (string? KeyId, string SignatureBase64)[] { (KeyId: "key-1", SignatureBase64: signatureBase64) });
|
||||
|
||||
Assert.Equal(payloadBase64, envelope.GetPayloadBase64());
|
||||
Assert.Single(envelope.Signatures);
|
||||
Assert.Equal("key-1", envelope.Signatures[0].KeyId);
|
||||
Assert.Equal(signatureBase64, envelope.Signatures[0].Signature);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FromBase64_ThrowsOnInvalidSignatureBase64()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
DsseEnvelopeExtensions.FromBase64(
|
||||
"example/type",
|
||||
payloadBase64,
|
||||
new (string? KeyId, string SignatureBase64)[] { (KeyId: "key-1", SignatureBase64: "not-base64") }));
|
||||
|
||||
Assert.Contains("base64", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,30 @@ public class DsseHelperTests
|
||||
{
|
||||
private sealed class FakeSigner : IAuthoritySigner
|
||||
{
|
||||
public byte[]? LastPayload { get; private set; }
|
||||
|
||||
public Task<string> GetKeyIdAsync(System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("fake-key");
|
||||
|
||||
public Task<byte[]> SignAsync(ReadOnlyMemory<byte> paePayload, System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Convert.FromHexString("deadbeef"));
|
||||
{
|
||||
LastPayload = paePayload.ToArray();
|
||||
return Task.FromResult(Convert.FromHexString("deadbeef"));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WrapAsync_ProducesDsseEnvelope()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var stmt = new InTotoStatement(
|
||||
Type: "https://in-toto.io/Statement/v1",
|
||||
Subject: new[] { new Subject("demo", new System.Collections.Generic.Dictionary<string, string> { { "sha256", "abcd" } }) },
|
||||
PredicateType: "demo/predicate",
|
||||
Predicate: new { hello = "world" });
|
||||
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, new FakeSigner());
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, signer, TestContext.Current.CancellationToken);
|
||||
|
||||
envelope.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
|
||||
var roundtrip = JsonSerializer.Deserialize<InTotoStatement>(envelope.Payload.Span);
|
||||
@@ -51,9 +57,27 @@ public class DsseHelperTests
|
||||
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payload);
|
||||
|
||||
// Verify PAE contains expected components (payload type and payload)
|
||||
var paeString = Encoding.UTF8.GetString(pae);
|
||||
paeString.Should().Contain(payloadType);
|
||||
paeString.Should().Contain("{}");
|
||||
var expected = Encoding.UTF8.GetBytes($"DSSEv1 {payloadType.Length} {payloadType} {payload.Length} {{}}");
|
||||
pae.Should().Equal(expected);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WrapAsync_UsesDefaultPayloadTypeWhenMissing()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var stmt = new InTotoStatement(
|
||||
Type: "",
|
||||
Subject: new[] { new Subject("demo", new System.Collections.Generic.Dictionary<string, string> { { "sha256", "abcd" } }) },
|
||||
PredicateType: "demo/predicate",
|
||||
Predicate: new { hello = "world" });
|
||||
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, signer, TestContext.Current.CancellationToken);
|
||||
|
||||
envelope.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
|
||||
signer.LastPayload.Should().NotBeNull();
|
||||
var expectedPayload = JsonSerializer.SerializeToUtf8Bytes(stmt, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = false });
|
||||
var expectedPae = DsseHelper.PreAuthenticationEncoding(envelope.PayloadType, expectedPayload);
|
||||
signer.LastPayload!.Should().Equal(expectedPae);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,27 @@ public static class DsseEnvelopeExtensions
|
||||
ArgumentNullException.ThrowIfNull(signatures);
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var dsseSignatures = signatures.Select(s => new DsseSignature(s.SignatureBase64, s.KeyId));
|
||||
var dsseSignatures = new List<DsseSignature>();
|
||||
var index = 0;
|
||||
foreach (var signature in signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature.SignatureBase64))
|
||||
{
|
||||
throw new ArgumentException("Signature must be provided.", nameof(signatures));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(signature.SignatureBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException($"Signature at index {index} must be base64-encoded.", nameof(signatures), ex);
|
||||
}
|
||||
|
||||
dsseSignatures.Add(new DsseSignature(signature.SignatureBase64, signature.KeyId));
|
||||
index++;
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payloadBytes, dsseSignatures);
|
||||
}
|
||||
|
||||
@@ -10,33 +10,42 @@ namespace StellaOps.Attestation;
|
||||
|
||||
public static class DsseHelper
|
||||
{
|
||||
private const string DefaultPayloadType = "https://in-toto.io/Statement/v1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static byte[] PreAuthenticationEncoding(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
static byte[] Cat(params byte[][] parts)
|
||||
{
|
||||
var len = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
len += part.Length;
|
||||
}
|
||||
|
||||
var buf = new byte[len];
|
||||
var offset = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
Buffer.BlockCopy(part, 0, buf, offset, part.Length);
|
||||
offset += part.Length;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
var header = Encoding.UTF8.GetBytes("DSSEv1");
|
||||
var pt = Encoding.UTF8.GetBytes(payloadType);
|
||||
var lenPt = Encoding.UTF8.GetBytes(pt.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var lenPayload = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var space = Encoding.UTF8.GetBytes(" ");
|
||||
|
||||
return Cat(header, space, lenPt, space, pt, space, lenPayload, space, payload.ToArray());
|
||||
var totalLength = header.Length + space.Length + lenPt.Length + space.Length + pt.Length +
|
||||
space.Length + lenPayload.Length + space.Length + payload.Length;
|
||||
var buffer = new byte[totalLength];
|
||||
var offset = 0;
|
||||
|
||||
static void CopyBytes(byte[] source, byte[] destination, ref int index)
|
||||
{
|
||||
Buffer.BlockCopy(source, 0, destination, index, source.Length);
|
||||
index += source.Length;
|
||||
}
|
||||
|
||||
CopyBytes(header, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(lenPt, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(pt, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(lenPayload, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static async Task<DsseEnvelope> WrapAsync(InTotoStatement statement, IAuthoritySigner signer, CancellationToken cancellationToken = default)
|
||||
@@ -44,13 +53,13 @@ public static class DsseHelper
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
ArgumentNullException.ThrowIfNull(signer);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, statement.GetType());
|
||||
var pae = PreAuthenticationEncoding(statement.Type ?? string.Empty, payloadBytes);
|
||||
var payloadType = string.IsNullOrWhiteSpace(statement.Type) ? DefaultPayloadType : statement.Type;
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, SerializerOptions);
|
||||
var pae = PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
var signatureBytes = await signer.SignAsync(pae, cancellationToken).ConfigureAwait(false);
|
||||
var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dsseSignature = DsseSignature.FromBytes(signatureBytes, keyId);
|
||||
var payloadType = statement.Type ?? "https://in-toto.io/Statement/v1";
|
||||
return new DsseEnvelope(payloadType, payloadBytes, new[] { dsseSignature });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0043-M | DONE | Maintainability audit for StellaOps.Attestation. |
|
||||
| AUDIT-0043-T | DONE | Test coverage audit for StellaOps.Attestation. |
|
||||
| AUDIT-0043-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0043-A | DONE | Applied DSSE payloadType alignment and base64 validation with tests. |
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Fluent builder for constructing Sigstore bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using StellaOps.Attestor.Bundle.Models;
|
||||
using StellaOps.Attestor.Bundle.Serialization;
|
||||
|
||||
@@ -38,11 +39,24 @@ public sealed class SigstoreBundleBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(payload);
|
||||
ArgumentNullException.ThrowIfNull(signatures);
|
||||
|
||||
EnsureBase64(payload, nameof(payload));
|
||||
var signatureList = signatures.ToList();
|
||||
if (signatureList.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one signature is required.", nameof(signatures));
|
||||
}
|
||||
|
||||
foreach (var signature in signatureList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
EnsureBase64(signature.Sig, nameof(signatures));
|
||||
}
|
||||
|
||||
_dsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = payload,
|
||||
Signatures = signatures.ToList()
|
||||
Signatures = signatureList
|
||||
};
|
||||
|
||||
return this;
|
||||
@@ -83,6 +97,7 @@ public sealed class SigstoreBundleBuilder
|
||||
public SigstoreBundleBuilder WithCertificateBase64(string base64DerCertificate)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(base64DerCertificate);
|
||||
EnsureBase64(base64DerCertificate, nameof(base64DerCertificate));
|
||||
_certificate = new CertificateInfo
|
||||
{
|
||||
RawBytes = base64DerCertificate
|
||||
@@ -140,6 +155,13 @@ public sealed class SigstoreBundleBuilder
|
||||
string version = "0.0.1",
|
||||
InclusionProof? inclusionProof = null)
|
||||
{
|
||||
EnsureNumber(logIndex, nameof(logIndex));
|
||||
EnsureNumber(integratedTime, nameof(integratedTime));
|
||||
EnsureBase64(logIdKeyId, nameof(logIdKeyId));
|
||||
EnsureBase64(canonicalizedBody, nameof(canonicalizedBody));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var entry = new TransparencyLogEntry
|
||||
{
|
||||
LogIndex = logIndex,
|
||||
@@ -260,4 +282,29 @@ public sealed class SigstoreBundleBuilder
|
||||
var bundle = Build();
|
||||
return SigstoreBundleSerializer.SerializeToUtf8Bytes(bundle);
|
||||
}
|
||||
|
||||
private static void EnsureBase64(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be provided.", paramName);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException("Value must be base64-encoded.", paramName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureNumber(string value, string paramName)
|
||||
{
|
||||
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new ArgumentException("Value must be an integer string.", paramName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,24 @@ public static class SigstoreBundleSerializer
|
||||
throw new SigstoreBundleException("Bundle verificationMaterial is required");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.Certificate is null &&
|
||||
bundle.VerificationMaterial.PublicKey is null)
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle verificationMaterial must include certificate or publicKey");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.Certificate is not null &&
|
||||
string.IsNullOrWhiteSpace(bundle.VerificationMaterial.Certificate.RawBytes))
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle certificate rawBytes is required");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.PublicKey is not null &&
|
||||
string.IsNullOrWhiteSpace(bundle.VerificationMaterial.PublicKey.RawBytes))
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle publicKey rawBytes is required");
|
||||
}
|
||||
|
||||
if (bundle.DsseEnvelope is null)
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle dsseEnvelope is required");
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0045-M | DONE | Maintainability audit for StellaOps.Attestor.Bundle. |
|
||||
| AUDIT-0045-T | DONE | Test coverage audit for StellaOps.Attestor.Bundle. |
|
||||
| AUDIT-0045-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0045-A | DONE | Applied bundle validation hardening, verifier fixes, and test coverage. |
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
@@ -287,7 +288,22 @@ public sealed class SigstoreBundleVerifier
|
||||
}
|
||||
|
||||
// Construct PAE (Pre-Authentication Encoding) for DSSE
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
|
||||
Message = "DSSE envelope payload is not valid base64",
|
||||
Exception = ex
|
||||
});
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
var paeMessage = ConstructPae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Verify at least one signature
|
||||
@@ -304,6 +320,15 @@ public sealed class SigstoreBundleVerifier
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
|
||||
Message = "DSSE signature is not valid base64",
|
||||
Exception = ex
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogDebug(ex, "Signature verification attempt failed");
|
||||
@@ -320,7 +345,9 @@ public sealed class SigstoreBundleVerifier
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
return new VerificationCheckResult(true, CheckResult.Passed, errors);
|
||||
return errors.Count == 0
|
||||
? new VerificationCheckResult(true, CheckResult.Passed, errors)
|
||||
: new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
private static byte[] ConstructPae(string payloadType, byte[] payload)
|
||||
@@ -331,8 +358,8 @@ public sealed class SigstoreBundleVerifier
|
||||
const byte Space = 0x20;
|
||||
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
|
||||
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
|
||||
@@ -426,23 +453,29 @@ public sealed class SigstoreBundleVerifier
|
||||
await Task.CompletedTask; // Async for future extensibility
|
||||
|
||||
var errors = new List<BundleVerificationError>();
|
||||
var sawProof = false;
|
||||
|
||||
foreach (var entry in tlogEntries)
|
||||
{
|
||||
if (entry.InclusionProof is null)
|
||||
{
|
||||
// Skip entries without inclusion proofs
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Message = $"Missing inclusion proof for log index {entry.LogIndex}"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var valid = VerifyMerkleInclusionProof(entry);
|
||||
sawProof = true;
|
||||
var valid = VerifyMerkleInclusionProof(entry, out var errorCode);
|
||||
if (!valid)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Code = errorCode,
|
||||
Message = $"Merkle inclusion proof verification failed for log index {entry.LogIndex}"
|
||||
});
|
||||
}
|
||||
@@ -458,6 +491,15 @@ public sealed class SigstoreBundleVerifier
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawProof && tlogEntries.Count > 0)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Message = "No inclusion proofs present in transparency log entries"
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
@@ -466,8 +508,10 @@ public sealed class SigstoreBundleVerifier
|
||||
return new VerificationCheckResult(true, CheckResult.Passed, errors);
|
||||
}
|
||||
|
||||
private bool VerifyMerkleInclusionProof(TransparencyLogEntry entry)
|
||||
private bool VerifyMerkleInclusionProof(TransparencyLogEntry entry, out BundleVerificationErrorCode errorCode)
|
||||
{
|
||||
errorCode = BundleVerificationErrorCode.InclusionProofInvalid;
|
||||
|
||||
if (entry.InclusionProof is null)
|
||||
{
|
||||
return false;
|
||||
@@ -475,9 +519,14 @@ public sealed class SigstoreBundleVerifier
|
||||
|
||||
var proof = entry.InclusionProof;
|
||||
|
||||
if (!string.Equals(proof.LogIndex, entry.LogIndex, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse values
|
||||
if (!long.TryParse(proof.LogIndex, out var leafIndex) ||
|
||||
!long.TryParse(proof.TreeSize, out var treeSize))
|
||||
if (!long.TryParse(proof.LogIndex, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leafIndex) ||
|
||||
!long.TryParse(proof.TreeSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var treeSize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -500,7 +549,13 @@ public sealed class SigstoreBundleVerifier
|
||||
// Verify Merkle path
|
||||
var computedRoot = ComputeMerkleRoot(leafHash, leafIndex, treeSize, hashes);
|
||||
|
||||
return computedRoot.SequenceEqual(expectedRoot);
|
||||
if (!computedRoot.SequenceEqual(expectedRoot))
|
||||
{
|
||||
errorCode = BundleVerificationErrorCode.RootHashMismatch;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] ComputeLeafHash(byte[] data)
|
||||
|
||||
@@ -333,4 +333,62 @@ public class SigstoreBundleBuilderTests
|
||||
var decoded = Convert.FromBase64String(bundle.VerificationMaterial.Certificate!.RawBytes);
|
||||
decoded.Should().BeEquivalentTo(certBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithDsseEnvelope_InvalidPayloadBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
"not-base64",
|
||||
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } });
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithDsseEnvelope_InvalidSignatureBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
new[] { new BundleSignature { Sig = "not-base64" } });
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithRekorEntry_InvalidLogIndex_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithRekorEntry(
|
||||
logIndex: "not-a-number",
|
||||
logIdKeyId: Convert.ToBase64String(new byte[32]),
|
||||
integratedTime: "1703500000",
|
||||
canonicalizedBody: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")));
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*integer*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithCertificateBase64_InvalidBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithCertificateBase64("not-base64");
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,18 @@ public class SigstoreBundleSerializerTests
|
||||
.WithMessage("*dsseEnvelope*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Deserialize_MissingVerificationKeyMaterial_ThrowsSigstoreBundleException()
|
||||
{
|
||||
var json = """{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{},"dsseEnvelope":{"payloadType":"test","payload":"e30=","signatures":[{"sig":"AAAA"}]}}""";
|
||||
|
||||
var act = () => SigstoreBundleSerializer.Deserialize(json);
|
||||
|
||||
act.Should().Throw<SigstoreBundleException>()
|
||||
.WithMessage("*certificate*publicKey*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_NullBundle_ThrowsArgumentNullException()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// SigstoreBundleVerifierTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
|
||||
// Tasks: BUNDLE-8200-020, BUNDLE-8200-021 - Bundle verification tests
|
||||
@@ -36,7 +36,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -61,7 +61,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -89,7 +89,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -104,16 +104,23 @@ public class SigstoreBundleVerifierTests
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
|
||||
var bundle = new SigstoreBundleBuilder()
|
||||
.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Array.Empty<BundleSignature>())
|
||||
.WithCertificateBase64(Convert.ToBase64String(certBytes))
|
||||
.Build();
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures = Array.Empty<BundleSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -137,7 +144,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -167,7 +174,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
@@ -199,7 +206,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -233,7 +240,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle, options);
|
||||
var result = await _verifier.VerifyAsync(bundle, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Checks.CertificateChain.Should().Be(CheckResult.Failed);
|
||||
@@ -262,19 +269,109 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Checks.InclusionProof.Should().Be(CheckResult.Skipped);
|
||||
result.Checks.TransparencyLog.Should().Be(CheckResult.Skipped);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InclusionProofMissing_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var paeMessage = ConstructPae(payloadType, payload);
|
||||
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
|
||||
|
||||
var bundle = new SigstoreBundleBuilder()
|
||||
.WithDsseEnvelope(
|
||||
payloadType,
|
||||
Convert.ToBase64String(payload),
|
||||
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
|
||||
.WithCertificateBase64(Convert.ToBase64String(certBytes))
|
||||
.WithRekorEntry(
|
||||
logIndex: "12",
|
||||
logIdKeyId: Convert.ToBase64String(new byte[32]),
|
||||
integratedTime: "1710000000",
|
||||
canonicalizedBody: Convert.ToBase64String(new byte[16]))
|
||||
.Build();
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.InclusionProofInvalid);
|
||||
result.Checks.InclusionProof.Should().Be(CheckResult.Failed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InvalidPayloadBase64_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = "not-base64",
|
||||
Signatures = new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InvalidSignatureBase64_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = new[] { new BundleSignature { Sig = "not-base64" } }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_NullBundle_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act
|
||||
var act = async () => await _verifier.VerifyAsync(null!);
|
||||
var act = async () => await _verifier.VerifyAsync(null!, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
@@ -286,8 +383,8 @@ public class SigstoreBundleVerifierTests
|
||||
const byte Space = 0x20;
|
||||
|
||||
var typeBytes = System.Text.Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenBytes = System.Text.Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLenBytes = System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
var typeLenBytes = System.Text.Encoding.UTF8.GetBytes(typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var payloadLenBytes = System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var prefixBytes = System.Text.Encoding.UTF8.GetBytes(DssePrefix);
|
||||
|
||||
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
|
||||
@@ -331,3 +428,4 @@ public class SigstoreBundleVerifierTests
|
||||
return cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user