Add unit tests and implementations for MongoDB index models and OpenAPI metadata
- Implemented `MongoIndexModelTests` to verify index models for various stores. - Created `OpenApiMetadataFactory` with methods to generate OpenAPI metadata. - Added tests for `OpenApiMetadataFactory` to ensure expected defaults and URL overrides. - Introduced `ObserverSurfaceSecrets` and `WebhookSurfaceSecrets` for managing secrets. - Developed `RuntimeSurfaceFsClient` and `WebhookSurfaceFsClient` for manifest retrieval. - Added dependency injection tests for `SurfaceEnvironmentRegistration` in both Observer and Webhook contexts. - Implemented tests for secret resolution in `ObserverSurfaceSecretsTests` and `WebhookSurfaceSecretsTests`. - Created `EnsureLinkNotMergeCollectionsMigrationTests` to validate MongoDB migration logic. - Added project files for MongoDB tests and NuGet package mirroring.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<RestoreSources>;;</RestoreSources>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
|
||||
static int PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: stella-forensic-verify --payload <file> --signature-hex <hex> --key-hex <hex> [--key-id <id>] [--content-type <ct>]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string? GetArg(string name)
|
||||
{
|
||||
for (int i = 0; i < args.Length - 1; i++)
|
||||
{
|
||||
if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
return args[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
string? payloadPath = GetArg("--payload");
|
||||
string? signatureHex = GetArg("--signature-hex");
|
||||
string? keyHex = GetArg("--key-hex");
|
||||
string keyId = GetArg("--key-id") ?? "hmac";
|
||||
string contentType = GetArg("--content-type") ?? "application/octet-stream";
|
||||
|
||||
if (payloadPath is null || signatureHex is null || keyHex is null)
|
||||
{
|
||||
return PrintUsage();
|
||||
}
|
||||
|
||||
byte[] payload = await System.IO.File.ReadAllBytesAsync(payloadPath);
|
||||
byte[] signature;
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
signature = Hex.FromHex(signatureHex);
|
||||
key = Hex.FromHex(keyHex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"hex parse error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var request = new SignRequest(payload, contentType);
|
||||
var signResult = new SignResult(signature, keyId, DateTimeOffset.MinValue, null);
|
||||
|
||||
var verifier = new HmacVerifier(new InMemoryKeyProvider(keyId, key));
|
||||
var result = await verifier.VerifyAsync(request, signResult);
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
valid = result.IsValid,
|
||||
reason = result.Reason,
|
||||
verifiedAt = result.VerifiedAt.ToUniversalTime().ToString("O")
|
||||
});
|
||||
Console.WriteLine(json);
|
||||
|
||||
return result.IsValid ? 0 : 2;
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PackAsTool>true</PackAsTool>
|
||||
<ToolCommandName>stella-forensic-verify</ToolCommandName>
|
||||
<PackageOutputPath>../../out/tools</PackageOutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
test
|
||||
113
src/Provenance/StellaOps.Provenance.Attestation/BuildModels.cs
Normal file
113
src/Provenance/StellaOps.Provenance.Attestation/BuildModels.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
public sealed record BuildDefinition(
|
||||
string BuildType,
|
||||
IReadOnlyDictionary<string, string>? ExternalParameters = null,
|
||||
IReadOnlyDictionary<string, string>? ResolvedDependencies = null);
|
||||
|
||||
public sealed record BuildMetadata(
|
||||
string? BuildInvocationId,
|
||||
DateTimeOffset? BuildStartedOn,
|
||||
DateTimeOffset? BuildFinishedOn,
|
||||
bool? Reproducible = null,
|
||||
IReadOnlyDictionary<string, bool>? Completeness = null,
|
||||
IReadOnlyDictionary<string, string>? Environment = null);
|
||||
|
||||
public static class CanonicalJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public static byte[] SerializeToUtf8Bytes<T>(T value)
|
||||
{
|
||||
var element = JsonSerializer.SerializeToElement(value, Options);
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = false });
|
||||
WriteCanonical(element, writer);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static string SerializeToString<T>(T value) => Encoding.UTF8.GetString(SerializeToUtf8Bytes(value));
|
||||
|
||||
private static void WriteCanonical(JsonElement element, Utf8JsonWriter writer)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
writer.WriteStartObject();
|
||||
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonical(property.Value, writer);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
WriteCanonical(item, writer);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
default:
|
||||
element.WriteTo(writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class MerkleTree
|
||||
{
|
||||
public static byte[] ComputeRoot(IEnumerable<byte[]> leaves)
|
||||
{
|
||||
var leafList = leaves?.ToList() ?? throw new ArgumentNullException(nameof(leaves));
|
||||
if (leafList.Count == 0) throw new ArgumentException("At least one leaf required", nameof(leaves));
|
||||
|
||||
var level = leafList.Select(NormalizeLeaf).ToList();
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
while (level.Count > 1)
|
||||
{
|
||||
var next = new List<byte[]>((level.Count + 1) / 2);
|
||||
for (var i = 0; i < level.Count; i += 2)
|
||||
{
|
||||
var left = level[i];
|
||||
var right = i + 1 < level.Count ? level[i + 1] : left;
|
||||
var combined = new byte[left.Length + right.Length];
|
||||
Buffer.BlockCopy(left, 0, combined, 0, left.Length);
|
||||
Buffer.BlockCopy(right, 0, combined, left.Length, right.Length);
|
||||
next.Add(sha.ComputeHash(combined));
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
|
||||
return level[0];
|
||||
|
||||
static byte[] NormalizeLeaf(byte[] data)
|
||||
{
|
||||
if (data.Length == 32) return data;
|
||||
using var sha = SHA256.Create();
|
||||
return sha.ComputeHash(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BuildStatement(
|
||||
BuildDefinition BuildDefinition,
|
||||
BuildMetadata BuildMetadata);
|
||||
|
||||
public static class BuildStatementFactory
|
||||
{
|
||||
public static BuildStatement Create(BuildDefinition definition, BuildMetadata metadata) => new(definition, metadata);
|
||||
}
|
||||
20
src/Provenance/StellaOps.Provenance.Attestation/Hex.cs
Normal file
20
src/Provenance/StellaOps.Provenance.Attestation/Hex.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
public static class Hex
|
||||
{
|
||||
public static byte[] FromHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex)) throw new ArgumentException("hex is required", nameof(hex));
|
||||
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
|
||||
if (hex.Length % 2 != 0) throw new FormatException("hex length must be even");
|
||||
|
||||
var bytes = new byte[hex.Length / 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
public sealed record PromotionPredicate(
|
||||
string ImageDigest,
|
||||
string SbomDigest,
|
||||
string VexDigest,
|
||||
string PromotionId,
|
||||
string? RekorEntry = null,
|
||||
IReadOnlyDictionary<string,string>? Metadata = null);
|
||||
|
||||
public static class PromotionAttestationBuilder
|
||||
{
|
||||
public static byte[] CreateCanonicalJson(PromotionPredicate predicate)
|
||||
{
|
||||
if (predicate is null) throw new ArgumentNullException(nameof(predicate));
|
||||
return CanonicalJson.SerializeToUtf8Bytes(predicate);
|
||||
}
|
||||
}
|
||||
107
src/Provenance/StellaOps.Provenance.Attestation/Signers.cs
Normal file
107
src/Provenance/StellaOps.Provenance.Attestation/Signers.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
public sealed record SignRequest(
|
||||
byte[] Payload,
|
||||
string ContentType,
|
||||
IReadOnlyDictionary<string, string>? Claims = null,
|
||||
IReadOnlyCollection<string>? RequiredClaims = null);
|
||||
|
||||
public sealed record SignResult(
|
||||
byte[] Signature,
|
||||
string KeyId,
|
||||
DateTimeOffset SignedAt,
|
||||
IReadOnlyDictionary<string, string>? Claims);
|
||||
|
||||
public interface IKeyProvider
|
||||
{
|
||||
string KeyId { get; }
|
||||
byte[] KeyMaterial { get; }
|
||||
}
|
||||
|
||||
public interface IAuditSink
|
||||
{
|
||||
void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt);
|
||||
void LogMissingClaim(string keyId, string claimName);
|
||||
}
|
||||
|
||||
public sealed class NullAuditSink : IAuditSink
|
||||
{
|
||||
public static readonly NullAuditSink Instance = new();
|
||||
private NullAuditSink() { }
|
||||
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt) { }
|
||||
public void LogMissingClaim(string keyId, string claimName) { }
|
||||
}
|
||||
|
||||
public sealed class HmacSigner : ISigner
|
||||
{
|
||||
private readonly IKeyProvider _keyProvider;
|
||||
private readonly IAuditSink _audit;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public HmacSigner(IKeyProvider keyProvider, IAuditSink? audit = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
|
||||
_audit = audit ?? NullAuditSink.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
|
||||
if (request.RequiredClaims is not null)
|
||||
{
|
||||
foreach (var required in request.RequiredClaims)
|
||||
{
|
||||
if (request.Claims is null || !request.Claims.ContainsKey(required))
|
||||
{
|
||||
_audit.LogMissingClaim(_keyProvider.KeyId, required);
|
||||
throw new InvalidOperationException($"Missing required claim {required}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(_keyProvider.KeyMaterial);
|
||||
var signature = hmac.ComputeHash(request.Payload);
|
||||
var signedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_audit.LogSigned(_keyProvider.KeyId, request.ContentType, request.Claims, signedAt);
|
||||
|
||||
return Task.FromResult(new SignResult(
|
||||
Signature: signature,
|
||||
KeyId: _keyProvider.KeyId,
|
||||
SignedAt: signedAt,
|
||||
Claims: request.Claims));
|
||||
}
|
||||
}
|
||||
|
||||
public interface ISigner
|
||||
{
|
||||
Task<SignResult> SignAsync(SignRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class InMemoryKeyProvider : IKeyProvider
|
||||
{
|
||||
public string KeyId { get; }
|
||||
public byte[] KeyMaterial { get; }
|
||||
|
||||
public InMemoryKeyProvider(string keyId, byte[] keyMaterial)
|
||||
{
|
||||
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
|
||||
KeyMaterial = keyMaterial ?? throw new ArgumentNullException(nameof(keyMaterial));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InMemoryAuditSink : IAuditSink
|
||||
{
|
||||
public List<(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)> Signed { get; } = new();
|
||||
public List<(string keyId, string claim)> Missing { get; } = new();
|
||||
|
||||
public void LogSigned(string keyId, string contentType, IReadOnlyDictionary<string, string>? claims, DateTimeOffset signedAt)
|
||||
=> Signed.Add((keyId, contentType, claims, signedAt));
|
||||
|
||||
public void LogMissingClaim(string keyId, string claimName)
|
||||
=> Missing.Add((keyId, claimName));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation;
|
||||
|
||||
public sealed record VerificationResult(bool IsValid, string Reason, DateTimeOffset VerifiedAt);
|
||||
|
||||
public interface IVerifier
|
||||
{
|
||||
Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class HmacVerifier : IVerifier
|
||||
{
|
||||
private readonly IKeyProvider _keyProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public HmacVerifier(IKeyProvider keyProvider, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_keyProvider = keyProvider ?? throw new ArgumentNullException(nameof(keyProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<VerificationResult> VerifyAsync(SignRequest request, SignResult signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
if (signature is null) throw new ArgumentNullException(nameof(signature));
|
||||
|
||||
using var hmac = new HMACSHA256(_keyProvider.KeyMaterial);
|
||||
var expected = hmac.ComputeHash(request.Payload);
|
||||
var ok = CryptographicOperations.FixedTimeEquals(expected, signature.Signature) &&
|
||||
string.Equals(_keyProvider.KeyId, signature.KeyId, StringComparison.Ordinal);
|
||||
|
||||
var result = new VerificationResult(
|
||||
IsValid: ok,
|
||||
Reason: ok ? ok : signature
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class CanonicalJsonTests
|
||||
{
|
||||
[Fact]
|
||||
public void Canonicalizes_property_order_and_omits_nulls()
|
||||
{
|
||||
var model = new BuildDefinition(
|
||||
BuildType: "https://slsa.dev/provenance/v1",
|
||||
ExternalParameters: new Dictionary<string, string>
|
||||
{
|
||||
["b"] = "2",
|
||||
["a"] = "1",
|
||||
["c"] = "3"
|
||||
},
|
||||
ResolvedDependencies: null);
|
||||
|
||||
var json = CanonicalJson.SerializeToString(model);
|
||||
|
||||
json.Should().Be("{\"BuildType\":\"https://slsa.dev/provenance/v1\",\"ExternalParameters\":{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\"}}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class HexTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_even_length_hex()
|
||||
{
|
||||
Hex.FromHex("0A0b").Should().BeEquivalentTo(new byte[] { 0x0A, 0x0B });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_on_odd_length()
|
||||
{
|
||||
Action act = () => Hex.FromHex("ABC");
|
||||
act.Should().Throw<FormatException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class MerkleTreeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Computes_deterministic_root_for_same_inputs()
|
||||
{
|
||||
var leaves = new[]
|
||||
{
|
||||
Encoding.UTF8.GetBytes("a"),
|
||||
Encoding.UTF8.GetBytes("b"),
|
||||
Encoding.UTF8.GetBytes("c")
|
||||
};
|
||||
|
||||
var root1 = MerkleTree.ComputeRoot(leaves);
|
||||
var root2 = MerkleTree.ComputeRoot(leaves);
|
||||
|
||||
root1.Should().BeEquivalentTo(root2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalizes_non_hash_leaves()
|
||||
{
|
||||
var leaves = new[] { Encoding.UTF8.GetBytes("single") };
|
||||
var root = MerkleTree.ComputeRoot(leaves);
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
var expected = sha.ComputeHash(leaves[0]);
|
||||
|
||||
root.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class PromotionAttestationBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Produces_canonical_json_for_predicate()
|
||||
{
|
||||
var predicate = new PromotionPredicate(
|
||||
ImageDigest: sha256:img,
|
||||
SbomDigest: sha256:sbom,
|
||||
VexDigest: sha256:vex,
|
||||
PromotionId: prom-1,
|
||||
RekorEntry: uuid,
|
||||
Metadata: new Dictionary<string, string>{{env,prod}});
|
||||
|
||||
var bytes = PromotionAttestationBuilder.CreateCanonicalJson(predicate);
|
||||
var json = Encoding.UTF8.GetString(bytes);
|
||||
|
||||
json.Should().Be("ImageDigest":"sha256:img");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class SignerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HmacSigner_is_deterministic_for_same_input()
|
||||
{
|
||||
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
|
||||
var audit = new InMemoryAuditSink();
|
||||
var signer = new HmacSigner(key, audit, TimeProvider.System);
|
||||
|
||||
var request = new SignRequest(Encoding.UTF8.GetBytes("payload"), "application/json");
|
||||
|
||||
var r1 = await signer.SignAsync(request);
|
||||
var r2 = await signer.SignAsync(request);
|
||||
|
||||
r1.Signature.Should().BeEquivalentTo(r2.Signature);
|
||||
r1.KeyId.Should().Be("test-key");
|
||||
audit.Signed.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HmacSigner_enforces_required_claims()
|
||||
{
|
||||
var key = new InMemoryKeyProvider("test-key", Encoding.UTF8.GetBytes("secret"));
|
||||
var audit = new InMemoryAuditSink();
|
||||
var signer = new HmacSigner(key, audit, TimeProvider.System);
|
||||
|
||||
var request = new SignRequest(
|
||||
Payload: Encoding.UTF8.GetBytes("payload"),
|
||||
ContentType: "application/json",
|
||||
Claims: new Dictionary<string, string> { ["foo"] = "bar" },
|
||||
RequiredClaims: new[] { "foo", "bar" });
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => signer.SignAsync(request));
|
||||
ex.Message.Should().Contain("bar");
|
||||
audit.Missing.Should().ContainSingle(m => m.claim == "bar");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Provenance.Attestation/StellaOps.Provenance.Attestation.csproj" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Provenance.Attestation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Provenance.Attestation.Tests;
|
||||
|
||||
public class VerificationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Verifier_accepts_valid_signature()
|
||||
{
|
||||
var key = new InMemoryKeyProvider(test-key, Encoding.UTF8.GetBytes(secret));
|
||||
var signer = new HmacSigner(key);
|
||||
var verifier = new HmacVerifier(key);
|
||||
|
||||
var request = new SignRequest(Encoding.UTF8.GetBytes(payload), application/json);
|
||||
var signature = await signer.SignAsync(request);
|
||||
|
||||
var result = await verifier.VerifyAsync(request, signature);
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verifier_rejects_tampered_payload()
|
||||
{
|
||||
var key = new InMemoryKeyProvider(test-key, Encoding.UTF8.GetBytes(secret));
|
||||
var signer = new HmacSigner(key);
|
||||
var verifier = new HmacVerifier(key);
|
||||
|
||||
var request = new SignRequest(Encoding.UTF8.GetBytes(payload), application/json);
|
||||
var signature = await signer.SignAsync(request);
|
||||
|
||||
var tampered = new SignRequest(Encoding.UTF8.GetBytes(payload-tampered), application/json);
|
||||
var result = await verifier.VerifyAsync(tampered, signature);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain(mismatch);
|
||||
}
|
||||
}
|
||||
EOF}
|
||||
Reference in New Issue
Block a user