Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public enum DsseCompressionAlgorithm
|
||||
{
|
||||
None = 0,
|
||||
Gzip = 1,
|
||||
Brotli = 2
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public sealed record DsseDetachedPayloadReference
|
||||
{
|
||||
public DsseDetachedPayloadReference(string uri, string sha256, long? length = null, string? mediaType = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
{
|
||||
throw new ArgumentException("Detached payload URI must be provided.", nameof(uri));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sha256))
|
||||
{
|
||||
throw new ArgumentException("Detached payload digest must be provided.", nameof(sha256));
|
||||
}
|
||||
|
||||
Uri = uri;
|
||||
Sha256 = sha256.ToLowerInvariant();
|
||||
Length = length;
|
||||
MediaType = mediaType;
|
||||
}
|
||||
|
||||
public string Uri { get; }
|
||||
|
||||
public string Sha256 { get; }
|
||||
|
||||
public long? Length { get; }
|
||||
|
||||
public string? MediaType { get; }
|
||||
}
|
||||
48
src/Attestor/StellaOps.Attestor.Envelope/DsseEnvelope.cs
Normal file
48
src/Attestor/StellaOps.Attestor.Envelope/DsseEnvelope.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public sealed class DsseEnvelope
|
||||
{
|
||||
public DsseEnvelope(
|
||||
string payloadType,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
IEnumerable<DsseSignature> signatures,
|
||||
string? payloadContentType = null,
|
||||
DsseDetachedPayloadReference? detachedPayload = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
PayloadType = payloadType;
|
||||
Payload = payload;
|
||||
PayloadContentType = payloadContentType;
|
||||
DetachedPayload = detachedPayload;
|
||||
|
||||
var normalised = signatures?.ToArray() ?? Array.Empty<DsseSignature>();
|
||||
if (normalised.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one signature must be supplied.", nameof(signatures));
|
||||
}
|
||||
|
||||
// Deterministic ordering (keyid asc, signature asc) for canonical output.
|
||||
Signatures = normalised
|
||||
.OrderBy(static x => x.KeyId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static x => x.Signature, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string PayloadType { get; }
|
||||
|
||||
public ReadOnlyMemory<byte> Payload { get; }
|
||||
|
||||
public string? PayloadContentType { get; }
|
||||
|
||||
public IReadOnlyList<DsseSignature> Signatures { get; }
|
||||
|
||||
public DsseDetachedPayloadReference? DetachedPayload { get; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public sealed class DsseEnvelopeSerializationOptions
|
||||
{
|
||||
public bool EmitCompactJson { get; init; } = true;
|
||||
|
||||
public bool EmitExpandedJson { get; init; } = true;
|
||||
|
||||
public bool IndentExpandedJson { get; init; } = true;
|
||||
|
||||
public bool IncludePayloadPreview { get; init; } = true;
|
||||
|
||||
public DsseCompressionAlgorithm CompressionAlgorithm { get; init; } = DsseCompressionAlgorithm.None;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public sealed class DsseEnvelopeSerializationResult
|
||||
{
|
||||
public DsseEnvelopeSerializationResult(
|
||||
byte[]? compactJson,
|
||||
byte[]? expandedJson,
|
||||
string payloadSha256,
|
||||
int originalPayloadLength,
|
||||
int embeddedPayloadLength,
|
||||
DsseCompressionAlgorithm compression,
|
||||
DsseDetachedPayloadReference? detachedPayload)
|
||||
{
|
||||
CompactJson = compactJson;
|
||||
ExpandedJson = expandedJson;
|
||||
PayloadSha256 = payloadSha256 ?? throw new ArgumentNullException(nameof(payloadSha256));
|
||||
OriginalPayloadLength = originalPayloadLength;
|
||||
EmbeddedPayloadLength = embeddedPayloadLength;
|
||||
Compression = compression;
|
||||
DetachedPayload = detachedPayload;
|
||||
}
|
||||
|
||||
public byte[]? CompactJson { get; }
|
||||
|
||||
public byte[]? ExpandedJson { get; }
|
||||
|
||||
public string PayloadSha256 { get; }
|
||||
|
||||
public int OriginalPayloadLength { get; }
|
||||
|
||||
public int EmbeddedPayloadLength { get; }
|
||||
|
||||
public DsseCompressionAlgorithm Compression { get; }
|
||||
|
||||
public DsseDetachedPayloadReference? DetachedPayload { get; }
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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;
|
||||
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();
|
||||
|
||||
var originalPayload = envelope.Payload.ToArray();
|
||||
var processedPayload = ApplyCompression(originalPayload, options.CompressionAlgorithm);
|
||||
var payloadSha256 = Convert.ToHexString(SHA256.HashData(originalPayload)).ToLowerInvariant();
|
||||
var payloadBase64 = Convert.ToBase64String(processedPayload);
|
||||
|
||||
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,
|
||||
processedPayload.Length,
|
||||
options,
|
||||
originalPayload);
|
||||
}
|
||||
|
||||
return new DsseEnvelopeSerializationResult(
|
||||
compactJson,
|
||||
expandedJson,
|
||||
payloadSha256,
|
||||
originalPayload.Length,
|
||||
processedPayload.Length,
|
||||
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 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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/Attestor/StellaOps.Attestor.Envelope/DsseSignature.cs
Normal file
31
src/Attestor/StellaOps.Attestor.Envelope/DsseSignature.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
public DsseSignature(string signature, string? keyId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature))
|
||||
{
|
||||
throw new ArgumentException("Signature must be provided.", nameof(signature));
|
||||
}
|
||||
|
||||
Signature = signature;
|
||||
KeyId = keyId;
|
||||
}
|
||||
|
||||
public string Signature { get; }
|
||||
|
||||
public string? KeyId { get; }
|
||||
|
||||
public static DsseSignature FromBytes(ReadOnlySpan<byte> signature, string? keyId = null)
|
||||
{
|
||||
if (signature.IsEmpty)
|
||||
{
|
||||
throw new ArgumentException("Signature bytes must be provided.", nameof(signature));
|
||||
}
|
||||
|
||||
return new DsseSignature(Convert.ToBase64String(signature), keyId);
|
||||
}
|
||||
}
|
||||
301
src/Attestor/StellaOps.Attestor.Envelope/EnvelopeKey.cs
Normal file
301
src/Attestor/StellaOps.Attestor.Envelope/EnvelopeKey.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the underlying key algorithm for DSSE envelope signing.
|
||||
/// </summary>
|
||||
public enum EnvelopeKeyKind
|
||||
{
|
||||
Ed25519,
|
||||
Ecdsa
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents signing or verification key material for DSSE envelope operations.
|
||||
/// </summary>
|
||||
public sealed class EnvelopeKey
|
||||
{
|
||||
private const int Ed25519PublicKeyLength = 32;
|
||||
private const int Ed25519PrivateKeySeedLength = 32;
|
||||
private const int Ed25519PrivateKeyExpandedLength = 64;
|
||||
|
||||
private readonly byte[]? ed25519PublicKey;
|
||||
private readonly byte[]? ed25519PrivateKey;
|
||||
private readonly ECParameters? ecdsaPublicParameters;
|
||||
private readonly ECParameters? ecdsaPrivateParameters;
|
||||
|
||||
private EnvelopeKey(
|
||||
EnvelopeKeyKind kind,
|
||||
string algorithmId,
|
||||
string keyId,
|
||||
byte[]? ed25519PublicKey,
|
||||
byte[]? ed25519PrivateKey,
|
||||
ECParameters? ecdsaPublicParameters,
|
||||
ECParameters? ecdsaPrivateParameters)
|
||||
{
|
||||
Kind = kind;
|
||||
AlgorithmId = algorithmId;
|
||||
KeyId = keyId;
|
||||
this.ed25519PublicKey = ed25519PublicKey;
|
||||
this.ed25519PrivateKey = ed25519PrivateKey;
|
||||
this.ecdsaPublicParameters = ecdsaPublicParameters;
|
||||
this.ecdsaPrivateParameters = ecdsaPrivateParameters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key classification.
|
||||
/// </summary>
|
||||
public EnvelopeKeyKind Kind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signing algorithm identifier (e.g., ED25519, ES256).
|
||||
/// </summary>
|
||||
public string AlgorithmId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the deterministic key identifier (RFC7638 JWK thumbprint based).
|
||||
/// </summary>
|
||||
public string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the key has private material available.
|
||||
/// </summary>
|
||||
public bool HasPrivateMaterial => Kind switch
|
||||
{
|
||||
EnvelopeKeyKind.Ed25519 => ed25519PrivateKey is not null,
|
||||
EnvelopeKeyKind.Ecdsa => ecdsaPrivateParameters.HasValue,
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the key has public material available.
|
||||
/// </summary>
|
||||
public bool HasPublicMaterial => Kind switch
|
||||
{
|
||||
EnvelopeKeyKind.Ed25519 => ed25519PublicKey is not null,
|
||||
EnvelopeKeyKind.Ecdsa => ecdsaPublicParameters.HasValue,
|
||||
_ => false
|
||||
};
|
||||
|
||||
internal ReadOnlySpan<byte> GetEd25519PublicKey()
|
||||
{
|
||||
if (Kind != EnvelopeKeyKind.Ed25519 || ed25519PublicKey is null)
|
||||
{
|
||||
throw new InvalidOperationException("Key does not provide Ed25519 public material.");
|
||||
}
|
||||
|
||||
return ed25519PublicKey;
|
||||
}
|
||||
|
||||
internal ReadOnlySpan<byte> GetEd25519PrivateKey()
|
||||
{
|
||||
if (Kind != EnvelopeKeyKind.Ed25519 || ed25519PrivateKey is null)
|
||||
{
|
||||
throw new InvalidOperationException("Key does not provide Ed25519 private material.");
|
||||
}
|
||||
|
||||
return ed25519PrivateKey;
|
||||
}
|
||||
|
||||
internal ECParameters GetEcdsaPublicParameters()
|
||||
{
|
||||
if (Kind != EnvelopeKeyKind.Ecdsa || !ecdsaPublicParameters.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Key does not provide ECDSA public parameters.");
|
||||
}
|
||||
|
||||
return CloneParameters(ecdsaPublicParameters.Value, includePrivate: false);
|
||||
}
|
||||
|
||||
internal ECParameters GetEcdsaPrivateParameters()
|
||||
{
|
||||
if (Kind != EnvelopeKeyKind.Ecdsa || !ecdsaPrivateParameters.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Key does not provide ECDSA private parameters.");
|
||||
}
|
||||
|
||||
return CloneParameters(ecdsaPrivateParameters.Value, includePrivate: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Ed25519 signing key (requires private + public material).
|
||||
/// </summary>
|
||||
/// <param name="privateKey">64-byte Ed25519 private key (seed || public key).</param>
|
||||
/// <param name="publicKey">32-byte Ed25519 public key.</param>
|
||||
/// <param name="keyId">Optional external key identifier override.</param>
|
||||
/// <returns>Envelope key instance.</returns>
|
||||
public static EnvelopeKey CreateEd25519Signer(ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> publicKey, string? keyId = null)
|
||||
{
|
||||
var normalizedPrivate = NormalizeEd25519PrivateKey(privateKey);
|
||||
ValidateEd25519PublicLength(publicKey);
|
||||
var publicCopy = publicKey.ToArray();
|
||||
var resolvedKeyId = string.IsNullOrWhiteSpace(keyId)
|
||||
? EnvelopeKeyIdCalculator.FromEd25519(publicCopy)
|
||||
: keyId;
|
||||
|
||||
return new EnvelopeKey(
|
||||
EnvelopeKeyKind.Ed25519,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
resolvedKeyId,
|
||||
publicCopy,
|
||||
normalizedPrivate,
|
||||
ecdsaPublicParameters: null,
|
||||
ecdsaPrivateParameters: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Ed25519 verification key (public material only).
|
||||
/// </summary>
|
||||
/// <param name="publicKey">32-byte Ed25519 public key.</param>
|
||||
/// <param name="keyId">Optional external key identifier override.</param>
|
||||
/// <returns>Envelope key instance.</returns>
|
||||
public static EnvelopeKey CreateEd25519Verifier(ReadOnlySpan<byte> publicKey, string? keyId = null)
|
||||
{
|
||||
ValidateEd25519PublicLength(publicKey);
|
||||
|
||||
var publicCopy = publicKey.ToArray();
|
||||
var resolvedKeyId = string.IsNullOrWhiteSpace(keyId)
|
||||
? EnvelopeKeyIdCalculator.FromEd25519(publicCopy)
|
||||
: keyId;
|
||||
|
||||
return new EnvelopeKey(
|
||||
EnvelopeKeyKind.Ed25519,
|
||||
SignatureAlgorithms.Ed25519,
|
||||
resolvedKeyId,
|
||||
publicCopy,
|
||||
ed25519PrivateKey: null,
|
||||
ecdsaPublicParameters: null,
|
||||
ecdsaPrivateParameters: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ECDSA signing key (private + public EC parameters).
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">ECDSA algorithm identifier (ES256, ES384, ES512).</param>
|
||||
/// <param name="privateParameters">EC parameters including private scalar.</param>
|
||||
/// <param name="keyId">Optional external key identifier override.</param>
|
||||
/// <returns>Envelope key instance.</returns>
|
||||
public static EnvelopeKey CreateEcdsaSigner(string algorithmId, in ECParameters privateParameters, string? keyId = null)
|
||||
{
|
||||
ValidateEcdsaAlgorithm(algorithmId);
|
||||
|
||||
if (privateParameters.D is null || privateParameters.D.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("ECDSA private parameters must include the scalar component (D).", nameof(privateParameters));
|
||||
}
|
||||
|
||||
if (privateParameters.Q.X is null || privateParameters.Q.Y is null)
|
||||
{
|
||||
throw new ArgumentException("ECDSA private parameters must include public coordinates.", nameof(privateParameters));
|
||||
}
|
||||
|
||||
var publicClone = CloneParameters(privateParameters, includePrivate: false);
|
||||
var privateClone = CloneParameters(privateParameters, includePrivate: true);
|
||||
var resolvedKeyId = string.IsNullOrWhiteSpace(keyId)
|
||||
? EnvelopeKeyIdCalculator.FromEcdsa(algorithmId, publicClone)
|
||||
: keyId;
|
||||
|
||||
return new EnvelopeKey(
|
||||
EnvelopeKeyKind.Ecdsa,
|
||||
algorithmId,
|
||||
resolvedKeyId,
|
||||
ed25519PublicKey: null,
|
||||
ed25519PrivateKey: null,
|
||||
ecdsaPublicParameters: publicClone,
|
||||
ecdsaPrivateParameters: privateClone);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an ECDSA verification key (public EC parameters).
|
||||
/// </summary>
|
||||
/// <param name="algorithmId">ECDSA algorithm identifier (ES256, ES384, ES512).</param>
|
||||
/// <param name="publicParameters">EC parameters containing only public coordinates.</param>
|
||||
/// <param name="keyId">Optional external key identifier override.</param>
|
||||
/// <returns>Envelope key instance.</returns>
|
||||
public static EnvelopeKey CreateEcdsaVerifier(string algorithmId, in ECParameters publicParameters, string? keyId = null)
|
||||
{
|
||||
ValidateEcdsaAlgorithm(algorithmId);
|
||||
|
||||
if (publicParameters.Q.X is null || publicParameters.Q.Y is null)
|
||||
{
|
||||
throw new ArgumentException("ECDSA public parameters must include X and Y coordinates.", nameof(publicParameters));
|
||||
}
|
||||
|
||||
if (publicParameters.D is not null)
|
||||
{
|
||||
throw new ArgumentException("ECDSA verification parameters must not include private scalar data.", nameof(publicParameters));
|
||||
}
|
||||
|
||||
var publicClone = CloneParameters(publicParameters, includePrivate: false);
|
||||
var resolvedKeyId = string.IsNullOrWhiteSpace(keyId)
|
||||
? EnvelopeKeyIdCalculator.FromEcdsa(algorithmId, publicClone)
|
||||
: keyId;
|
||||
|
||||
return new EnvelopeKey(
|
||||
EnvelopeKeyKind.Ecdsa,
|
||||
algorithmId,
|
||||
resolvedKeyId,
|
||||
ed25519PublicKey: null,
|
||||
ed25519PrivateKey: null,
|
||||
ecdsaPublicParameters: publicClone,
|
||||
ecdsaPrivateParameters: null);
|
||||
}
|
||||
|
||||
private static byte[] NormalizeEd25519PrivateKey(ReadOnlySpan<byte> privateKey)
|
||||
{
|
||||
return privateKey.Length switch
|
||||
{
|
||||
Ed25519PrivateKeySeedLength => privateKey.ToArray(),
|
||||
Ed25519PrivateKeyExpandedLength => privateKey[..Ed25519PrivateKeySeedLength].ToArray(),
|
||||
_ => throw new ArgumentException($"Ed25519 private key must be {Ed25519PrivateKeySeedLength} or {Ed25519PrivateKeyExpandedLength} bytes.", nameof(privateKey))
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateEd25519PublicLength(ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (publicKey.Length != Ed25519PublicKeyLength)
|
||||
{
|
||||
throw new ArgumentException($"Ed25519 public key must be {Ed25519PublicKeyLength} bytes.", nameof(publicKey));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEcdsaAlgorithm(string algorithmId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
var supported = string.Equals(algorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(algorithmId, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(algorithmId, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!supported)
|
||||
{
|
||||
throw new ArgumentException($"Unsupported ECDSA algorithm '{algorithmId}'.", nameof(algorithmId));
|
||||
}
|
||||
}
|
||||
|
||||
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
|
||||
{
|
||||
var clone = new ECParameters
|
||||
{
|
||||
Curve = source.Curve,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
|
||||
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
|
||||
}
|
||||
};
|
||||
|
||||
if (includePrivate && source.D is not null)
|
||||
{
|
||||
clone.D = (byte[])source.D.Clone();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
internal static class EnvelopeKeyIdCalculator
|
||||
{
|
||||
public static string FromEd25519(ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (publicKey.Length != 32)
|
||||
{
|
||||
throw new ArgumentException("Ed25519 public key must be 32 bytes.", nameof(publicKey));
|
||||
}
|
||||
|
||||
var jwk = $"{{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"{ToBase64Url(publicKey)}\"}}";
|
||||
return $"sha256:{ComputeSha256Base64Url(jwk)}";
|
||||
}
|
||||
|
||||
public static string FromEcdsa(string algorithmId, in ECParameters parameters)
|
||||
{
|
||||
var curve = ResolveCurveName(algorithmId);
|
||||
var x = parameters.Q.X ?? throw new ArgumentException("ECDSA public parameters missing X coordinate.", nameof(parameters));
|
||||
var y = parameters.Q.Y ?? throw new ArgumentException("ECDSA public parameters missing Y coordinate.", nameof(parameters));
|
||||
|
||||
var jwk = $"{{\"crv\":\"{curve}\",\"kty\":\"EC\",\"x\":\"{ToBase64Url(x)}\",\"y\":\"{ToBase64Url(y)}\"}}";
|
||||
return $"sha256:{ComputeSha256Base64Url(jwk)}";
|
||||
}
|
||||
|
||||
private static string ResolveCurveName(string algorithmId) => algorithmId?.ToUpperInvariant() switch
|
||||
{
|
||||
"ES256" => "P-256",
|
||||
"ES384" => "P-384",
|
||||
"ES512" => "P-521",
|
||||
_ => throw new ArgumentException($"Unsupported ECDSA algorithm '{algorithmId}'.", nameof(algorithmId))
|
||||
};
|
||||
|
||||
private static string ComputeSha256Base64Url(string value)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
var digest = sha.ComputeHash(bytes);
|
||||
return ToBase64Url(digest);
|
||||
}
|
||||
|
||||
private static string ToBase64Url(ReadOnlySpan<byte> value)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(value);
|
||||
return base64
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a DSSE envelope signature (detached from payload).
|
||||
/// </summary>
|
||||
public sealed class EnvelopeSignature
|
||||
{
|
||||
private readonly byte[] signature;
|
||||
|
||||
public EnvelopeSignature(string keyId, string algorithmId, ReadOnlySpan<byte> value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
throw new ArgumentException("Key identifier is required.", nameof(keyId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(algorithmId))
|
||||
{
|
||||
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
|
||||
}
|
||||
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Signature bytes must not be empty.", nameof(value));
|
||||
}
|
||||
|
||||
KeyId = keyId;
|
||||
AlgorithmId = algorithmId;
|
||||
signature = value.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key identifier associated with the signature.
|
||||
/// </summary>
|
||||
public string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signing algorithm identifier.
|
||||
/// </summary>
|
||||
public string AlgorithmId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw signature bytes.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte> Value => signature;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes returned by envelope signing and verification helpers.
|
||||
/// </summary>
|
||||
public enum EnvelopeSignatureErrorCode
|
||||
{
|
||||
UnsupportedAlgorithm,
|
||||
InvalidKeyMaterial,
|
||||
MissingPrivateKey,
|
||||
MissingPublicKey,
|
||||
AlgorithmMismatch,
|
||||
KeyIdMismatch,
|
||||
InvalidSignatureFormat,
|
||||
SignatureInvalid,
|
||||
SigningFailed,
|
||||
VerificationFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a deterministic error emitted by signature helpers.
|
||||
/// </summary>
|
||||
public sealed record EnvelopeSignatureError(EnvelopeSignatureErrorCode Code, string Message, Exception? Exception = null);
|
||||
|
||||
/// <summary>
|
||||
/// Generic result wrapper providing success state and structured errors.
|
||||
/// </summary>
|
||||
public sealed class EnvelopeResult<T>
|
||||
{
|
||||
private EnvelopeResult(bool isSuccess, T? value, EnvelopeSignatureError? error)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
this.value = value;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
public T Value => IsSuccess
|
||||
? value ?? throw new InvalidOperationException("Successful result is missing value.")
|
||||
: throw new InvalidOperationException("Cannot access Value when result indicates failure.");
|
||||
|
||||
public EnvelopeSignatureError Error => !IsSuccess
|
||||
? error ?? throw new InvalidOperationException("Failed result is missing error information.")
|
||||
: throw new InvalidOperationException("Cannot access Error when result indicates success.");
|
||||
|
||||
private readonly T? value;
|
||||
|
||||
private readonly EnvelopeSignatureError? error;
|
||||
|
||||
public static EnvelopeResult<T> Success(T value) => new(true, value, null);
|
||||
|
||||
public static EnvelopeResult<T> Failure(EnvelopeSignatureError error) => new(false, default, error);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope;
|
||||
|
||||
/// <summary>
|
||||
/// Provides Ed25519 and ECDSA helpers for creating and verifying DSSE envelope signatures.
|
||||
/// </summary>
|
||||
public sealed class EnvelopeSignatureService
|
||||
{
|
||||
private const int Ed25519SignatureLength = 64;
|
||||
|
||||
public EnvelopeResult<EnvelopeSignature> Sign(ReadOnlySpan<byte> payload, EnvelopeKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return key.Kind switch
|
||||
{
|
||||
EnvelopeKeyKind.Ed25519 => SignEd25519(payload, key),
|
||||
EnvelopeKeyKind.Ecdsa => SignEcdsa(payload, key),
|
||||
_ => EnvelopeResult<EnvelopeSignature>.Failure(Error(EnvelopeSignatureErrorCode.UnsupportedAlgorithm, $"Unsupported key kind '{key.Kind}'."))
|
||||
};
|
||||
}
|
||||
|
||||
public EnvelopeResult<bool> Verify(ReadOnlySpan<byte> payload, EnvelopeSignature signature, EnvelopeKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (signature is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(signature));
|
||||
}
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!key.HasPublicMaterial)
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.MissingPublicKey, "Verification requires public key material."));
|
||||
}
|
||||
|
||||
if (!string.Equals(signature.KeyId, key.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.KeyIdMismatch, "Signature key identifier does not match the supplied key."));
|
||||
}
|
||||
|
||||
if (!string.Equals(signature.AlgorithmId, key.AlgorithmId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.AlgorithmMismatch, "Signature algorithm does not match the supplied key."));
|
||||
}
|
||||
|
||||
return key.Kind switch
|
||||
{
|
||||
EnvelopeKeyKind.Ed25519 => VerifyEd25519(payload, signature, key),
|
||||
EnvelopeKeyKind.Ecdsa => VerifyEcdsa(payload, signature, key),
|
||||
_ => EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.UnsupportedAlgorithm, $"Unsupported key kind '{key.Kind}'."))
|
||||
};
|
||||
}
|
||||
|
||||
private static EnvelopeResult<EnvelopeSignature> SignEd25519(ReadOnlySpan<byte> payload, EnvelopeKey key)
|
||||
{
|
||||
if (!key.HasPrivateMaterial)
|
||||
{
|
||||
return EnvelopeResult<EnvelopeSignature>.Failure(Error(EnvelopeSignatureErrorCode.MissingPrivateKey, "Signing requires Ed25519 private material."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = payload.ToArray();
|
||||
var privateKey = new Ed25519PrivateKeyParameters(key.GetEd25519PrivateKey().ToArray(), 0);
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(true, privateKey);
|
||||
signer.BlockUpdate(payloadBytes, 0, payloadBytes.Length);
|
||||
var signatureBytes = signer.GenerateSignature();
|
||||
return EnvelopeResult<EnvelopeSignature>.Success(new EnvelopeSignature(key.KeyId, key.AlgorithmId, signatureBytes));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return EnvelopeResult<EnvelopeSignature>.Failure(Error(EnvelopeSignatureErrorCode.SigningFailed, "Failed to produce Ed25519 signature.", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static EnvelopeResult<EnvelopeSignature> SignEcdsa(ReadOnlySpan<byte> payload, EnvelopeKey key)
|
||||
{
|
||||
if (!key.HasPrivateMaterial)
|
||||
{
|
||||
return EnvelopeResult<EnvelopeSignature>.Failure(Error(EnvelopeSignatureErrorCode.MissingPrivateKey, "Signing requires ECDSA private material."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(key.GetEcdsaPrivateParameters());
|
||||
var signatureBytes = ecdsa.SignData(payload, ResolveHashAlgorithm(key.AlgorithmId));
|
||||
return EnvelopeResult<EnvelopeSignature>.Success(new EnvelopeSignature(key.KeyId, key.AlgorithmId, signatureBytes));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return EnvelopeResult<EnvelopeSignature>.Failure(Error(EnvelopeSignatureErrorCode.SigningFailed, "Failed to produce ECDSA signature.", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static EnvelopeResult<bool> VerifyEd25519(ReadOnlySpan<byte> payload, EnvelopeSignature signature, EnvelopeKey key)
|
||||
{
|
||||
var signatureBytes = signature.Value.Span;
|
||||
if (signatureBytes.Length != Ed25519SignatureLength)
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.InvalidSignatureFormat, $"Ed25519 signatures must be {Ed25519SignatureLength} bytes."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = payload.ToArray();
|
||||
var publicKey = new Ed25519PublicKeyParameters(key.GetEd25519PublicKey().ToArray(), 0);
|
||||
var verifier = new Ed25519Signer();
|
||||
verifier.Init(false, publicKey);
|
||||
verifier.BlockUpdate(payloadBytes, 0, payloadBytes.Length);
|
||||
var valid = verifier.VerifySignature(signatureBytes.ToArray());
|
||||
return valid
|
||||
? EnvelopeResult<bool>.Success(true)
|
||||
: EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.SignatureInvalid, "Ed25519 signature verification failed."));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or CryptographicException)
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.VerificationFailed, "Failed to verify Ed25519 signature.", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static EnvelopeResult<bool> VerifyEcdsa(ReadOnlySpan<byte> payload, EnvelopeSignature signature, EnvelopeKey key)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(key.GetEcdsaPublicParameters());
|
||||
var valid = ecdsa.VerifyData(payload, signature.Value.Span, ResolveHashAlgorithm(key.AlgorithmId));
|
||||
return valid
|
||||
? EnvelopeResult<bool>.Success(true)
|
||||
: EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.SignatureInvalid, "ECDSA signature verification failed."));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or CryptographicException)
|
||||
{
|
||||
return EnvelopeResult<bool>.Failure(Error(EnvelopeSignatureErrorCode.VerificationFailed, "Failed to verify ECDSA signature.", ex));
|
||||
}
|
||||
}
|
||||
|
||||
private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId) => algorithmId?.ToUpperInvariant() switch
|
||||
{
|
||||
"ES256" => HashAlgorithmName.SHA256,
|
||||
"ES384" => HashAlgorithmName.SHA384,
|
||||
"ES512" => HashAlgorithmName.SHA512,
|
||||
_ => throw new ArgumentException($"Unsupported ECDSA algorithm '{algorithmId}'.", nameof(algorithmId))
|
||||
};
|
||||
|
||||
private static EnvelopeSignatureError Error(EnvelopeSignatureErrorCode code, string message, Exception? exception = null)
|
||||
=> new(code, message, exception);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using EnvelopeModel = StellaOps.Attestor.Envelope;
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeSerializerTests
|
||||
{
|
||||
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("deterministic-dsse-payload");
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ProducesDeterministicCompactJson_ForSignaturePermutations()
|
||||
{
|
||||
var signatures = new[]
|
||||
{
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("0A1B2C3D4E5F60718293A4B5C6D7E8F9"), "tenant-z"),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"), null),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("00112233445566778899AABBCCDDEEFF"), "tenant-a"),
|
||||
EnvelopeModel.DsseSignature.FromBytes(Convert.FromHexString("1234567890ABCDEF1234567890ABCDEF"), "tenant-b")
|
||||
};
|
||||
|
||||
var baselineEnvelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, signatures);
|
||||
var baseline = EnvelopeModel.DsseEnvelopeSerializer.Serialize(baselineEnvelope);
|
||||
baseline.CompactJson.Should().NotBeNull();
|
||||
var baselineJson = Encoding.UTF8.GetString(baseline.CompactJson!);
|
||||
|
||||
var rng = new Random(12345);
|
||||
for (var iteration = 0; iteration < 32; iteration++)
|
||||
{
|
||||
var shuffled = signatures.OrderBy(_ => rng.Next()).ToArray();
|
||||
var envelope = new EnvelopeModel.DsseEnvelope("application/vnd.stellaops.test+json", SamplePayload, shuffled);
|
||||
var result = EnvelopeModel.DsseEnvelopeSerializer.Serialize(envelope);
|
||||
|
||||
result.CompactJson.Should().NotBeNull();
|
||||
var json = Encoding.UTF8.GetString(result.CompactJson!);
|
||||
json.Should().Be(baselineJson, "canonical JSON must be deterministic regardless of signature insertion order");
|
||||
|
||||
result.PayloadSha256.Should().Be(
|
||||
Convert.ToHexString(SHA256.HashData(SamplePayload)).ToLowerInvariant(),
|
||||
"payload hash must reflect the raw payload bytes");
|
||||
|
||||
using var document = JsonDocument.Parse(result.CompactJson!);
|
||||
var keyIds = document.RootElement
|
||||
.GetProperty("signatures")
|
||||
.EnumerateArray()
|
||||
.Select(element => element.TryGetProperty("keyid", out var key) ? key.GetString() : null)
|
||||
.ToArray();
|
||||
|
||||
keyIds.Should().Equal(new string?[] { null, "tenant-a", "tenant-b", "tenant-z" },
|
||||
"signatures must be ordered by key identifier (null first) for canonical output");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class EnvelopeSignatureServiceTests
|
||||
{
|
||||
private static readonly byte[] SamplePayload = Encoding.UTF8.GetBytes("stella-ops-deterministic");
|
||||
|
||||
private static readonly byte[] Ed25519Seed =
|
||||
Convert.FromHexString("9D61B19DEFFD5A60BA844AF492EC2CC4" +
|
||||
"4449C5697B326919703BAC031CAE7F60D75A980182B10AB7D54BFED3C964073A" +
|
||||
"0EE172F3DAA62325AF021A68F707511A");
|
||||
|
||||
private static readonly byte[] Ed25519Public =
|
||||
Convert.FromHexString("D75A980182B10AB7D54BFED3C964073A0EE172F3DAA62325AF021A68F707511A");
|
||||
|
||||
private readonly EnvelopeSignatureService service = new();
|
||||
|
||||
[Fact]
|
||||
public void SignAndVerify_Ed25519_Succeeds()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
signResult.Value.AlgorithmId.Should().Be(SignatureAlgorithms.Ed25519);
|
||||
signResult.Value.KeyId.Should().Be(signingKey.KeyId);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeTrue();
|
||||
verifyResult.Value.Should().BeTrue();
|
||||
|
||||
var expectedKeyId = ComputeExpectedEd25519KeyId(Ed25519Public);
|
||||
signingKey.KeyId.Should().Be(expectedKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_Ed25519_InvalidSignature_ReturnsError()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var tamperedBytes = signResult.Value.Value.ToArray();
|
||||
tamperedBytes[0] ^= 0xFF;
|
||||
var tamperedSignature = new EnvelopeSignature(signResult.Value.KeyId, signResult.Value.AlgorithmId, tamperedBytes);
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, tamperedSignature, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.SignatureInvalid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignAndVerify_EcdsaEs256_Succeeds()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
|
||||
var verifyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, verifyKey);
|
||||
verifyResult.IsSuccess.Should().BeTrue();
|
||||
verifyResult.Value.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sign_WithVerificationOnlyKey_ReturnsMissingPrivateKey()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var verifyOnlyKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es256, in publicParameters);
|
||||
|
||||
var signResult = service.Sign(SamplePayload, verifyOnlyKey);
|
||||
|
||||
signResult.IsSuccess.Should().BeFalse();
|
||||
signResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.MissingPrivateKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMismatchedKeyId_ReturnsError()
|
||||
{
|
||||
var signingKey = EnvelopeKey.CreateEd25519Signer(Ed25519Seed, Ed25519Public);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var alternateKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public, "sha256:alternate");
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, alternateKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.KeyIdMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithInvalidSignatureLength_ReturnsFormatError()
|
||||
{
|
||||
var verifyKey = EnvelopeKey.CreateEd25519Verifier(Ed25519Public);
|
||||
var invalidSignature = new EnvelopeSignature(verifyKey.KeyId, verifyKey.AlgorithmId, new byte[16]);
|
||||
|
||||
var verifyResult = service.Verify(SamplePayload, invalidSignature, verifyKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.InvalidSignatureFormat);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithAlgorithmMismatch_ReturnsError()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var privateParameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var publicParameters = ecdsa.ExportParameters(includePrivateParameters: false);
|
||||
var signingKey = EnvelopeKey.CreateEcdsaSigner(SignatureAlgorithms.Es256, in privateParameters);
|
||||
var signResult = service.Sign(SamplePayload, signingKey);
|
||||
signResult.IsSuccess.Should().BeTrue();
|
||||
|
||||
var mismatchKey = EnvelopeKey.CreateEcdsaVerifier(SignatureAlgorithms.Es384, in publicParameters, signResult.Value.KeyId);
|
||||
var verifyResult = service.Verify(SamplePayload, signResult.Value, mismatchKey);
|
||||
|
||||
verifyResult.IsSuccess.Should().BeFalse();
|
||||
verifyResult.Error.Code.Should().Be(EnvelopeSignatureErrorCode.AlgorithmMismatch);
|
||||
}
|
||||
|
||||
private static string ComputeExpectedEd25519KeyId(byte[] publicKey)
|
||||
{
|
||||
var jwk = $"{{\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"{ToBase64Url(publicKey)}\"}}";
|
||||
using var sha = SHA256.Create();
|
||||
var digest = sha.ComputeHash(Encoding.UTF8.GetBytes(jwk));
|
||||
return $"sha256:{ToBase64Url(digest)}";
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] bytes)
|
||||
=> Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="__Tests\**\*.cs" />
|
||||
<Compile Remove="StellaOps.Attestor.Envelope.Tests\**\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,13 +1,13 @@
|
||||
# Attestation Envelope Task Board — Epic 19: Attestor Console
|
||||
|
||||
## Sprint 72 – Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTEST-ENVELOPE-72-001 | TODO | Envelope Guild | — | Implement DSSE canonicalization, JSON normalization, multi-signature structures, and hashing helpers. | Canonicalization deterministic (property tests); hash matches DSSE spec; unit tests green. |
|
||||
| ATTEST-ENVELOPE-72-002 | TODO | Envelope Guild | ATTEST-ENVELOPE-72-001 | Support compact and expanded JSON output, payload compression, and detached payload references. | API returns both variants; payload compression toggles tested; docs updated. |
|
||||
|
||||
## Sprint 73 – Crypto Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTEST-ENVELOPE-73-001 | TODO | Envelope Guild, KMS Guild | ATTEST-ENVELOPE-72-001 | Implement Ed25519 & ECDSA signature create/verify helpers, key identification (`keyid`) scheme, and error mapping. | Sign/verify tests pass with fixtures; invalid signatures produce deterministic errors. |
|
||||
| ATTEST-ENVELOPE-73-002 | TODO | Envelope Guild | ATTEST-ENVELOPE-73-001 | Add fuzz tests for envelope parsing, signature verification, and canonical JSON round-trips. | Fuzz suite integrated; coverage metrics recorded; no regressions. |
|
||||
# Attestation Envelope Task Board — Epic 19: Attestor Console
|
||||
|
||||
## Sprint 72 – Foundations
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTEST-ENVELOPE-72-001 | DONE (2025-11-01) | Envelope Guild | — | Implement DSSE canonicalization, JSON normalization, multi-signature structures, and hashing helpers. | Canonicalization deterministic (property tests); hash matches DSSE spec; unit tests green. |
|
||||
| ATTEST-ENVELOPE-72-002 | DONE | Envelope Guild | ATTEST-ENVELOPE-72-001 | Support compact and expanded JSON output, payload compression, and detached payload references. | API returns both variants; payload compression toggles tested; docs updated. |
|
||||
|
||||
## Sprint 73 – Crypto Integration
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| ATTEST-ENVELOPE-73-001 | DONE | Envelope Guild, KMS Guild | ATTEST-ENVELOPE-72-001 | Implement Ed25519 & ECDSA signature create/verify helpers, key identification (`keyid`) scheme, and error mapping. | Sign/verify tests pass with fixtures; invalid signatures produce deterministic errors. |
|
||||
| ATTEST-ENVELOPE-73-002 | DONE | Envelope Guild | ATTEST-ENVELOPE-73-001 | Add fuzz tests for envelope parsing, signature verification, and canonical JSON round-trips. | Fuzz suite integrated; coverage metrics recorded; no regressions. |
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.Attestor.Envelope.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeSerializerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_WithDefaultOptions_ProducesCompactAndExpandedJson()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"foo\":\"bar\"}");
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") },
|
||||
"application/json");
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope);
|
||||
|
||||
Assert.NotNull(result.CompactJson);
|
||||
Assert.NotNull(result.ExpandedJson);
|
||||
|
||||
var compact = Encoding.UTF8.GetString(result.CompactJson!);
|
||||
Assert.Equal("{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"eyJmb28iOiJiYXIifQ==\",\"signatures\":[{\"sig\":\"AQID\"}]}", compact);
|
||||
|
||||
using var expanded = JsonDocument.Parse(result.ExpandedJson!);
|
||||
var root = expanded.RootElement;
|
||||
|
||||
Assert.Equal("application/vnd.in-toto+json", root.GetProperty("payloadType").GetString());
|
||||
Assert.Equal("eyJmb28iOiJiYXIifQ==", root.GetProperty("payload").GetString());
|
||||
Assert.Equal("AQID", root.GetProperty("signatures")[0].GetProperty("sig").GetString());
|
||||
|
||||
var info = root.GetProperty("payloadInfo");
|
||||
Assert.Equal(payload.Length, info.GetProperty("length").GetInt32());
|
||||
Assert.Equal(result.PayloadSha256, info.GetProperty("sha256").GetString());
|
||||
Assert.False(info.TryGetProperty("compression", out _));
|
||||
|
||||
var preview = root.GetProperty("payloadPreview");
|
||||
Assert.Equal("application/json", preview.GetProperty("mediaType").GetString());
|
||||
Assert.Equal("bar", preview.GetProperty("json").GetProperty("foo").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WithCompressionEnabled_EmbedsCompressedPayloadMetadata()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{\"foo\":\"bar\",\"count\":1}");
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") },
|
||||
"application/json");
|
||||
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WithDetachedReference_WritesMetadata()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("detached payload preview");
|
||||
var reference = new DsseDetachedPayloadReference(
|
||||
"https://evidence.example.com/sbom.json",
|
||||
"abc123",
|
||||
payload.Length,
|
||||
"application/json");
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") },
|
||||
"text/plain",
|
||||
reference);
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope);
|
||||
|
||||
Assert.NotNull(result.ExpandedJson);
|
||||
|
||||
using var expanded = JsonDocument.Parse(result.ExpandedJson!);
|
||||
var detached = expanded.RootElement.GetProperty("detachedPayload");
|
||||
|
||||
Assert.Equal(reference.Uri, detached.GetProperty("uri").GetString());
|
||||
Assert.Equal(reference.Sha256, detached.GetProperty("sha256").GetString());
|
||||
Assert.Equal(reference.Length, detached.GetProperty("length").GetInt64());
|
||||
Assert.Equal(reference.MediaType, detached.GetProperty("mediaType").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_CompactOnly_SkipsExpandedPayload()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("payload");
|
||||
var envelope = new DsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
payload,
|
||||
new[] { new DsseSignature("AQID") });
|
||||
|
||||
var options = new DsseEnvelopeSerializationOptions
|
||||
{
|
||||
EmitExpandedJson = false
|
||||
};
|
||||
|
||||
var result = DsseEnvelopeSerializer.Serialize(envelope, options);
|
||||
|
||||
Assert.NotNull(result.CompactJson);
|
||||
Assert.Null(result.ExpandedJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1504</WarningsNotAsErrors>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FsCheck.Xunit" Version="3.3.1" />
|
||||
<PackageReference Include="FsCheck" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="DsseEnvelopeFuzzTests.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\..\\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user