using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using StellaOps.DeltaVerdict.Models; using StellaOps.DeltaVerdict.Serialization; namespace StellaOps.DeltaVerdict.Signing; public interface IDeltaSigningService { Task SignAsync( DeltaVerdict.Models.DeltaVerdict delta, SigningOptions options, CancellationToken ct = default); Task VerifyAsync( DeltaVerdict.Models.DeltaVerdict delta, VerificationOptions options, CancellationToken ct = default); } public sealed class DeltaSigningService : IDeltaSigningService { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; public Task SignAsync( DeltaVerdict.Models.DeltaVerdict delta, SigningOptions options, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(delta); ArgumentNullException.ThrowIfNull(options); ct.ThrowIfCancellationRequested(); var withDigest = DeltaVerdictSerializer.WithDigest(delta); var payloadJson = DeltaVerdictSerializer.Serialize(withDigest with { Signature = null }); var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); var envelope = BuildEnvelope(payloadBytes, options); var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions); return Task.FromResult(withDigest with { Signature = envelopeJson }); } public Task VerifyAsync( DeltaVerdict.Models.DeltaVerdict delta, VerificationOptions options, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(delta); ArgumentNullException.ThrowIfNull(options); ct.ThrowIfCancellationRequested(); if (string.IsNullOrEmpty(delta.Signature)) { return Task.FromResult(VerificationResult.Fail("Delta is not signed")); } DsseEnvelope? envelope; try { envelope = JsonSerializer.Deserialize(delta.Signature, JsonOptions); } catch (JsonException ex) { return Task.FromResult(VerificationResult.Fail($"Invalid signature envelope: {ex.Message}")); } if (envelope is null) { return Task.FromResult(VerificationResult.Fail("Signature envelope is empty")); } var payloadBytes = Convert.FromBase64String(envelope.Payload); var pae = BuildPae(envelope.PayloadType, payloadBytes); var expectedSig = ComputeSignature(pae, options); var matched = envelope.Signatures.Any(sig => string.Equals(sig.KeyId, options.KeyId, StringComparison.Ordinal) && string.Equals(sig.Sig, expectedSig, StringComparison.Ordinal)); if (!matched) { return Task.FromResult(VerificationResult.Fail("Signature verification failed")); } if (!string.IsNullOrEmpty(delta.DeltaDigest)) { var computed = DeltaVerdictSerializer.ComputeDigest(delta); if (!string.Equals(computed, delta.DeltaDigest, StringComparison.OrdinalIgnoreCase)) { return Task.FromResult(VerificationResult.Fail("Delta digest mismatch")); } } return Task.FromResult(VerificationResult.Success()); } private static DsseEnvelope BuildEnvelope(byte[] payload, SigningOptions options) { var pae = BuildPae(options.PayloadType, payload); var signature = ComputeSignature(pae, options); return new DsseEnvelope( options.PayloadType, Convert.ToBase64String(payload), [new DsseSignature(options.KeyId, signature)]); } private static string ComputeSignature(byte[] pae, SigningOptions options) { return ComputeSignatureCore(pae, options.Algorithm, options.SecretBase64); } private static string ComputeSignature(byte[] pae, VerificationOptions options) { return ComputeSignatureCore(pae, options.Algorithm, options.SecretBase64); } private static string ComputeSignatureCore(byte[] pae, SigningAlgorithm algorithm, string? secretBase64) { return algorithm switch { SigningAlgorithm.HmacSha256 => ComputeHmac(pae, secretBase64), SigningAlgorithm.Sha256 => Convert.ToBase64String(SHA256.HashData(pae)), _ => throw new InvalidOperationException($"Unsupported signing algorithm: {algorithm}") }; } private static string ComputeHmac(byte[] data, string? secretBase64) { if (string.IsNullOrWhiteSpace(secretBase64)) { throw new InvalidOperationException("HMAC signing requires a base64 secret."); } var secret = Convert.FromBase64String(secretBase64); using var hmac = new HMACSHA256(secret); var sig = hmac.ComputeHash(data); return Convert.ToBase64String(sig); } private static byte[] BuildPae(string payloadType, byte[] payload) { var prefix = "DSSEv1"; var typeBytes = Encoding.UTF8.GetBytes(payloadType); var prefixBytes = Encoding.UTF8.GetBytes(prefix); var lengthType = Encoding.UTF8.GetBytes(typeBytes.Length.ToString()); var lengthPayload = Encoding.UTF8.GetBytes(payload.Length.ToString()); using var stream = new MemoryStream(); stream.Write(prefixBytes); stream.WriteByte((byte)' '); stream.Write(lengthType); stream.WriteByte((byte)' '); stream.Write(typeBytes); stream.WriteByte((byte)' '); stream.Write(lengthPayload); stream.WriteByte((byte)' '); stream.Write(payload); return stream.ToArray(); } } public sealed record SigningOptions { public required string KeyId { get; init; } public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.HmacSha256; public string? SecretBase64 { get; init; } public string PayloadType { get; init; } = "application/vnd.stellaops.delta-verdict+json"; } public sealed record VerificationOptions { public required string KeyId { get; init; } public SigningAlgorithm Algorithm { get; init; } = SigningAlgorithm.HmacSha256; public string? SecretBase64 { get; init; } } public enum SigningAlgorithm { HmacSha256, Sha256 } public sealed record VerificationResult { public required bool IsValid { get; init; } public string? Error { get; init; } public static VerificationResult Success() => new() { IsValid = true }; public static VerificationResult Fail(string error) => new() { IsValid = false, Error = error }; } public sealed record DsseEnvelope( string PayloadType, string Payload, IReadOnlyList Signatures); public sealed record DsseSignature(string KeyId, string Sig);