save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -0,0 +1,19 @@
# Attestor Verify Tests AGENTS
## Purpose & Scope
- Working directory: `src/Attestor/__Tests/StellaOps.Attestor.Verify.Tests/`.
- Roles: QA automation, backend engineer.
- Focus: signature verification, issuer trust, transparency proof evaluation, and policy aggregation for Attestor.Verify.
## Required Reading (treat as read before DOING)
- `docs/modules/attestor/architecture.md`
- `docs/modules/platform/architecture-overview.md`
- Relevant sprint files.
## Working Agreements
- Determinism is mandatory: fixed timestamps, stable IDs, deterministic ordering.
- Keep tests offline-friendly and avoid wall-clock delays.
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
## Testing
- Use xUnit + FluentAssertions + TestKit; prefer deterministic fixtures.

View File

@@ -0,0 +1,398 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Storage;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Verify;
using StellaOps.Cryptography;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Attestor.Verify.Tests;
public sealed class AttestorVerificationEngineTests
{
private static readonly DateTimeOffset FixedTime = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_KmsSignaturesCountedOncePerSignature()
{
var canonicalizer = new TestDsseCanonicalizer();
var cryptoHash = new TestCryptoHash();
var options = Options.Create(new AttestorOptions
{
Verification =
{
RequireTransparencyInclusion = false,
RequireCheckpoint = false
},
Security =
{
SignerIdentity =
{
KmsKeys = new List<string>
{
Convert.ToBase64String(Encoding.UTF8.GetBytes("kms-secret-1")),
Convert.ToBase64String(Encoding.UTF8.GetBytes("kms-secret-2"))
}
}
}
});
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
var payloadType = "application/vnd.in-toto+json";
var signatureBytes = ComputeHmacSignature(payload, payloadType, Encoding.UTF8.GetBytes("kms-secret-1"));
var bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "kms",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = payloadType,
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
Signature = Convert.ToBase64String(signatureBytes)
}
}
}
};
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "kms");
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
report.Signatures.VerifiedSignatures.Should().Be(1);
report.Signatures.TotalSignatures.Should().Be(1);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_KeylessUsesAsnSanParsing()
{
using var key = ECDsa.Create();
using var cert = CreateSelfSignedCertificate(key, "CN=Leaf", "leaf.example.test");
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
var payloadType = "application/vnd.in-toto+json";
var signatureBytes = key.SignData(DssePreAuthenticationEncoding.Compute(payloadType, payload), HashAlgorithmName.SHA256);
var bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
CertificateChain = { ToPem(cert) },
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = payloadType,
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
Signature = Convert.ToBase64String(signatureBytes)
}
}
}
};
var canonicalizer = new TestDsseCanonicalizer();
var cryptoHash = new TestCryptoHash();
var options = Options.Create(new AttestorOptions
{
Verification =
{
RequireTransparencyInclusion = false,
RequireCheckpoint = false
}
});
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "keyless");
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
report.Issuer.SubjectAlternativeName.Should().Be("leaf.example.test");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyAsync_KeylessChainBuildUsesIntermediateStore()
{
using var rootKey = ECDsa.Create();
using var root = CreateCertificateAuthority(rootKey, "CN=Root");
using var intermediateKey = ECDsa.Create();
using var intermediate = CreateIntermediateCertificate(intermediateKey, root, "CN=Intermediate");
using var leafKey = ECDsa.Create();
using var leaf = CreateLeafCertificate(leafKey, intermediate, "CN=Leaf", "leaf-chain.example");
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}");
var payloadType = "application/vnd.in-toto+json";
var signatureBytes = leafKey.SignData(DssePreAuthenticationEncoding.Compute(payloadType, payload), HashAlgorithmName.SHA256);
var bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
CertificateChain =
{
ToPem(leaf),
ToPem(intermediate)
},
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = payloadType,
PayloadBase64 = Convert.ToBase64String(payload),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
Signature = Convert.ToBase64String(signatureBytes)
}
}
}
};
var rootPath = Path.GetTempFileName();
File.WriteAllBytes(rootPath, root.Export(X509ContentType.Cert));
try
{
var canonicalizer = new TestDsseCanonicalizer();
var cryptoHash = new TestCryptoHash();
var options = Options.Create(new AttestorOptions
{
Verification =
{
RequireTransparencyInclusion = false,
RequireCheckpoint = false
},
Security =
{
SignerIdentity =
{
FulcioRoots = { rootPath }
}
}
});
var entry = BuildEntry(bundle, canonicalizer, cryptoHash, mode: "keyless");
var engine = new AttestorVerificationEngine(canonicalizer, cryptoHash, options, NullLogger<AttestorVerificationEngine>.Instance);
var report = await engine.EvaluateAsync(entry, bundle, FixedTime);
report.Issuer.Issues.Should().NotContain(issue => issue.StartsWith("certificate_chain_untrusted", StringComparison.OrdinalIgnoreCase));
}
finally
{
File.Delete(rootPath);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DssePreAuthenticationEncoding_FollowsSpec()
{
var payload = Encoding.UTF8.GetBytes("{}");
var payloadType = "application/test";
var pae = DssePreAuthenticationEncoding.Compute(payloadType, payload);
var expected = $"DSSEv1 {Encoding.UTF8.GetByteCount(payloadType)} {payloadType} {payload.Length} {{}}";
Encoding.UTF8.GetString(pae).Should().Be(expected);
}
private static AttestorEntry BuildEntry(
AttestorSubmissionRequest.SubmissionBundle bundle,
IDsseCanonicalizer canonicalizer,
ICryptoHash cryptoHash,
string mode)
{
var request = new AttestorSubmissionRequest
{
Bundle = bundle,
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Kind = "container",
ImageDigest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
SubjectUri = "oci://registry.example.test/example"
},
BundleSha256 = string.Empty
}
};
var canonicalBytes = canonicalizer.CanonicalizeAsync(request, CancellationToken.None).GetAwaiter().GetResult();
var bundleHash = cryptoHash.ComputeHashHexForPurpose(canonicalBytes, HashPurpose.Attestation);
return new AttestorEntry
{
RekorUuid = "rekor-test",
BundleSha256 = bundleHash,
Status = "included",
Artifact = new AttestorEntry.ArtifactDescriptor
{
Sha256 = request.Meta.Artifact.Sha256,
Kind = request.Meta.Artifact.Kind,
ImageDigest = request.Meta.Artifact.ImageDigest,
SubjectUri = request.Meta.Artifact.SubjectUri
},
Log = new AttestorEntry.LogDescriptor
{
Backend = "primary",
Url = "https://rekor.example.test"
},
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
{
Mode = mode
},
CreatedAt = FixedTime
};
}
private static byte[] ComputeHmacSignature(byte[] payload, string payloadType, byte[] secret)
{
using var hmac = new HMACSHA256(secret);
return hmac.ComputeHash(DssePreAuthenticationEncoding.Compute(payloadType, payload));
}
private static X509Certificate2 CreateSelfSignedCertificate(ECDsa key, string subject, string dnsName)
{
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName(dnsName);
request.CertificateExtensions.Add(sanBuilder.Build());
var cert = request.CreateSelfSigned(FixedTime.AddDays(-1), FixedTime.AddDays(1));
return EnsurePrivateKey(cert, key);
}
private static X509Certificate2 CreateCertificateAuthority(ECDsa key, string subject)
{
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true));
var cert = request.CreateSelfSigned(FixedTime.AddDays(-1), FixedTime.AddDays(1));
return EnsurePrivateKey(cert, key);
}
private static X509Certificate2 CreateIntermediateCertificate(ECDsa key, X509Certificate2 issuer, string subject)
{
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true));
var serial = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var cert = request.Create(issuer, FixedTime.AddDays(-1), FixedTime.AddDays(1), serial);
return EnsurePrivateKey(cert, key);
}
private static X509Certificate2 CreateLeafCertificate(ECDsa key, X509Certificate2 issuer, string subject, string dnsName)
{
var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName(dnsName);
request.CertificateExtensions.Add(sanBuilder.Build());
var serial = new byte[] { 0x05, 0x06, 0x07, 0x08 };
var cert = request.Create(issuer, FixedTime.AddDays(-1), FixedTime.AddDays(1), serial);
return EnsurePrivateKey(cert, key);
}
private static X509Certificate2 EnsurePrivateKey(X509Certificate2 certificate, ECDsa key)
=> certificate.HasPrivateKey ? certificate : certificate.CopyWithPrivateKey(key);
private static string ToPem(X509Certificate2 certificate)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(certificate.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
private sealed class TestDsseCanonicalizer : IDsseCanonicalizer
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(request, Options));
}
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
{
using var algorithm = SHA256.Create();
return algorithm.ComputeHash(data.ToArray());
}
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var algorithm = SHA256.Create();
await using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return algorithm.ComputeHash(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var bytes = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data, HashAlgorithms.Sha256);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data, HashAlgorithms.Sha256);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data, HashAlgorithms.Sha256);
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashAsync(stream, HashAlgorithms.Sha256, cancellationToken);
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> ComputeHashHexAsync(stream, HashAlgorithms.Sha256, cancellationToken);
public string GetAlgorithmForPurpose(string purpose)
=> HashAlgorithms.Sha256;
public string GetHashPrefix(string purpose)
=> "sha256:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> $"{GetHashPrefix(purpose)}{ComputeHashHexForPurpose(data, purpose)}";
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<UseXunitV3>true</UseXunitV3>
<RootNamespace>StellaOps.Attestor.Verify.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" >
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# Attestor Verify Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0071-A | DONE | Added test coverage for Attestor.Verify apply fixes. |