Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/SuppressionDsseSigner.cs

169 lines
6.6 KiB
C#

using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Envelope;
using StellaOps.Canonical.Json;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Service for creating and verifying DSSE-signed suppression witness envelopes.
/// Sprint: SPRINT_20260106_001_002 (SUP-015)
/// </summary>
public sealed class SuppressionDsseSigner : ISuppressionDsseSigner
{
private readonly EnvelopeSignatureService _signatureService;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.Default
};
/// <summary>
/// Creates a new SuppressionDsseSigner with the specified signature service.
/// </summary>
public SuppressionDsseSigner(EnvelopeSignatureService signatureService)
{
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
}
/// <summary>
/// Creates a new SuppressionDsseSigner with a default signature service.
/// </summary>
public SuppressionDsseSigner() : this(new EnvelopeSignatureService())
{
}
/// <inheritdoc />
public SuppressionDsseResult SignWitness(SuppressionWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(witness);
ArgumentNullException.ThrowIfNull(signingKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Serialize witness to canonical JSON bytes
var payloadBytes = CanonJson.Canonicalize(witness, CanonicalJsonOptions);
// Sign DSSE payload using the shared PAE helper
var signResult = _signatureService.SignDsse(SuppressionWitnessSchema.DssePayloadType, payloadBytes, signingKey, cancellationToken);
if (!signResult.IsSuccess)
{
return SuppressionDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
}
var signature = signResult.Value;
// Create the DSSE envelope
var dsseSignature = new DsseSignature(
signature: Convert.ToBase64String(signature.Value.Span),
keyId: signature.KeyId);
var envelope = new DsseEnvelope(
payloadType: SuppressionWitnessSchema.DssePayloadType,
payload: payloadBytes,
signatures: [dsseSignature]);
return SuppressionDsseResult.Success(envelope, payloadBytes);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
return SuppressionDsseResult.Failure($"Failed to create DSSE envelope: {ex.Message}");
}
}
/// <inheritdoc />
public SuppressionVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(publicKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Verify payload type
if (!string.Equals(envelope.PayloadType, SuppressionWitnessSchema.DssePayloadType, StringComparison.Ordinal))
{
return SuppressionVerifyResult.Failure($"Invalid payload type: expected '{SuppressionWitnessSchema.DssePayloadType}', got '{envelope.PayloadType}'");
}
// Deserialize the witness from payload
var witness = JsonSerializer.Deserialize<SuppressionWitness>(envelope.Payload.Span, CanonicalJsonOptions);
if (witness is null)
{
return SuppressionVerifyResult.Failure("Failed to deserialize witness from payload");
}
// Verify schema version
if (!string.Equals(witness.WitnessSchema, SuppressionWitnessSchema.Version, StringComparison.Ordinal))
{
return SuppressionVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
}
// Find signature matching the public key
var matchingSignature = envelope.Signatures.FirstOrDefault(
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
if (matchingSignature is null)
{
return SuppressionVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
}
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
var verifyResult = _signatureService.VerifyDsse(envelope.PayloadType, envelope.Payload.Span, envelopeSignature, publicKey, cancellationToken);
if (!verifyResult.IsSuccess)
{
return SuppressionVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
}
return SuppressionVerifyResult.Success(witness, matchingSignature.KeyId!);
}
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
{
return SuppressionVerifyResult.Failure($"Verification failed: {ex.Message}");
}
}
}
/// <summary>
/// Result of DSSE signing a suppression witness.
/// </summary>
public sealed record SuppressionDsseResult
{
public bool IsSuccess { get; init; }
public DsseEnvelope? Envelope { get; init; }
public byte[]? PayloadBytes { get; init; }
public string? Error { get; init; }
public static SuppressionDsseResult Success(DsseEnvelope envelope, byte[] payloadBytes)
=> new() { IsSuccess = true, Envelope = envelope, PayloadBytes = payloadBytes };
public static SuppressionDsseResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}
/// <summary>
/// Result of verifying a DSSE-signed suppression witness.
/// </summary>
public sealed record SuppressionVerifyResult
{
public bool IsSuccess { get; init; }
public SuppressionWitness? Witness { get; init; }
public string? VerifiedKeyId { get; init; }
public string? Error { get; init; }
public static SuppressionVerifyResult Success(SuppressionWitness witness, string keyId)
=> new() { IsSuccess = true, Witness = witness, VerifiedKeyId = keyId };
public static SuppressionVerifyResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}