chore(sprints): archive 20260226 advisories and expand deterministic tests
This commit is contained in:
@@ -8,6 +8,9 @@ using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.X509;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestation.Tests;
|
||||
@@ -44,6 +47,33 @@ public class DsseVerifierTests
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidRsaSignature_ReturnsSuccess()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var (envelope, publicKeyPem) = CreateSignedEnvelope(rsa);
|
||||
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ValidSignatureCount.Should().Be(1);
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidEd25519Signature_ReturnsSuccess()
|
||||
{
|
||||
var seed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray();
|
||||
var privateKey = new Ed25519PrivateKeyParameters(seed, 0);
|
||||
var (envelope, publicKeyPem) = CreateSignedEnvelope(privateKey);
|
||||
|
||||
var result = await _verifier.VerifyAsync(envelope, publicKeyPem, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.ValidSignatureCount.Should().Be(1);
|
||||
result.Issues.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidSignature_ReturnsFail()
|
||||
{
|
||||
@@ -64,6 +94,39 @@ public class DsseVerifierTests
|
||||
result.Issues.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidRsaSignature_ReturnsDeterministicReason()
|
||||
{
|
||||
using var signingKey = RSA.Create(2048);
|
||||
var (envelope, _) = CreateSignedEnvelope(signingKey);
|
||||
|
||||
using var verifierKey = RSA.Create(2048);
|
||||
var verifierPem = ExportPublicKeyPem(verifierKey);
|
||||
|
||||
var result = await _verifier.VerifyAsync(envelope, verifierPem, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(issue => issue.Contains("signature_mismatch", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithInvalidEd25519Signature_ReturnsDeterministicReason()
|
||||
{
|
||||
var signingSeed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 1)).ToArray();
|
||||
var verifyingSeed = Enumerable.Range(0, 32).Select(static i => (byte)(i + 101)).ToArray();
|
||||
|
||||
var signingKey = new Ed25519PrivateKeyParameters(signingSeed, 0);
|
||||
var verifyingKey = new Ed25519PrivateKeyParameters(verifyingSeed, 0);
|
||||
|
||||
var (envelope, _) = CreateSignedEnvelope(signingKey);
|
||||
var verifyingPem = ExportPublicKeyPem(verifyingKey.GeneratePublicKey());
|
||||
|
||||
var result = await _verifier.VerifyAsync(envelope, verifyingPem, TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Issues.Should().Contain(issue => issue.Contains("signature_mismatch", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithMalformedJson_ReturnsParseError()
|
||||
{
|
||||
@@ -277,9 +340,77 @@ public class DsseVerifierTests
|
||||
return (envelope, publicKeyPem);
|
||||
}
|
||||
|
||||
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(RSA 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);
|
||||
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
var signatureBytes = signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
var envelope = JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType,
|
||||
payload = payloadBase64,
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyId = "test-key-rsa-001", sig = signatureBase64 }
|
||||
}
|
||||
});
|
||||
|
||||
return (envelope, ExportPublicKeyPem(signingKey));
|
||||
}
|
||||
|
||||
private static (string EnvelopeJson, string PublicKeyPem) CreateSignedEnvelope(Ed25519PrivateKeyParameters 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);
|
||||
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
|
||||
var signer = new Ed25519Signer();
|
||||
signer.Init(true, signingKey);
|
||||
signer.BlockUpdate(pae, 0, pae.Length);
|
||||
var signatureBytes = signer.GenerateSignature();
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
var envelope = JsonSerializer.Serialize(new
|
||||
{
|
||||
payloadType,
|
||||
payload = payloadBase64,
|
||||
signatures = new[]
|
||||
{
|
||||
new { keyId = "test-key-ed25519-001", sig = signatureBase64 }
|
||||
}
|
||||
});
|
||||
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey.GeneratePublicKey());
|
||||
return (envelope, publicKeyPem);
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(ECDsa key)
|
||||
{
|
||||
var publicKeyBytes = key.ExportSubjectPublicKeyInfo();
|
||||
return ExportPublicKeyPem(key.ExportSubjectPublicKeyInfo());
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(RSA key)
|
||||
{
|
||||
return ExportPublicKeyPem(key.ExportSubjectPublicKeyInfo());
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(Ed25519PublicKeyParameters key)
|
||||
{
|
||||
var publicKeyBytes = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(key).GetDerEncoded();
|
||||
return ExportPublicKeyPem(publicKeyBytes);
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(byte[] publicKeyBytes)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(publicKeyBytes);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("-----BEGIN PUBLIC KEY-----");
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Tests.Fixtures;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class VerdictControllerSecurityTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerdict_WithTrustedRosterKey_ReturnsCreated_AndGetByHashResolves()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("trusted-key", "trusted", publicKeyPem)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var createResponse = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
var createPayload = await createResponse.Content.ReadFromJsonAsync<VerdictAttestationResponseDto>();
|
||||
Assert.NotNull(createPayload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(createPayload!.VerdictId));
|
||||
|
||||
var getResponse = await client.GetAsync($"/api/v1/verdicts/{createPayload.VerdictId}");
|
||||
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
|
||||
var getPayload = await getResponse.Content.ReadFromJsonAsync<VerdictLookupResponseDto>();
|
||||
Assert.NotNull(getPayload);
|
||||
Assert.Equal(createPayload.VerdictId, getPayload!.VerdictId);
|
||||
Assert.Equal("test-tenant", getPayload.TenantId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerdict_WithUnknownRosterKey_ReturnsForbidden()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("trusted-key", "trusted", publicKeyPem)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "unknown-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("unknown-key"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal("authority_key_unknown", await ReadProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerdict_WithRevokedRosterKey_ReturnsForbidden()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("revoked-key", "revoked", publicKeyPem)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "revoked-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("revoked-key"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal("authority_key_revoked", await ReadProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CreateVerdict_WithRosterKeyMissingPublicMaterial_ReturnsDeterministicServerError()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("trusted-key", "trusted", string.Empty)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Equal("authority_key_missing_public_key", await ReadProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task VerdictEndpoints_WithTenantHeaderSpoofing_ReturnForbidden()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("trusted-key", "trusted", publicKeyPem)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "spoofed-tenant");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync("/internal/api/v1/attestations/verdict", BuildRequest("trusted-key"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, createResponse.StatusCode);
|
||||
Assert.Equal("tenant_mismatch", await ReadProblemCodeAsync(createResponse));
|
||||
|
||||
var getResponse = await client.GetAsync("/api/v1/verdicts/verdict-missing");
|
||||
Assert.Equal(HttpStatusCode.Forbidden, getResponse.StatusCode);
|
||||
Assert.Equal("tenant_mismatch", await ReadProblemCodeAsync(getResponse));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetVerdictByHash_WhenMissing_ReturnsNotFound()
|
||||
{
|
||||
using var signingKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var publicKeyPem = ExportPublicKeyPem(signingKey);
|
||||
|
||||
using var factory = CreateFactory(
|
||||
rosterEntries: new[]
|
||||
{
|
||||
new RosterEntry("trusted-key", "trusted", publicKeyPem)
|
||||
},
|
||||
new DeterministicSigningService(signingKey, keyId: "trusted-key"));
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/verdicts/verdict-not-found");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("verdict_not_found", await ReadProblemCodeAsync(response));
|
||||
}
|
||||
|
||||
private static VerdictAttestationRequestDto BuildRequest(string keyId) => new()
|
||||
{
|
||||
PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1",
|
||||
Predicate = "{\"verdict\":{\"status\":\"pass\",\"severity\":\"low\",\"score\":0.1},\"metadata\":{\"policyRunId\":\"run-1\",\"policyId\":\"policy-1\",\"policyVersion\":1,\"evaluatedAt\":\"2026-02-26T00:00:00Z\"}}",
|
||||
Subject = new VerdictSubjectDto
|
||||
{
|
||||
Name = "finding-1"
|
||||
},
|
||||
KeyId = keyId
|
||||
};
|
||||
|
||||
private static WebApplicationFactory<Program> CreateFactory(
|
||||
IReadOnlyList<RosterEntry> rosterEntries,
|
||||
IAttestationSigningService signingService)
|
||||
{
|
||||
var baseFactory = new AttestorTestWebApplicationFactory();
|
||||
return baseFactory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>();
|
||||
for (var i = 0; i < rosterEntries.Count; i++)
|
||||
{
|
||||
settings[$"attestor:verdictTrust:keys:{i}:keyId"] = rosterEntries[i].KeyId;
|
||||
settings[$"attestor:verdictTrust:keys:{i}:status"] = rosterEntries[i].Status;
|
||||
settings[$"attestor:verdictTrust:keys:{i}:publicKeyPem"] = rosterEntries[i].PublicKeyPem;
|
||||
}
|
||||
|
||||
configuration.AddInMemoryCollection(settings);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IAttestationSigningService>();
|
||||
services.RemoveAll<IHttpClientFactory>();
|
||||
services.AddSingleton(signingService);
|
||||
services.AddSingleton<IHttpClientFactory>(new StubEvidenceLockerHttpClientFactory());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
|
||||
{
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
if (payload.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("code", out var directCode) && directCode.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return directCode.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExportPublicKeyPem(ECDsa key)
|
||||
{
|
||||
var publicKeyDer = key.ExportSubjectPublicKeyInfo();
|
||||
var base64 = Convert.ToBase64String(publicKeyDer);
|
||||
return $"-----BEGIN PUBLIC KEY-----\n{base64}\n-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
private readonly record struct RosterEntry(string KeyId, string Status, string PublicKeyPem);
|
||||
|
||||
private sealed class DeterministicSigningService : IAttestationSigningService
|
||||
{
|
||||
private readonly ECDsa _signingKey;
|
||||
private readonly string _keyId;
|
||||
|
||||
public DeterministicSigningService(ECDsa signingKey, string keyId)
|
||||
{
|
||||
_signingKey = signingKey;
|
||||
_keyId = keyId;
|
||||
}
|
||||
|
||||
public Task<AttestationSignResult> SignAsync(
|
||||
AttestationSignRequest request,
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(request.PayloadBase64);
|
||||
var pae = DssePreAuthenticationEncoding.Compute(request.PayloadType, payloadBytes);
|
||||
var signature = _signingKey.SignData(pae, HashAlgorithmName.SHA256);
|
||||
|
||||
var result = new AttestationSignResult
|
||||
{
|
||||
KeyId = _keyId,
|
||||
Algorithm = "ES256",
|
||||
Mode = "keyful",
|
||||
Provider = "unit-test",
|
||||
SignedAt = DateTimeOffset.UnixEpoch,
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = request.PayloadType,
|
||||
PayloadBase64 = request.PayloadBase64,
|
||||
Signatures = new List<AttestorSubmissionRequest.DsseSignature>
|
||||
{
|
||||
new()
|
||||
{
|
||||
KeyId = _keyId,
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
}
|
||||
}
|
||||
},
|
||||
Mode = "keyful",
|
||||
CertificateChain = Array.Empty<string>()
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "verdict"
|
||||
},
|
||||
BundleSha256 = Convert.ToHexString(SHA256.HashData(payloadBytes)).ToLowerInvariant(),
|
||||
Archive = true,
|
||||
LogPreference = "primary"
|
||||
}
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubEvidenceLockerHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client = new(new StubEvidenceLockerHandler())
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost:5200", UriKind.Absolute),
|
||||
Timeout = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
|
||||
private sealed class StubEvidenceLockerHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Method == HttpMethod.Post &&
|
||||
request.RequestUri is { AbsolutePath: "/api/v1/verdicts" })
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Get &&
|
||||
request.RequestUri is { AbsolutePath: var path } &&
|
||||
path.StartsWith("/api/v1/verdicts/", StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user