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:
master
2025-11-02 13:40:38 +02:00
parent 66cb6c4b8a
commit f98cea3bcf
516 changed files with 68157 additions and 24754 deletions

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Attestor.Envelope;
public enum DsseCompressionAlgorithm
{
None = 0,
Gzip = 1,
Brotli = 2
}

View File

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

View 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; }
}

View File

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

View File

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

View File

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

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

View 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;
}
}

View File

@@ -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('/', '_');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('/', '_');
}

View File

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

View File

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

View File

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

View File

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

View File

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