save progress
This commit is contained in:
@@ -16,6 +16,11 @@ public sealed record DsseDetachedPayloadReference
|
||||
throw new ArgumentException("Detached payload digest must be provided.", nameof(sha256));
|
||||
}
|
||||
|
||||
if (!IsSha256Digest(sha256))
|
||||
{
|
||||
throw new ArgumentException("Detached payload digest must be a 64-character hex SHA256 value.", nameof(sha256));
|
||||
}
|
||||
|
||||
Uri = uri;
|
||||
Sha256 = sha256.ToLowerInvariant();
|
||||
Length = length;
|
||||
@@ -29,4 +34,27 @@ public sealed record DsseDetachedPayloadReference
|
||||
public long? Length { get; }
|
||||
|
||||
public string? MediaType { get; }
|
||||
|
||||
private static bool IsSha256Digest(string value)
|
||||
{
|
||||
if (value.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (!IsHex(ch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsHex(char ch)
|
||||
=> (ch >= '0' && ch <= '9')
|
||||
|| (ch >= 'a' && ch <= 'f')
|
||||
|| (ch >= 'A' && ch <= 'F');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
@@ -18,10 +16,25 @@ public static class DsseEnvelopeSerializer
|
||||
|
||||
options ??= new DsseEnvelopeSerializationOptions();
|
||||
|
||||
if (!options.EmitCompactJson && !options.EmitExpandedJson)
|
||||
{
|
||||
throw new InvalidOperationException("At least one JSON format must be emitted.");
|
||||
}
|
||||
|
||||
if (options.CompressionAlgorithm != DsseCompressionAlgorithm.None)
|
||||
{
|
||||
throw new NotSupportedException("Payload compression is not supported during serialization. Compress the payload before envelope creation and ensure payloadType/metadata reflect the compressed bytes.");
|
||||
}
|
||||
|
||||
var originalPayload = envelope.Payload.ToArray();
|
||||
var processedPayload = ApplyCompression(originalPayload, options.CompressionAlgorithm);
|
||||
var payloadSha256 = Convert.ToHexString(SHA256.HashData(originalPayload)).ToLowerInvariant();
|
||||
var payloadBase64 = Convert.ToBase64String(processedPayload);
|
||||
var payloadBase64 = Convert.ToBase64String(originalPayload);
|
||||
|
||||
if (envelope.DetachedPayload is not null
|
||||
&& !string.Equals(payloadSha256, envelope.DetachedPayload.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Detached payload digest does not match the envelope payload.");
|
||||
}
|
||||
|
||||
byte[]? compactJson = null;
|
||||
if (options.EmitCompactJson)
|
||||
@@ -37,7 +50,7 @@ public static class DsseEnvelopeSerializer
|
||||
payloadBase64,
|
||||
payloadSha256,
|
||||
originalPayload.Length,
|
||||
processedPayload.Length,
|
||||
originalPayload.Length,
|
||||
options,
|
||||
originalPayload);
|
||||
}
|
||||
@@ -47,7 +60,7 @@ public static class DsseEnvelopeSerializer
|
||||
expandedJson,
|
||||
payloadSha256,
|
||||
originalPayload.Length,
|
||||
processedPayload.Length,
|
||||
originalPayload.Length, // No compression, so processed == original
|
||||
options.CompressionAlgorithm,
|
||||
envelope.DetachedPayload);
|
||||
}
|
||||
@@ -227,33 +240,6 @@ public static class DsseEnvelopeSerializer
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ApplyCompression(byte[] payload, DsseCompressionAlgorithm algorithm)
|
||||
{
|
||||
return algorithm switch
|
||||
{
|
||||
DsseCompressionAlgorithm.None => payload,
|
||||
DsseCompressionAlgorithm.Gzip => CompressWithStream(payload, static (stream) => new GZipStream(stream, CompressionLevel.SmallestSize, leaveOpen: true)),
|
||||
DsseCompressionAlgorithm.Brotli => CompressWithStream(payload, static (stream) => new BrotliStream(stream, CompressionLevel.SmallestSize, leaveOpen: true)),
|
||||
_ => throw new NotSupportedException($"Compression algorithm '{algorithm}' is not supported.")
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CompressWithStream(byte[] payload, Func<Stream, Stream> streamFactory)
|
||||
{
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
|
||||
using var output = new MemoryStream();
|
||||
using (var compressionStream = streamFactory(output))
|
||||
{
|
||||
compressionStream.Write(payload);
|
||||
}
|
||||
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static string GetCompressionName(DsseCompressionAlgorithm algorithm)
|
||||
{
|
||||
return algorithm switch
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Computes DSSE pre-authentication encoding (PAE) for payload signing.
|
||||
/// </summary>
|
||||
public static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("DSSEv1");
|
||||
private static readonly byte[] Space = new byte[] { (byte)' ' };
|
||||
|
||||
public static byte[] Compute(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payloadType is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(payloadType));
|
||||
}
|
||||
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var payloadTypeLength = Encoding.ASCII.GetBytes(payloadTypeBytes.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var payloadLength = Encoding.ASCII.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
Write(buffer, Prefix);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadTypeLength);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadTypeBytes);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadLength);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payload);
|
||||
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
private static void Write(ArrayBufferWriter<byte> writer, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var span = writer.GetSpan(bytes.Length);
|
||||
bytes.CopyTo(span);
|
||||
writer.Advance(bytes.Length);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public sealed record DsseSignature
|
||||
throw new ArgumentException("Signature must be provided.", nameof(signature));
|
||||
}
|
||||
|
||||
ValidateBase64(signature);
|
||||
|
||||
Signature = signature;
|
||||
KeyId = keyId;
|
||||
}
|
||||
@@ -28,4 +30,19 @@ public sealed record DsseSignature
|
||||
|
||||
return new DsseSignature(Convert.ToBase64String(signature), keyId);
|
||||
}
|
||||
|
||||
private static void ValidateBase64(string signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Convert.FromBase64String(signature).Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Signature must not decode to an empty byte array.", nameof(signature));
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException("Signature must be valid base64.", nameof(signature), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,19 @@ public sealed class EnvelopeSignatureService
|
||||
};
|
||||
}
|
||||
|
||||
public EnvelopeResult<EnvelopeSignature> SignDsse(string payloadType, ReadOnlySpan<byte> payload, EnvelopeKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
||||
return Sign(pae, key, cancellationToken);
|
||||
}
|
||||
|
||||
public EnvelopeResult<bool> Verify(ReadOnlySpan<byte> payload, EnvelopeSignature signature, EnvelopeKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (signature is null)
|
||||
@@ -67,6 +80,19 @@ public sealed class EnvelopeSignatureService
|
||||
};
|
||||
}
|
||||
|
||||
public EnvelopeResult<bool> VerifyDsse(string payloadType, ReadOnlySpan<byte> payload, EnvelopeSignature signature, EnvelopeKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
||||
return Verify(pae, signature, key, cancellationToken);
|
||||
}
|
||||
|
||||
private static EnvelopeResult<EnvelopeSignature> SignEd25519(ReadOnlySpan<byte> payload, EnvelopeKey key)
|
||||
{
|
||||
if (!key.HasPrivateMaterial)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<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-0051-M | DONE | Maintainability audit for StellaOps.Attestor.Envelope. |
|
||||
| AUDIT-0051-T | DONE | Test coverage audit for StellaOps.Attestor.Envelope. |
|
||||
| AUDIT-0051-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0051-A | DONE | Applied audit remediation for envelope signing/serialization. |
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
@@ -50,8 +46,8 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_WithCompressionEnabled_EmbedsCompressedPayloadMetadata()
|
||||
[Fact]
|
||||
public void Serialize_WithCompressionEnabled_Throws()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"foo\":\"bar\",\"count\":1}");
|
||||
var envelope = new DsseEnvelope(
|
||||
@@ -65,30 +61,7 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
CompressionAlgorithm = DsseCompressionAlgorithm.Gzip
|
||||
};
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, options);
|
||||
|
||||
Assert.NotNull(result.CompactJson);
|
||||
var compactDoc = JsonDocument.Parse(result.CompactJson!);
|
||||
var payloadBase64 = compactDoc.RootElement.GetProperty("payload").GetString();
|
||||
Assert.False(string.IsNullOrEmpty(payloadBase64));
|
||||
|
||||
var compressedBytes = Convert.FromBase64String(payloadBase64!);
|
||||
using var compressedStream = new MemoryStream(compressedBytes);
|
||||
using var gzip = new GZipStream(compressedStream, CompressionMode.Decompress);
|
||||
using var decompressed = new MemoryStream();
|
||||
gzip.CopyTo(decompressed);
|
||||
Assert.True(payload.SequenceEqual(decompressed.ToArray()));
|
||||
|
||||
using var expanded = JsonDocument.Parse(result.ExpandedJson!);
|
||||
var info = expanded.RootElement.GetProperty("payloadInfo");
|
||||
Assert.Equal(payload.Length, info.GetProperty("length").GetInt32());
|
||||
var compression = info.GetProperty("compression");
|
||||
Assert.Equal("gzip", compression.GetProperty("algorithm").GetString());
|
||||
Assert.Equal(compressedBytes.Length, compression.GetProperty("compressedLength").GetInt32());
|
||||
|
||||
Assert.Equal(DsseCompressionAlgorithm.Gzip, result.Compression);
|
||||
Assert.Equal(payload.Length, result.OriginalPayloadLength);
|
||||
Assert.Equal(compressedBytes.Length, result.EmbeddedPayloadLength);
|
||||
Assert.Throws<NotSupportedException>(() => DsseEnvelopeSerializer.Serialize(envelope, options));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -96,9 +69,10 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
public void Serialize_WithDetachedReference_WritesMetadata()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("detached payload preview");
|
||||
var payloadSha256 = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(payload)).ToLowerInvariant();
|
||||
var reference = new DsseDetachedPayloadReference(
|
||||
"https://evidence.example.com/sbom.json",
|
||||
"abc123",
|
||||
payloadSha256,
|
||||
payload.Length,
|
||||
"application/json");
|
||||
|
||||
@@ -123,7 +97,28 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Serialize_WithDetachedReferenceMismatch_Throws()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("detached payload preview");
|
||||
var reference = new DsseDetachedPayloadReference(
|
||||
"https://evidence.example.com/sbom.json",
|
||||
new string('a', 64),
|
||||
payload.Length,
|
||||
"application/json");
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") },
|
||||
"text/plain",
|
||||
reference);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => DsseEnvelopeSerializer.Serialize(envelope));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_CompactOnly_SkipsExpandedPayload()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("payload");
|
||||
@@ -142,4 +137,23 @@ public sealed class DsseEnvelopeSerializerTests
|
||||
Assert.NotNull(result.CompactJson);
|
||||
Assert.Null(result.ExpandedJson);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_WithNoFormats_Throws()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("payload");
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") });
|
||||
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitCompactJson = false,
|
||||
EmitExpandedJson = false
|
||||
};
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => DsseEnvelopeSerializer.Serialize(envelope, options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class EnvelopeSignatureServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DssePreAuthenticationEncoding_UsesAsciiLengths()
|
||||
{
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payload = Encoding.UTF8.GetBytes("hello");
|
||||
|
||||
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
||||
var expected = $"DSSEv1 {Encoding.UTF8.GetByteCount(payloadType)} {payloadType} {payload.Length} hello";
|
||||
|
||||
Assert.Equal(expected, Encoding.UTF8.GetString(pae));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SignDsse_MatchesSignOnPreAuthenticationEncoding()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var key = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, parameters, "test-key");
|
||||
var service = new EnvelopeSignatureService();
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var payload = Encoding.UTF8.GetBytes("payload");
|
||||
|
||||
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
||||
var direct = service.Sign(pae, key);
|
||||
var viaDsse = service.SignDsse(payloadType, payload, key);
|
||||
|
||||
Assert.True(direct.IsSuccess);
|
||||
Assert.True(viaDsse.IsSuccess);
|
||||
Assert.Equal(direct.Value.KeyId, viaDsse.Value.KeyId);
|
||||
Assert.Equal(direct.Value.AlgorithmId, viaDsse.Value.AlgorithmId);
|
||||
|
||||
var verifyDirect = service.Verify(pae, direct.Value, key);
|
||||
var verify = service.VerifyDsse(payloadType, payload, viaDsse.Value, key);
|
||||
Assert.True(verifyDirect.IsSuccess);
|
||||
Assert.True(verify.IsSuccess);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DsseSignature_WithInvalidBase64_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new DsseSignature("not base64"));
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| AUDIT-0052-M | DONE | Maintainability audit for StellaOps.Attestor.Envelope.Tests. |
|
||||
| AUDIT-0052-T | DONE | Test coverage audit for StellaOps.Attestor.Envelope.Tests. |
|
||||
| AUDIT-0052-A | TODO | Pending approval for changes. |
|
||||
| VAL-SMOKE-001 | DONE | Stabilized DSSE signature tests under xUnit v3. |
|
||||
|
||||
Reference in New Issue
Block a user