chore(sprints): archive 20260226 advisories and expand deterministic tests

This commit is contained in:
master
2026-03-04 03:09:23 +02:00
parent 4fe8eb56ae
commit aaad8104cb
35 changed files with 4686 additions and 1 deletions

View File

@@ -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-----");

View File

@@ -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));
}
}
}