using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using Azaion.E2E.Helpers; using FluentAssertions; using Microsoft.IdentityModel.Tokens; using Xunit; namespace Azaion.E2E.Tests; /// /// AZ-532 — ES256 signing + JWKS endpoint + alg-confusion defence. /// public class AsymmetricSigningTests : IClassFixture { private readonly TestFixture _fixture; public AsymmetricSigningTests(TestFixture fixture) => _fixture = fixture; [Fact] public async Task AC1_Access_token_header_uses_ES256_with_active_kid() { // Arrange using var client = _fixture.CreateHttpClient(); var api = new ApiClient(client); // Act var login = await api.LoginFullAsync(_fixture.AdminEmail, _fixture.AdminPassword); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(login.AccessToken); // Assert jwt.Header.Alg.Should().Be("ES256"); jwt.Header.Kid.Should().Be(_fixture.JwtActiveKid, "tokens must carry the active key's kid"); } [Fact] public async Task AC2_JWKS_endpoint_returns_public_key_set_with_long_cache() { // Arrange using var client = _fixture.CreateHttpClient(); // Act using var resp = await client.GetAsync("/.well-known/jwks.json"); // Assert — status, cache headers, payload shape resp.StatusCode.Should().Be(HttpStatusCode.OK); resp.Headers.CacheControl.Should().NotBeNull(); resp.Headers.CacheControl!.Public.Should().BeTrue(); resp.Headers.CacheControl.MaxAge.Should().Be(TimeSpan.FromHours(1)); var doc = await resp.Content.ReadFromJsonAsync(); doc.TryGetProperty("keys", out var keys).Should().BeTrue(); keys.GetArrayLength().Should().BeGreaterThan(0); foreach (var k in keys.EnumerateArray()) { k.GetProperty("kty").GetString().Should().Be("EC"); k.GetProperty("crv").GetString().Should().Be("P-256"); k.GetProperty("alg").GetString().Should().Be("ES256"); k.GetProperty("use").GetString().Should().Be("sig"); k.GetProperty("kid").GetString().Should().NotBeNullOrEmpty(); k.GetProperty("x").GetString().Should().NotBeNullOrEmpty(); k.GetProperty("y").GetString().Should().NotBeNullOrEmpty(); } } [Fact] public async Task AC3_Both_keys_appear_in_JWKS_during_rotation_overlap() { // Arrange — the e2e fixture mounts kid-test-a (active) and kid-test-b (overlap). using var client = _fixture.CreateHttpClient(); // Act using var resp = await client.GetAsync("/.well-known/jwks.json"); var doc = await resp.Content.ReadFromJsonAsync(); var kids = doc.GetProperty("keys").EnumerateArray() .Select(k => k.GetProperty("kid").GetString()) .ToHashSet(); // Assert kids.Should().Contain("kid-test-a"); kids.Should().Contain("kid-test-b", "kid-test-b is mounted alongside kid-test-a precisely to verify rotation-overlap"); } [Fact] public async Task AC4_JWKS_response_omits_all_private_key_components() { // Arrange using var client = _fixture.CreateHttpClient(); // Act using var resp = await client.GetAsync("/.well-known/jwks.json"); var raw = await resp.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(raw); // Assert — no EC private scalar (`d`) and no RSA private primes anywhere foreach (var k in doc.RootElement.GetProperty("keys").EnumerateArray()) { k.TryGetProperty("d", out _).Should().BeFalse("EC private scalar must never leak"); k.TryGetProperty("p", out _).Should().BeFalse("RSA private prime must never leak"); k.TryGetProperty("q", out _).Should().BeFalse("RSA private prime must never leak"); k.TryGetProperty("dp", out _).Should().BeFalse(); k.TryGetProperty("dq", out _).Should().BeFalse(); k.TryGetProperty("qi", out _).Should().BeFalse(); } } [Fact] public async Task AC5_Forged_HS256_token_signed_with_public_key_is_rejected() { // Arrange — alg-confusion: take the public key bytes and use them as an // HMAC secret; sign a token with alg=HS256. A naive verifier that only // uses ValidIssuerSigningKey without pinning algorithms accepts it. AZ-532 // pins ValidAlgorithms = [ES256], so this MUST be rejected. using var client = _fixture.CreateHttpClient(); using var jwks = await client.GetAsync("/.well-known/jwks.json"); var doc = await jwks.Content.ReadFromJsonAsync(); var k = doc.GetProperty("keys").EnumerateArray().First(); var x = Base64UrlEncoder.DecodeBytes(k.GetProperty("x").GetString()!); var y = Base64UrlEncoder.DecodeBytes(k.GetProperty("y").GetString()!); var publicKeyBytes = new byte[1 + x.Length + y.Length]; publicKeyBytes[0] = 0x04; // uncompressed point marker — exact bytes don't matter Buffer.BlockCopy(x, 0, publicKeyBytes, 1, x.Length); Buffer.BlockCopy(y, 0, publicKeyBytes, 1 + x.Length, y.Length); var hmacKey = new SymmetricSecurityKey(publicKeyBytes) { KeyId = k.GetProperty("kid").GetString() }; var creds = new SigningCredentials(hmacKey, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: "AzaionApi", audience: "Annotators/OrangePi/Admins", claims: [ new Claim(ClaimTypes.Role, "ApiAdmin"), new Claim(ClaimTypes.Name, "forged@x.com") ], notBefore: DateTime.UtcNow.AddMinutes(-1), expires: DateTime.UtcNow.AddMinutes(5), signingCredentials: creds); var jwt = new JwtSecurityTokenHandler().WriteToken(token); // Act using var req = new HttpRequestMessage(HttpMethod.Get, "/users"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); using var resp = await client.SendAsync(req); // Assert resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized, "alg=HS256 must be rejected even when the HMAC secret is the public key bytes"); } }