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.Instance); var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty() }); 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.Instance); var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty() }); var payload = payloadText.ToUtf8Bytes(); var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload); var tampered = signature.Replace('a', 'b', StringComparison.Ordinal); await Assert.ThrowsAsync(() => 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.Instance); var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty() }); var payload = payloadText.ToUtf8Bytes(); var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload); await Assert.ThrowsAsync(() => 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.Instance); var payloadText = System.Text.Json.JsonSerializer.Serialize(new { advisories = Array.Empty() }); var payload = payloadText.ToUtf8Bytes(); var (signature, _) = await CreateDetachedJwsAsync(provider, key.Reference.KeyId, payload); provider.RemoveSigningKey(key.Reference.KeyId); await Assert.ThrowsAsync(() => 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 payload) { var signer = provider.GetSigner(SignatureAlgorithms.Es256, new CryptoKeyReference(keyId)); var header = new Dictionary { ["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 BuildSigningInput(string encodedHeader, ReadOnlySpan 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 ToUtf8Bytes(this string value) => System.Text.Encoding.UTF8.GetBytes(value); }