save progress
This commit is contained in:
301
src/Attestor/StellaOps.Attestation/DsseVerifier.cs
Normal file
301
src/Attestor/StellaOps.Attestation/DsseVerifier.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
// <copyright file="DsseVerifier.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of DSSE signature verification.
|
||||
/// Uses the existing DsseHelper for PAE computation.
|
||||
/// </summary>
|
||||
public sealed class DsseVerifier : IDsseVerifier
|
||||
{
|
||||
private readonly ILogger<DsseVerifier> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options for parsing DSSE envelopes.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public DsseVerifier(ILogger<DsseVerifier> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
string publicKeyPem,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return VerifyAsync(envelopeJson, new[] { publicKeyPem }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
IEnumerable<string> trustedKeysPem,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(envelopeJson);
|
||||
ArgumentNullException.ThrowIfNull(trustedKeysPem);
|
||||
|
||||
var trustedKeys = trustedKeysPem.ToList();
|
||||
if (trustedKeys.Count == 0)
|
||||
{
|
||||
return DsseVerificationResult.Failure(0, ImmutableArray.Create("no_trusted_keys_provided"));
|
||||
}
|
||||
|
||||
return await VerifyWithAllKeysAsync(envelopeJson, trustedKeys, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
Func<string?, CancellationToken, Task<string?>> keyResolver,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(envelopeJson);
|
||||
ArgumentNullException.ThrowIfNull(keyResolver);
|
||||
|
||||
// Parse the envelope
|
||||
DsseEnvelopeDto? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<DsseEnvelopeDto>(envelopeJson, JsonOptions);
|
||||
if (envelope is null)
|
||||
{
|
||||
return DsseVerificationResult.ParseError("Failed to deserialize envelope");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse DSSE envelope JSON");
|
||||
return DsseVerificationResult.ParseError(ex.Message);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(envelope.Payload))
|
||||
{
|
||||
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_payload"));
|
||||
}
|
||||
|
||||
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
|
||||
{
|
||||
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_signatures"));
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return DsseVerificationResult.Failure(envelope.Signatures.Count, ImmutableArray.Create("payload_invalid_base64"));
|
||||
}
|
||||
|
||||
// Compute PAE for signature verification
|
||||
var payloadType = envelope.PayloadType ?? "https://in-toto.io/Statement/v1";
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
|
||||
// Verify each signature
|
||||
var verifiedKeyIds = new List<string>();
|
||||
var issues = new List<string>();
|
||||
|
||||
foreach (var sig in envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sig.Sig))
|
||||
{
|
||||
issues.Add($"signature_{sig.KeyId ?? "unknown"}_empty");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the public key for this signature
|
||||
var publicKeyPem = await keyResolver(sig.KeyId, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
issues.Add($"key_not_found_{sig.KeyId ?? "unknown"}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(sig.Sig);
|
||||
if (VerifySignature(pae, signatureBytes, publicKeyPem))
|
||||
{
|
||||
verifiedKeyIds.Add(sig.KeyId ?? "unknown");
|
||||
_logger.LogDebug("DSSE signature verified for keyId: {KeyId}", sig.KeyId ?? "unknown");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}");
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
issues.Add($"signature_invalid_base64_{sig.KeyId ?? "unknown"}");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
issues.Add($"signature_crypto_error_{sig.KeyId ?? "unknown"}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compute payload hash for result
|
||||
var payloadHash = $"sha256:{Convert.ToHexString(SHA256.HashData(payloadBytes)).ToLowerInvariant()}";
|
||||
|
||||
if (verifiedKeyIds.Count > 0)
|
||||
{
|
||||
return DsseVerificationResult.Success(
|
||||
verifiedKeyIds.Count,
|
||||
envelope.Signatures.Count,
|
||||
verifiedKeyIds.ToImmutableArray(),
|
||||
payloadType,
|
||||
payloadHash);
|
||||
}
|
||||
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidSignatureCount = 0,
|
||||
TotalSignatureCount = envelope.Signatures.Count,
|
||||
VerifiedKeyIds = ImmutableArray<string>.Empty,
|
||||
PayloadType = payloadType,
|
||||
PayloadHash = payloadHash,
|
||||
Issues = issues.ToImmutableArray(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies against all trusted keys, returning success if any key validates any signature.
|
||||
/// </summary>
|
||||
private async Task<DsseVerificationResult> VerifyWithAllKeysAsync(
|
||||
string envelopeJson,
|
||||
List<string> trustedKeys,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Parse envelope first to get signature keyIds
|
||||
DsseEnvelopeDto? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<DsseEnvelopeDto>(envelopeJson, JsonOptions);
|
||||
if (envelope is null)
|
||||
{
|
||||
return DsseVerificationResult.ParseError("Failed to deserialize envelope");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return DsseVerificationResult.ParseError(ex.Message);
|
||||
}
|
||||
|
||||
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
|
||||
{
|
||||
return DsseVerificationResult.Failure(0, ImmutableArray.Create("envelope_missing_signatures"));
|
||||
}
|
||||
|
||||
// Try each trusted key
|
||||
var allIssues = new List<string>();
|
||||
foreach (var key in trustedKeys)
|
||||
{
|
||||
var keyIndex = trustedKeys.IndexOf(key);
|
||||
|
||||
async Task<string?> SingleKeyResolver(string? keyId, CancellationToken ct)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
return key;
|
||||
}
|
||||
|
||||
var result = await VerifyAsync(envelopeJson, SingleKeyResolver, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsValid)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Collect issues for debugging
|
||||
foreach (var issue in result.Issues)
|
||||
{
|
||||
allIssues.Add($"key{keyIndex}: {issue}");
|
||||
}
|
||||
}
|
||||
|
||||
return DsseVerificationResult.Failure(envelope.Signatures.Count, allIssues.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature against PAE using the provided public key.
|
||||
/// Supports ECDSA P-256 and RSA keys.
|
||||
/// </summary>
|
||||
private bool VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
|
||||
{
|
||||
// Try ECDSA first (most common for Sigstore/Fulcio)
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(publicKeyPem);
|
||||
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Not an ECDSA key, try RSA
|
||||
}
|
||||
|
||||
// Try RSA
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Not an RSA key either
|
||||
}
|
||||
|
||||
// Try Ed25519 if available (.NET 9+)
|
||||
try
|
||||
{
|
||||
// Ed25519 support via System.Security.Cryptography
|
||||
// Note: Ed25519 verification requires different handling
|
||||
// For now, we log and return false - can be extended later
|
||||
_logger.LogDebug("Ed25519 signature verification not yet implemented");
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ed25519 not available
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for deserializing DSSE envelope JSON.
|
||||
/// </summary>
|
||||
private sealed class DsseEnvelopeDto
|
||||
{
|
||||
public string? PayloadType { get; set; }
|
||||
public string? Payload { get; set; }
|
||||
public List<DsseSignatureDto>? Signatures { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for DSSE signature.
|
||||
/// </summary>
|
||||
private sealed class DsseSignatureDto
|
||||
{
|
||||
public string? KeyId { get; set; }
|
||||
public string? Sig { get; set; }
|
||||
}
|
||||
}
|
||||
151
src/Attestor/StellaOps.Attestation/IDsseVerifier.cs
Normal file
151
src/Attestor/StellaOps.Attestation/IDsseVerifier.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// <copyright file="IDsseVerifier.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for verifying DSSE (Dead Simple Signing Envelope) signatures.
|
||||
/// </summary>
|
||||
public interface IDsseVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope against a public key.
|
||||
/// </summary>
|
||||
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
|
||||
/// <param name="publicKeyPem">The PEM-encoded public key for verification.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result containing status and details.</returns>
|
||||
Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
string publicKeyPem,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope against multiple trusted public keys.
|
||||
/// Returns success if at least one signature is valid.
|
||||
/// </summary>
|
||||
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
|
||||
/// <param name="trustedKeysPem">Collection of PEM-encoded public keys.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result containing status and details.</returns>
|
||||
Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
IEnumerable<string> trustedKeysPem,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope using a key resolver function.
|
||||
/// </summary>
|
||||
/// <param name="envelopeJson">The serialized DSSE envelope JSON.</param>
|
||||
/// <param name="keyResolver">Function to resolve public key by key ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result containing status and details.</returns>
|
||||
Task<DsseVerificationResult> VerifyAsync(
|
||||
string envelopeJson,
|
||||
Func<string?, CancellationToken, Task<string?>> keyResolver,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of DSSE signature verification.
|
||||
/// </summary>
|
||||
public sealed record DsseVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification succeeded (at least one valid signature).
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of signatures that passed verification.
|
||||
/// </summary>
|
||||
public required int ValidSignatureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of signatures in the envelope.
|
||||
/// </summary>
|
||||
public required int TotalSignatureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key IDs of signatures that passed verification.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> VerifiedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used for the primary verified signature (first one that passed).
|
||||
/// </summary>
|
||||
public string? PrimaryKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload type from the envelope.
|
||||
/// </summary>
|
||||
public string? PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the payload.
|
||||
/// </summary>
|
||||
public string? PayloadHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Issues encountered during verification.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Issues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful verification result.
|
||||
/// </summary>
|
||||
public static DsseVerificationResult Success(
|
||||
int validCount,
|
||||
int totalCount,
|
||||
ImmutableArray<string> verifiedKeyIds,
|
||||
string? payloadType = null,
|
||||
string? payloadHash = null)
|
||||
{
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = true,
|
||||
ValidSignatureCount = validCount,
|
||||
TotalSignatureCount = totalCount,
|
||||
VerifiedKeyIds = verifiedKeyIds,
|
||||
PrimaryKeyId = verifiedKeyIds.Length > 0 ? verifiedKeyIds[0] : null,
|
||||
PayloadType = payloadType,
|
||||
PayloadHash = payloadHash,
|
||||
Issues = ImmutableArray<string>.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed verification result.
|
||||
/// </summary>
|
||||
public static DsseVerificationResult Failure(
|
||||
int totalCount,
|
||||
ImmutableArray<string> issues)
|
||||
{
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidSignatureCount = 0,
|
||||
TotalSignatureCount = totalCount,
|
||||
VerifiedKeyIds = ImmutableArray<string>.Empty,
|
||||
Issues = issues,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failure result for a parsing error.
|
||||
/// </summary>
|
||||
public static DsseVerificationResult ParseError(string message)
|
||||
{
|
||||
return new DsseVerificationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ValidSignatureCount = 0,
|
||||
TotalSignatureCount = 0,
|
||||
VerifiedKeyIds = ImmutableArray<string>.Empty,
|
||||
Issues = ImmutableArray.Create($"envelope_parse_error: {message}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user