sprints and audit work
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
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(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <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 = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
|
||||
|
||||
// Build the PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = BuildPae(SuppressionWitnessSchema.DssePayloadType, payloadBytes);
|
||||
|
||||
// Sign the PAE
|
||||
var signResult = _signatureService.Sign(pae, 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}");
|
||||
}
|
||||
|
||||
// Build PAE and verify signature
|
||||
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
|
||||
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
|
||||
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.Verify(pae, 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>
|
||||
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
|
||||
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
|
||||
/// </summary>
|
||||
private static byte[] BuildPae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
|
||||
|
||||
// Write "DSSEv1 "
|
||||
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
|
||||
|
||||
// Write len(type) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, typeBytes.Length);
|
||||
|
||||
// Write type followed by space
|
||||
writer.Write(typeBytes);
|
||||
writer.Write((byte)' ');
|
||||
|
||||
// Write len(payload) as ASCII decimal string followed by space
|
||||
WriteLengthAndSpace(writer, payload.Length);
|
||||
|
||||
// Write payload
|
||||
writer.Write(payload);
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
|
||||
{
|
||||
// Write length as ASCII decimal string
|
||||
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
|
||||
writer.Write((byte)' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 };
|
||||
}
|
||||
Reference in New Issue
Block a user