save progress
This commit is contained in:
295
src/Attestor/StellaOps.Attestation.Tests/DsseVerifierTests.cs
Normal file
295
src/Attestor/StellaOps.Attestation.Tests/DsseVerifierTests.cs
Normal file
@@ -0,0 +1,295 @@
|
||||
// <copyright file="DsseVerifierTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for DsseVerifier.
|
||||
/// Sprint: SPRINT_20260105_002_001_REPLAY, Tasks RPL-006 through RPL-010.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class DsseVerifierTests
|
||||
{
|
||||
private readonly DsseVerifier _verifier;
|
||||
|
||||
public DsseVerifierTests()
|
||||
{
|
||||
_verifier = new DsseVerifier(NullLogger<DsseVerifier>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidEcdsaSignature_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ValidSignatureCount.Should().Be(1);
|
||||
result.TotalSignatureCount.Should().Be(1);
|
||||
result.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidSignature_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, _) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
// Use a different key for verification
|
||||
using var differentKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var differentPublicKeyPem = ExportPublicKeyPem(differentKey);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, differentPublicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.ValidSignatureCount.Should().Be(0);
|
||||
result.Issues.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithMalformedJson_ReturnsParseError()
|
||||
{
|
||||
// Arrange
|
||||
var malformedJson = "{ not valid json }";
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(malformedJson, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("envelope_parse_error"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithMissingPayload_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var envelope = JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType = "https://in-toto.io/Statement/v1",
|
||||
signatures = new[] { new { keyId = "key-001", sig = "YWJj" } }
|
||||
});
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("envelope_missing_payload"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithMissingSignatures_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}"));
|
||||
var envelope = JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType = "https://in-toto.io/Statement/v1",
|
||||
payload,
|
||||
signatures = Array.Empty<object>()
|
||||
});
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain("envelope_missing_signatures");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithNoTrustedKeys_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, _) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, Array.Empty<string>(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain("no_trusted_keys_provided");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithMultipleTrustedKeys_SucceedsWithMatchingKey()
|
||||
{
|
||||
// Arrange
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var otherKey1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var otherKey2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
var (envelope, signingKeyPem) = CreateSignedEnvelope(signingKey);
|
||||
|
||||
var trustedKeys = new[]
|
||||
{
|
||||
ExportPublicKeyPem(otherKey1),
|
||||
signingKeyPem,
|
||||
ExportPublicKeyPem(otherKey2),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, trustedKeys, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ValidSignatureCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithKeyResolver_UsesResolverForVerification()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
Task<string?> KeyResolver(string? keyId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<string?>(publicKeyPem);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, KeyResolver, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithKeyResolverReturningNull_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, _) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
static Task<string?> KeyResolver(string? keyId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, KeyResolver, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(i => i.Contains("key_not_found"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsPayloadHash()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, publicKeyPem) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.PayloadHash.Should().StartWith("sha256:");
|
||||
result.PayloadHash.Should().HaveLength("sha256:".Length + 64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ThrowsOnNullEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(ecdsa);
|
||||
|
||||
// Act & Assert - null envelope throws ArgumentNullException
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _verifier.VerifyAsync(null!, publicKeyPem, TestContext.Current.CancellationToken));
|
||||
|
||||
// Empty envelope throws ArgumentException (whitespace check)
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _verifier.VerifyAsync("", publicKeyPem, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ThrowsOnNullKeys()
|
||||
{
|
||||
// Arrange
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, _) = CreateSignedEnvelope(ecdsa);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _verifier.VerifyAsync(envelope, (IEnumerable<string>)null!, TestContext.Current.CancellationToken));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _verifier.VerifyAsync(envelope, (Func<string?, CancellationToken, Task<string?>>)null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(ECDsa signingKey)
|
||||
{
|
||||
var payloadType = "https://in-toto.io/Statement/v1";
|
||||
var payloadContent = "{\"_type\":\"https://in-toto.io/Statement/v1\",\"subject\":[]}";
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadContent);
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
// Compute PAE
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
|
||||
// Sign
|
||||
var signatureBytes = signingKey.SignData(pae, HashAlgorithmName.SHA256);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
// Build envelope
|
||||
var envelope = JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType,
|
||||
payload = payloadBase64,
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyId = "test-key-001", sig = signatureBase64 }
|
||||
}
|
||||
});
|
||||
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
return (envelope, publicKeyPem);
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(ECDsa key)
|
||||
{
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
var base64 = Convert.ToBase64String(publicKeyBytes);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
|
||||
for (var i = 0; i < base64.Length; i += 64)
|
||||
{
|
||||
builder.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
|
||||
}
|
||||
|
||||
builder.AppendLine("-----END PUBLIC KEY-----");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -25,6 +25,12 @@ using Xunit;
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for time skew validation in attestation submission and verification.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Evidence)]
|
||||
[Trait("BlastRadius", TestCategories.BlastRadius.Crypto)]
|
||||
public sealed class TimeSkewValidationIntegrationTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 12, 18, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
Reference in New Issue
Block a user