using System.Text; using StellaOps.Provenance.Attestation; using Xunit; namespace StellaOps.Provenance.Attestation.Tests; public sealed class SignersTests { [Fact] public async Task HmacSigner_SignsAndAudits() { var key = new InMemoryKeyProvider("k1", Convert.FromHexString("0f0e0d0c0b0a09080706050403020100")); var audit = new InMemoryAuditSink(); var time = new TestTimeProvider(new DateTimeOffset(2025, 11, 22, 12, 0, 0, TimeSpan.Zero)); var signer = new HmacSigner(key, audit, time); var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "application/json", Claims: new Dictionary { { "sub", "builder" } }); var result = await signer.SignAsync(request); Assert.Equal("k1", result.KeyId); Assert.Equal(time.GetUtcNow(), result.SignedAt); Assert.Equal( Convert.FromHexString("b3ae92d9a593318d03d7c4b6dca9710c416f582e88cfc08196d8c2cdabb3c480"), result.Signature); Assert.Single(audit.Signed); Assert.Empty(audit.Missing); } [Fact] public async Task HmacSigner_EnforcesRequiredClaims() { var key = new InMemoryKeyProvider("k-claims", Encoding.UTF8.GetBytes("secret")); var audit = new InMemoryAuditSink(); var signer = new HmacSigner(key, audit, new TestTimeProvider(DateTimeOffset.UtcNow)); var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "text/plain", Claims: new Dictionary(), RequiredClaims: new[] { "sub" }); await Assert.ThrowsAsync(() => signer.SignAsync(request)); Assert.Contains(audit.Missing, x => x.keyId == "k-claims" && x.claim == "sub"); } [Fact] public async Task RotatingKeyProvider_LogsRotationWhenNewKeyBecomesActive() { var now = new DateTimeOffset(2025, 11, 22, 10, 0, 0, TimeSpan.Zero); var time = new TestTimeProvider(now); var audit = new InMemoryAuditSink(); var expiring = new InMemoryKeyProvider("old", new byte[] { 0x01 }, now.AddMinutes(5)); var longLived = new InMemoryKeyProvider("new", new byte[] { 0x02 }, now.AddHours(1)); var provider = new RotatingKeyProvider(new[] { expiring, longLived }, time, audit); var signer = new HmacSigner(provider, audit, time); await signer.SignAsync(new SignRequest(new byte[] { 0xAB }, "demo")); Assert.Contains(audit.Rotations, r => r.previousKeyId == "old" && r.nextKeyId == "new"); Assert.Equal("new", provider.KeyId); } [Fact] public async Task CosignSigner_UsesClientAndAudits() { var signatureBytes = Convert.FromBase64String(await File.ReadAllTextAsync(Path.Combine("Fixtures", "cosign.sig"))); // fixture is deterministic var client = new FakeCosignClient(signatureBytes); var audit = new InMemoryAuditSink(); var time = new TestTimeProvider(new DateTimeOffset(2025, 11, 22, 13, 0, 0, TimeSpan.Zero)); var signer = new CosignSigner("cosign://stella", client, audit, time); var request = new SignRequest(Encoding.UTF8.GetBytes("subject"), "application/vnd.stella+json", Claims: new Dictionary { { "sub", "artifact" } }, RequiredClaims: new[] { "sub" }); var result = await signer.SignAsync(request); Assert.Equal(signatureBytes, result.Signature); Assert.Equal(time.GetUtcNow(), result.SignedAt); Assert.Equal("cosign://stella", result.KeyId); Assert.Single(audit.Signed); Assert.Empty(audit.Missing); var call = Assert.Single(client.Calls); Assert.Equal("cosign://stella", call.keyRef); Assert.Equal("application/vnd.stella+json", call.contentType); Assert.Equal(request.Payload, call.payload); } [Fact] public async Task KmsSigner_EnforcesRequiredClaims() { var signature = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE }; var client = new FakeKmsClient(signature); var audit = new InMemoryAuditSink(); var key = new InMemoryKeyProvider("kms-1", new byte[] { 0x00 }, DateTimeOffset.UtcNow.AddDays(1)); var signer = new KmsSigner(client, key, audit, new TestTimeProvider(DateTimeOffset.UtcNow)); var request = new SignRequest(Encoding.UTF8.GetBytes("body"), "application/json", Claims: new Dictionary { { "aud", "stella" } }, RequiredClaims: new[] { "sub" }); await Assert.ThrowsAsync(() => signer.SignAsync(request)); Assert.Contains(audit.Missing, x => x.keyId == "kms-1" && x.claim == "sub"); var validAudit = new InMemoryAuditSink(); var validSigner = new KmsSigner(client, key, validAudit, new TestTimeProvider(DateTimeOffset.UtcNow)); var validRequest = new SignRequest(Encoding.UTF8.GetBytes("body"), "application/json", Claims: new Dictionary { { "aud", "stella" }, { "sub", "actor" } }, RequiredClaims: new[] { "sub" }); var result = await validSigner.SignAsync(validRequest); Assert.Equal(signature, result.Signature); Assert.Equal("kms-1", result.KeyId); Assert.Empty(validAudit.Missing); } private sealed class FakeCosignClient : ICosignClient { public List<(byte[] payload, string contentType, string keyRef)> Calls { get; } = new(); private readonly byte[] _signature; public FakeCosignClient(byte[] signature) { _signature = signature ?? throw new ArgumentNullException(nameof(signature)); } public Task SignAsync(byte[] payload, string contentType, string keyRef, CancellationToken cancellationToken) { Calls.Add((payload, contentType, keyRef)); return Task.FromResult(_signature); } } private sealed class FakeKmsClient : IKmsClient { private readonly byte[] _signature; public List<(byte[] payload, string contentType, string keyId)> Calls { get; } = new(); public FakeKmsClient(byte[] signature) => _signature = signature; public Task SignAsync(byte[] payload, string contentType, string keyId, CancellationToken cancellationToken) { Calls.Add((payload, contentType, keyId)); return Task.FromResult(_signature); } } }