Files
git.stella-ops.org/src/Attestor/StellaOps.Attestor.Envelope/DsseEnvelopeSerializer.cs
StellaOps Bot 3f197814c5 save progress
2026-01-02 21:06:27 +02:00

318 lines
10 KiB
C#

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
namespace StellaOps.Attestor.Envelope;
public static class DsseEnvelopeSerializer
{
public static DsseEnvelopeSerializationResult Serialize(DsseEnvelope envelope, DsseEnvelopeSerializationOptions? options = null)
{
ArgumentNullException.ThrowIfNull(envelope);
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 payloadSha256 = Convert.ToHexString(SHA256.HashData(originalPayload)).ToLowerInvariant();
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)
{
compactJson = BuildCompactJson(envelope.PayloadType, payloadBase64, envelope.Signatures);
}
byte[]? expandedJson = null;
if (options.EmitExpandedJson)
{
expandedJson = BuildExpandedJson(
envelope,
payloadBase64,
payloadSha256,
originalPayload.Length,
originalPayload.Length,
options,
originalPayload);
}
return new DsseEnvelopeSerializationResult(
compactJson,
expandedJson,
payloadSha256,
originalPayload.Length,
originalPayload.Length, // No compression, so processed == original
options.CompressionAlgorithm,
envelope.DetachedPayload);
}
private static byte[] BuildCompactJson(string payloadType, string payloadBase64, IReadOnlyList<DsseSignature> signatures)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false
});
writer.WriteStartObject();
writer.WriteString("payloadType", payloadType);
writer.WriteString("payload", payloadBase64);
writer.WritePropertyName("signatures");
writer.WriteStartArray();
foreach (var signature in EnumerateCanonicalSignatures(signatures))
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
writer.WriteString("keyid", signature.KeyId);
}
writer.WriteString("sig", signature.Signature);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static byte[]? BuildExpandedJson(
DsseEnvelope envelope,
string payloadBase64,
string payloadSha256,
int originalPayloadLength,
int embeddedPayloadLength,
DsseEnvelopeSerializationOptions options,
byte[] originalPayload)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = options.IndentExpandedJson
});
writer.WriteStartObject();
writer.WriteString("payloadType", envelope.PayloadType);
writer.WriteString("payload", payloadBase64);
writer.WritePropertyName("signatures");
writer.WriteStartArray();
foreach (var signature in EnumerateCanonicalSignatures(envelope.Signatures))
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
writer.WriteString("keyid", signature.KeyId);
}
writer.WriteString("sig", signature.Signature);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("payloadInfo");
writer.WriteStartObject();
writer.WriteString("sha256", payloadSha256);
writer.WriteNumber("length", originalPayloadLength);
if (options.CompressionAlgorithm != DsseCompressionAlgorithm.None)
{
writer.WritePropertyName("compression");
writer.WriteStartObject();
writer.WriteString("algorithm", GetCompressionName(options.CompressionAlgorithm));
writer.WriteNumber("compressedLength", embeddedPayloadLength);
writer.WriteEndObject();
}
writer.WriteEndObject(); // payloadInfo
if (options.IncludePayloadPreview && TryWritePayloadPreview(envelope.PayloadContentType, originalPayload, writer))
{
// preview already written inside helper
}
if (envelope.DetachedPayload is not null)
{
writer.WritePropertyName("detachedPayload");
writer.WriteStartObject();
writer.WriteString("uri", envelope.DetachedPayload.Uri);
writer.WriteString("sha256", envelope.DetachedPayload.Sha256);
if (envelope.DetachedPayload.Length.HasValue)
{
writer.WriteNumber("length", envelope.DetachedPayload.Length.Value);
}
if (!string.IsNullOrWhiteSpace(envelope.DetachedPayload.MediaType))
{
writer.WriteString("mediaType", envelope.DetachedPayload.MediaType);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static bool TryWritePayloadPreview(string? contentType, byte[] originalPayload, Utf8JsonWriter writer)
{
if (string.IsNullOrWhiteSpace(contentType))
{
return false;
}
var lower = contentType.ToLowerInvariant();
if (!lower.Contains("json") && !lower.StartsWith("text/", StringComparison.Ordinal))
{
return false;
}
writer.WritePropertyName("payloadPreview");
writer.WriteStartObject();
writer.WriteString("mediaType", contentType);
if (lower.Contains("json") && TryParseJson(originalPayload, out var jsonDocument))
{
writer.WritePropertyName("json");
jsonDocument.WriteTo(writer);
jsonDocument.Dispose();
}
else if (TryDecodeUtf8(originalPayload, out var text))
{
writer.WriteString("text", text);
}
writer.WriteEndObject();
return true;
}
private static bool TryParseJson(byte[] payload, out JsonDocument document)
{
try
{
document = JsonDocument.Parse(payload);
return true;
}
catch (JsonException)
{
document = null!;
return false;
}
}
private static bool TryDecodeUtf8(byte[] payload, out string text)
{
var utf8 = new UTF8Encoding(false, true);
try
{
text = utf8.GetString(payload);
return true;
}
catch (DecoderFallbackException)
{
text = string.Empty;
return false;
}
}
private static string GetCompressionName(DsseCompressionAlgorithm algorithm)
{
return algorithm switch
{
DsseCompressionAlgorithm.Gzip => "gzip",
DsseCompressionAlgorithm.Brotli => "brotli",
DsseCompressionAlgorithm.None => "none",
_ => algorithm.ToString().ToLowerInvariant()
};
}
private static IEnumerable<DsseSignature> EnumerateCanonicalSignatures(IReadOnlyList<DsseSignature> signatures)
{
if (signatures.Count <= 1)
{
return signatures;
}
var comparer = CanonicalSignatureComparer.Instance;
var previous = signatures[0];
for (var i = 1; i < signatures.Count; i++)
{
var current = signatures[i];
if (comparer.Compare(previous, current) > 0)
{
var buffer = new List<DsseSignature>(signatures.Count);
for (var j = 0; j < signatures.Count; j++)
{
buffer.Add(signatures[j]);
}
buffer.Sort(comparer);
return buffer;
}
previous = current;
}
return signatures;
}
private sealed class CanonicalSignatureComparer : IComparer<DsseSignature>
{
public static CanonicalSignatureComparer Instance { get; } = new();
public int Compare(DsseSignature? x, DsseSignature? y)
{
if (ReferenceEquals(x, y))
{
return 0;
}
ArgumentNullException.ThrowIfNull(x);
ArgumentNullException.ThrowIfNull(y);
var keyComparison = string.Compare(x.KeyId, y.KeyId, StringComparison.Ordinal);
if (keyComparison != 0)
{
if (x.KeyId is null)
{
return -1;
}
if (y.KeyId is null)
{
return 1;
}
return keyComparison;
}
return string.Compare(x.Signature, y.Signature, StringComparison.Ordinal);
}
}
}