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 signatures) { var buffer = new ArrayBufferWriter(); 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(); 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 EnumerateCanonicalSignatures(IReadOnlyList 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(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 { 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); } } }