- Added `/concelier/advisories/{vulnerabilityKey}/replay` endpoint to return conflict summaries and explainers.
- Introduced `MergeConflictExplainerPayload` to structure conflict details including type, reason, and source rankings.
- Enhanced `MergeConflictSummary` to include structured explainer payloads and hashes for persisted conflicts.
- Updated `MirrorEndpointExtensions` to enforce rate limits and cache headers for mirror distribution endpoints.
- Refactored tests to cover new replay endpoint functionality and validate conflict explainers.
- Documented changes in TASKS.md, noting completion of mirror distribution endpoints and updated operational runbook.
144 lines
6.0 KiB
C#
144 lines
6.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
|
|
using StellaOps.Cryptography;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Tests;
|
|
|
|
public sealed class MirrorSignatureVerifierTests
|
|
{
|
|
[Fact]
|
|
public async Task VerifyAsync_ValidSignaturePasses()
|
|
{
|
|
var provider = new DefaultCryptoProvider();
|
|
var key = CreateSigningKey("mirror-key");
|
|
provider.UpsertSigningKey(key);
|
|
|
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
|
|
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
|
var payload = payloadText.ToUtf8Bytes();
|
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
|
|
|
await verifier.VerifyAsync(payload, signature, CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_InvalidSignatureThrows()
|
|
{
|
|
var provider = new DefaultCryptoProvider();
|
|
var key = CreateSigningKey("mirror-key");
|
|
provider.UpsertSigningKey(key);
|
|
|
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
|
|
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
|
var payload = payloadText.ToUtf8Bytes();
|
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
|
|
|
var tampered = signature.Replace('a', 'b', StringComparison.Ordinal);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(payload, tampered, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_KeyMismatchThrows()
|
|
{
|
|
var provider = new DefaultCryptoProvider();
|
|
var key = CreateSigningKey("mirror-key");
|
|
provider.UpsertSigningKey(key);
|
|
|
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
|
|
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
|
var payload = payloadText.ToUtf8Bytes();
|
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
|
|
payload,
|
|
signature,
|
|
expectedKeyId: "unexpected-key",
|
|
expectedProvider: null,
|
|
cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task VerifyAsync_ThrowsWhenProviderMissingKey()
|
|
{
|
|
var provider = new DefaultCryptoProvider();
|
|
var key = CreateSigningKey("mirror-key");
|
|
provider.UpsertSigningKey(key);
|
|
|
|
var registry = new CryptoProviderRegistry(new[] { provider });
|
|
var verifier = new MirrorSignatureVerifier(registry, NullLogger<MirrorSignatureVerifier>.Instance);
|
|
|
|
var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty<string>() });
|
|
var payload = payloadText.ToUtf8Bytes();
|
|
var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload);
|
|
|
|
provider.RemoveSigningKey(key.Reference.KeyId);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => verifier.VerifyAsync(
|
|
payload,
|
|
signature,
|
|
expectedKeyId: key.Reference.KeyId,
|
|
expectedProvider: provider.Name,
|
|
cancellationToken: CancellationToken.None));
|
|
}
|
|
|
|
private static CryptoSigningKey CreateSigningKey(string keyId)
|
|
{
|
|
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
|
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
|
return new CryptoSigningKey(new CryptoKeyReference(keyId), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static async Task<(string Signature, DateTimeOffset SignedAt)> CreateDetachedJwsAsync(
|
|
DefaultCryptoProvider provider,
|
|
string keyId,
|
|
ReadOnlyMemory<byte> payload)
|
|
{
|
|
var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference(keyId));
|
|
var header = new Dictionary<string, object?>
|
|
{
|
|
["alg"] = SignatureAlgorithms.Es256,
|
|
["kid"] = keyId,
|
|
["provider"] = provider.Name,
|
|
["typ"] = "application/vnd.stellaops.concelier.mirror-bundle+jws",
|
|
["b64"] = false,
|
|
["crit"] = new[] { "b64" }
|
|
};
|
|
|
|
var headerJson = System.Text.Json.JsonSerializer.Serialize(header);
|
|
var protectedHeader = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(headerJson);
|
|
|
|
var signingInput = BuildSigningInput(protectedHeader, payload.Span);
|
|
var signatureBytes = await signer.SignAsync(signingInput, CancellationToken.None).ConfigureAwait(false);
|
|
var encodedSignature = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(signatureBytes);
|
|
|
|
return (string.Concat(protectedHeader, "..", encodedSignature), DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
|
|
{
|
|
var headerBytes = System.Text.Encoding.ASCII.GetBytes(encodedHeader);
|
|
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
|
|
headerBytes.CopyTo(buffer.AsSpan());
|
|
buffer[headerBytes.Length] = (byte)'.';
|
|
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
|
|
return buffer;
|
|
}
|
|
}
|
|
|
|
file static class Utf8Extensions
|
|
{
|
|
public static ReadOnlyMemory<byte> ToUtf8Bytes(this string value)
|
|
=> System.Text.Encoding.UTF8.GetBytes(value);
|
|
}
|