save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -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. |

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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. |