Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using MongoDB.Driver;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Offline;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.WebService;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestationBundleEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportEndpoint_RequiresAuthentication()
|
||||
{
|
||||
using var factory = new AttestorWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/api/v1/attestations:export", new StringContent("{}", Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAndImportEndpoints_RoundTripBundles()
|
||||
{
|
||||
using var factory = new AttestorWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
AttachAuth(client);
|
||||
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\"}");
|
||||
var bundleHashBytes = System.Security.Cryptography.SHA256.HashData(canonicalBytes);
|
||||
var bundleSha = Convert.ToHexString(bundleHashBytes).ToLowerInvariant();
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAttestorEntryRepository>();
|
||||
var archiveStore = scope.ServiceProvider.GetRequiredService<IAttestorArchiveStore>();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "uuid-export-01",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "feedface",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/uuid-export-01"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Issuer = "tenant-a"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
await archiveStore.ArchiveBundleAsync(new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBytes,
|
||||
ProofJson = Array.Empty<byte>(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = entry.Status
|
||||
}
|
||||
});
|
||||
|
||||
var canonicalBytes2 = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\",\"sequence\":2}");
|
||||
var bundleSha2 = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBytes2)).ToLowerInvariant();
|
||||
var secondEntry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "uuid-export-02",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "deadcafe",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha2,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddMinutes(1),
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/uuid-export-02"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Issuer = "tenant-a"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(secondEntry);
|
||||
await archiveStore.ArchiveBundleAsync(new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = secondEntry.RekorUuid,
|
||||
ArtifactSha256 = secondEntry.Artifact.Sha256,
|
||||
BundleSha256 = secondEntry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBytes2,
|
||||
ProofJson = Array.Empty<byte>(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["status"] = secondEntry.Status
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var exportResponse = await client.PostAsJsonAsync("/api/v1/attestations:export", new
|
||||
{
|
||||
scope = "tenant-a",
|
||||
limit = 1
|
||||
});
|
||||
|
||||
exportResponse.EnsureSuccessStatusCode();
|
||||
var exportPayload = await exportResponse.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(exportPayload), "Export response payload was empty.");
|
||||
var package = JsonSerializer.Deserialize<AttestorBundlePackage>(
|
||||
exportPayload,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(package);
|
||||
Assert.Single(package!.Items);
|
||||
Assert.NotNull(package.ContinuationToken);
|
||||
|
||||
var importResponse = await client.PostAsJsonAsync("/api/v1/attestations:import", package);
|
||||
importResponse.EnsureSuccessStatusCode();
|
||||
var importPayload = await importResponse.Content.ReadAsStringAsync();
|
||||
Assert.False(string.IsNullOrWhiteSpace(importPayload), "Import response payload was empty.");
|
||||
var importResult = JsonSerializer.Deserialize<AttestorBundleImportResult>(
|
||||
importPayload,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true });
|
||||
Assert.NotNull(importResult);
|
||||
Assert.Equal(0, importResult!.Imported);
|
||||
Assert.Equal(1, importResult.Updated);
|
||||
Assert.Empty(importResult.Issues);
|
||||
}
|
||||
|
||||
private static void AttachAuth(HttpClient client)
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AttestorWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["attestor:s3:enabled"] = "true",
|
||||
["attestor:s3:bucket"] = "attestor-test",
|
||||
["attestor:s3:endpoint"] = "http://localhost",
|
||||
["attestor:s3:useTls"] = "false",
|
||||
["attestor:redis:url"] = string.Empty,
|
||||
["attestor:mongo:uri"] = "mongodb://localhost:27017/attestor-tests",
|
||||
["attestor:mongo:database"] = "attestor-tests"
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(settings!);
|
||||
});
|
||||
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.RemoveAll<IConnectionMultiplexer>();
|
||||
services.RemoveAll<IAttestorEntryRepository>();
|
||||
services.RemoveAll<IAttestorArchiveStore>();
|
||||
services.RemoveAll<IAttestorAuditSink>();
|
||||
services.RemoveAll<IAttestorDedupeStore>();
|
||||
services.RemoveAll<IAttestorBundleService>();
|
||||
services.RemoveAll<ITransparencyWitnessClient>();
|
||||
services.RemoveAll<IAttestationSigningService>();
|
||||
services.RemoveAll<IBulkVerificationJobStore>();
|
||||
services.AddSingleton<IAttestorEntryRepository, InMemoryAttestorEntryRepository>();
|
||||
services.AddSingleton<IAttestorArchiveStore, InMemoryAttestorArchiveStore>();
|
||||
services.AddSingleton<IAttestorAuditSink, InMemoryAttestorAuditSink>();
|
||||
services.AddSingleton<IAttestorDedupeStore, InMemoryAttestorDedupeStore>();
|
||||
services.AddSingleton<IAttestorBundleService, AttestorBundleService>();
|
||||
services.AddSingleton<ITransparencyWitnessClient, TestTransparencyWitnessClient>();
|
||||
services.AddSingleton<IAttestationSigningService, TestAttestationSigningService>();
|
||||
services.AddSingleton<IBulkVerificationJobStore, TestBulkVerificationJobStore>();
|
||||
var authBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName;
|
||||
options.DefaultChallengeScheme = TestAuthHandler.SchemeName;
|
||||
});
|
||||
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
|
||||
authenticationScheme: TestAuthHandler.SchemeName,
|
||||
displayName: null,
|
||||
configureOptions: options => { options.TimeProvider ??= TimeProvider.System; });
|
||||
#pragma warning disable CS0618
|
||||
services.TryAddSingleton<ISystemClock, SystemClock>();
|
||||
#pragma warning restore CS0618
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var header) || header.Count == 0)
|
||||
{
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, "test-user"),
|
||||
new Claim("scope", "attestor.read attestor.write attestor.verify")
|
||||
};
|
||||
|
||||
var schemeName = Scheme?.Name ?? SchemeName;
|
||||
var identity = new ClaimsIdentity(claims, schemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, schemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestationQueryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersAndPagination_Work()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var origin = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
var scope = index % 2 == 0 ? "tenant-a" : "tenant-b";
|
||||
var type = index % 2 == 0 ? "sbom" : "report";
|
||||
var issuer = $"issuer-{index % 2}";
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = $"uuid-{index:D2}",
|
||||
BundleSha256 = $"bundle-{index:D2}",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = $"sha-{index:D2}",
|
||||
Kind = type,
|
||||
ImageDigest = $"sha256:{index:D2}",
|
||||
SubjectUri = $"pkg:example/app@{index}"
|
||||
},
|
||||
CreatedAt = origin.AddMinutes(index),
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = $"https://rekor.example/entries/{index:D2}"
|
||||
},
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = scope,
|
||||
SubjectAlternativeName = issuer,
|
||||
KeyId = $"kid-{index}"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
}
|
||||
|
||||
var query = new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 3
|
||||
};
|
||||
|
||||
var firstPage = await repository.QueryAsync(query);
|
||||
Assert.Equal(3, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.ContinuationToken);
|
||||
Assert.All(firstPage.Items, item =>
|
||||
{
|
||||
Assert.Equal("tenant-a", item.SignerIdentity.Issuer);
|
||||
Assert.Equal("sbom", item.Artifact.Kind);
|
||||
});
|
||||
var firstPageIds = firstPage.Items.Select(item => item.RekorUuid).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var secondPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = query.Scope,
|
||||
Type = query.Type,
|
||||
PageSize = query.PageSize,
|
||||
ContinuationToken = firstPage.ContinuationToken
|
||||
});
|
||||
|
||||
Assert.True(secondPage.Items.Count > 0);
|
||||
Assert.All(secondPage.Items, item => Assert.DoesNotContain(item.RekorUuid, firstPageIds));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildQuery_ValidatesInputs()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.QueryString = new QueryString("?subject=sha-01&type=sbom&issuer=issuer-0&scope=tenant-a&pageSize=25&createdAfter=2025-01-01T00:00:00Z&createdBefore=2025-01-31T00:00:00Z");
|
||||
|
||||
var success = AttestationListContracts.TryBuildQuery(httpContext.Request, out var query, out var error);
|
||||
Assert.True(success);
|
||||
Assert.Null(error);
|
||||
Assert.Equal("sha-01", query.Subject);
|
||||
Assert.Equal("sbom", query.Type);
|
||||
Assert.Equal("issuer-0", query.Issuer);
|
||||
Assert.Equal("tenant-a", query.Scope);
|
||||
Assert.Equal(25, query.PageSize);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-01-01T00:00:00Z"), query.CreatedAfter);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-01-31T00:00:00Z"), query.CreatedBefore);
|
||||
|
||||
httpContext.Request.QueryString = new QueryString("?pageSize=-5");
|
||||
success = AttestationListContracts.TryBuildQuery(httpContext.Request, out _, out error);
|
||||
Assert.False(success);
|
||||
var problem = Assert.IsType<ProblemHttpResult>(error);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorEntryRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersAndPagination_Work()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var origin = DateTimeOffset.UtcNow;
|
||||
|
||||
for (var index = 0; index < 6; index++)
|
||||
{
|
||||
var scope = index % 2 == 0 ? "tenant-a" : "tenant-b";
|
||||
var kind = index % 2 == 0 ? "sbom" : "report";
|
||||
var entry = CreateEntry(index, origin.AddMinutes(index), scope, kind);
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
}
|
||||
|
||||
var firstPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 2
|
||||
});
|
||||
|
||||
Assert.Equal(2, firstPage.Items.Count);
|
||||
Assert.NotNull(firstPage.ContinuationToken);
|
||||
Assert.All(firstPage.Items, item =>
|
||||
{
|
||||
Assert.Equal("tenant-a", item.SignerIdentity.Issuer);
|
||||
Assert.Equal("sbom", item.Artifact.Kind);
|
||||
});
|
||||
|
||||
var seen = firstPage.Items.Select(item => item.RekorUuid).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var secondPage = await repository.QueryAsync(new AttestorEntryQuery
|
||||
{
|
||||
Scope = "tenant-a",
|
||||
Type = "sbom",
|
||||
PageSize = 2,
|
||||
ContinuationToken = firstPage.ContinuationToken
|
||||
});
|
||||
|
||||
Assert.True(secondPage.Items.Count > 0);
|
||||
Assert.All(secondPage.Items, item => Assert.DoesNotContain(item.RekorUuid, seen));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_EnforcesUniqueBundleSha()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var entryA = CreateEntry(100, now, "tenant-a", "sbom");
|
||||
var entryB = CreateEntry(200, now.AddMinutes(1), "tenant-b", "report", entryA.BundleSha256);
|
||||
|
||||
await repository.SaveAsync(entryA);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.SaveAsync(entryB));
|
||||
}
|
||||
|
||||
private static AttestorEntry CreateEntry(int index, DateTimeOffset createdAt, string scope, string kind, string? bundleShaOverride = null)
|
||||
{
|
||||
var uuid = $"uuid-{index:D4}";
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = uuid,
|
||||
BundleSha256 = bundleShaOverride ?? MakeHex(10_000 + index),
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = MakeHex(20_000 + index),
|
||||
Kind = kind,
|
||||
ImageDigest = $"sha256:{index:D4}",
|
||||
SubjectUri = $"pkg:example/app@{index}"
|
||||
},
|
||||
Index = index,
|
||||
Proof = null,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = $"https://rekor.example/entries/{index:D4}",
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = createdAt,
|
||||
Status = "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = scope,
|
||||
SubjectAlternativeName = $"issuer-{index % 3}",
|
||||
KeyId = $"kid-{index:D4}"
|
||||
},
|
||||
Mirror = new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = "mirror",
|
||||
Url = $"https://rekor-mirror.example/{index:D4}",
|
||||
Uuid = $"mirror-{uuid}",
|
||||
Index = index,
|
||||
Status = "pending",
|
||||
Proof = null,
|
||||
LogId = null,
|
||||
Error = null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string MakeHex(int seed)
|
||||
=> Convert.ToHexString(BitConverter.GetBytes(seed)).ToLowerInvariant().PadLeft(64, '0');
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Signing;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Tests;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorSigningServiceTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _temporaryPaths = new();
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_Ed25519Key_ReturnsValidSignature()
|
||||
{
|
||||
var privateKey = new byte[32];
|
||||
for (var i = 0; i < privateKey.Length; i++)
|
||||
{
|
||||
privateKey[i] = (byte)i;
|
||||
}
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = "ed25519-1",
|
||||
Algorithm = StellaOps.Cryptography.SignatureAlgorithms.Ed25519,
|
||||
Mode = "keyful",
|
||||
Material = Convert.ToBase64String(privateKey),
|
||||
MaterialFormat = "base64"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
new DefaultDsseCanonicalizer(),
|
||||
auditSink,
|
||||
metrics,
|
||||
NullLogger<AttestorSigningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestationSignRequest
|
||||
{
|
||||
KeyId = "ed25519-1",
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(payloadBytes),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:subject",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "client",
|
||||
CallerTenant = "tenant",
|
||||
MtlsThumbprint = "thumbprint"
|
||||
};
|
||||
|
||||
var result = await service.SignAsync(request, context);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("ed25519-1", result.KeyId);
|
||||
Assert.Equal("keyful", result.Mode);
|
||||
Assert.Equal("bouncycastle.ed25519", result.Provider);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
|
||||
Assert.Single(result.Bundle.Dsse.Signatures);
|
||||
|
||||
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
|
||||
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
|
||||
var verifier = new Org.BouncyCastle.Crypto.Signers.Ed25519Signer();
|
||||
var privateParams = new Org.BouncyCastle.Crypto.Parameters.Ed25519PrivateKeyParameters(privateKey, 0);
|
||||
verifier.Init(false, privateParams.GeneratePublicKey());
|
||||
verifier.BlockUpdate(preAuth, 0, preAuth.Length);
|
||||
Assert.True(verifier.VerifySignature(signature));
|
||||
|
||||
Assert.Single(auditSink.Records);
|
||||
Assert.Equal("sign", auditSink.Records[0].Action);
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SignAsync_KmsKey_ProducesVerifiableSignature()
|
||||
{
|
||||
var kmsRoot = CreateTempDirectory();
|
||||
const string kmsPassword = "Test-Kms-Password!";
|
||||
const string kmsKeyId = "kms-key-1";
|
||||
const string kmsVersion = "v1";
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var keyParameters = ecdsa.ExportParameters(true);
|
||||
var publicParameters = new ECParameters
|
||||
{
|
||||
Curve = keyParameters.Curve,
|
||||
Q = keyParameters.Q
|
||||
};
|
||||
|
||||
using (var kmsClient = new FileKmsClient(new FileKmsOptions
|
||||
{
|
||||
RootPath = kmsRoot,
|
||||
Password = kmsPassword
|
||||
}))
|
||||
{
|
||||
var material = new KmsKeyMaterial(
|
||||
kmsKeyId,
|
||||
kmsVersion,
|
||||
KmsAlgorithms.Es256,
|
||||
"P-256",
|
||||
keyParameters.D!,
|
||||
keyParameters.Q.X!,
|
||||
keyParameters.Q.Y!,
|
||||
DateTimeOffset.UtcNow);
|
||||
await kmsClient.ImportAsync(kmsKeyId, material);
|
||||
}
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Signing = new AttestorOptions.SigningOptions
|
||||
{
|
||||
Kms = new AttestorOptions.SigningKmsOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RootPath = kmsRoot,
|
||||
Password = kmsPassword
|
||||
},
|
||||
Keys =
|
||||
{
|
||||
new AttestorOptions.SigningKeyOptions
|
||||
{
|
||||
KeyId = kmsKeyId,
|
||||
Algorithm = StellaOps.Cryptography.SignatureAlgorithms.Es256,
|
||||
Mode = "kms",
|
||||
ProviderKeyId = kmsKeyId,
|
||||
KmsVersionId = kmsVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var registry = new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance);
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var service = new AttestorSigningService(
|
||||
registry,
|
||||
new DefaultDsseCanonicalizer(),
|
||||
auditSink,
|
||||
metrics,
|
||||
NullLogger<AttestorSigningService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("{\"value\":1}");
|
||||
var request = new AttestationSignRequest
|
||||
{
|
||||
KeyId = kmsKeyId,
|
||||
PayloadType = "application/json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('b', 64),
|
||||
Kind = "report"
|
||||
}
|
||||
};
|
||||
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:subject",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "tenant"
|
||||
};
|
||||
|
||||
var result = await service.SignAsync(request, context);
|
||||
Assert.Equal("kms", result.Mode);
|
||||
Assert.Equal("kms", result.Provider);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Meta.BundleSha256));
|
||||
|
||||
var signature = Convert.FromBase64String(result.Bundle.Dsse.Signatures[0].Signature);
|
||||
var preAuth = DssePreAuthenticationEncoding.Compute(result.Bundle.Dsse.PayloadType, Convert.FromBase64String(result.Bundle.Dsse.PayloadBase64));
|
||||
using var verifier = ECDsa.Create(publicParameters);
|
||||
|
||||
Assert.True(verifier.VerifyData(preAuth, signature, HashAlgorithmName.SHA256));
|
||||
Assert.Single(auditSink.Records);
|
||||
Assert.Equal("sign", auditSink.Records[0].Action);
|
||||
Assert.Equal("signed", auditSink.Records[0].Result);
|
||||
}
|
||||
|
||||
private string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "attestor-signing-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
_temporaryPaths.Add(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var path in _temporaryPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
_temporaryPaths.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_PersistsAndFetchesEntry()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var entry = CreateEntry();
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var byUuid = await repository.GetByUuidAsync(entry.RekorUuid);
|
||||
var byBundle = await repository.GetByBundleShaAsync(entry.BundleSha256);
|
||||
var byArtifact = await repository.GetByArtifactShaAsync(entry.Artifact.Sha256);
|
||||
|
||||
Assert.NotNull(byUuid);
|
||||
Assert.NotNull(byBundle);
|
||||
Assert.Equal(entry.RekorUuid, byBundle!.RekorUuid);
|
||||
Assert.Single(byArtifact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_UpsertsExistingDocument()
|
||||
{
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var entry = CreateEntry(status: "included");
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var updated = CreateEntry(
|
||||
rekorUuid: entry.RekorUuid,
|
||||
bundleSha: entry.BundleSha256,
|
||||
artifactSha: entry.Artifact.Sha256,
|
||||
status: "pending");
|
||||
|
||||
await repository.SaveAsync(updated);
|
||||
|
||||
var stored = await repository.GetByUuidAsync(entry.RekorUuid);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pending", stored!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemoryDedupeStore_RoundTripsAndExpires()
|
||||
{
|
||||
var store = new InMemoryAttestorDedupeStore();
|
||||
var bundleSha = Guid.NewGuid().ToString("N");
|
||||
var uuid = Guid.NewGuid().ToString("N");
|
||||
|
||||
await store.SetAsync(bundleSha, uuid, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var first = await store.TryGetExistingAsync(bundleSha);
|
||||
Assert.Equal(uuid, first);
|
||||
|
||||
// fast-forward past expiry
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(75));
|
||||
var second = await store.TryGetExistingAsync(bundleSha);
|
||||
Assert.Null(second);
|
||||
}
|
||||
|
||||
private static AttestorEntry CreateEntry(
|
||||
string? rekorUuid = null,
|
||||
string? bundleSha = null,
|
||||
string? artifactSha = null,
|
||||
string status = "included")
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = rekorUuid ?? Guid.NewGuid().ToString("N"),
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = artifactSha ?? "sha256:" + Guid.NewGuid().ToString("N"),
|
||||
Kind = "sbom",
|
||||
ImageDigest = "sha256:" + Guid.NewGuid().ToString("N"),
|
||||
SubjectUri = "oci://registry.example/app"
|
||||
},
|
||||
BundleSha256 = bundleSha ?? Guid.NewGuid().ToString("N"),
|
||||
Index = 42,
|
||||
Proof = null,
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/api/v1/log",
|
||||
LogId = "log-1"
|
||||
},
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = status,
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = "keyless",
|
||||
Issuer = "tenant-a",
|
||||
SubjectAlternativeName = "signer@example",
|
||||
KeyId = "kid"
|
||||
},
|
||||
Mirror = null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
@@ -41,22 +46,26 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var validator = new AttestorSubmissionValidator(canonicalizer);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var verificationCache = new StubVerificationCache();
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
verificationCache,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
var context = new SubmissionContext
|
||||
@@ -72,12 +81,14 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var first = await service.SubmitAsync(request, context);
|
||||
var second = await service.SubmitAsync(request, context);
|
||||
|
||||
Assert.NotNull(first.Uuid);
|
||||
Assert.Equal(first.Uuid, second.Uuid);
|
||||
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(first.Uuid, stored!.RekorUuid);
|
||||
Assert.NotNull(first.Uuid);
|
||||
Assert.Equal(first.Uuid, second.Uuid);
|
||||
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(first.Uuid, stored!.RekorUuid);
|
||||
Assert.Single(verificationCache.InvalidatedSubjects);
|
||||
Assert.Equal(request.Meta.Artifact.Sha256, verificationCache.InvalidatedSubjects[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -115,22 +126,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
@@ -178,22 +192,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "both";
|
||||
@@ -244,22 +261,25 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
var logger = new NullLogger<AttestorSubmissionService>();
|
||||
using var metrics = new AttestorMetrics();
|
||||
|
||||
var service = new AttestorSubmissionService(
|
||||
validator,
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new StubVerificationCache(),
|
||||
options,
|
||||
logger,
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var request = CreateValidRequest(canonicalizer);
|
||||
request.Meta.LogPreference = "mirror";
|
||||
@@ -278,14 +298,31 @@ public sealed class AttestorSubmissionServiceTests
|
||||
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("mirror", stored!.Log.Backend);
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
Assert.Null(result.Mirror);
|
||||
}
|
||||
|
||||
private sealed class StubVerificationCache : IAttestorVerificationCache
|
||||
{
|
||||
public List<string> InvalidatedSubjects { get; } = new();
|
||||
|
||||
public Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AttestorVerificationResult?>(null);
|
||||
|
||||
public Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InvalidatedSubjects.Add(subject);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(DefaultDsseCanonicalizer canonicalizer)
|
||||
{
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "keyless",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorSubmissionValidatorHardeningTests
|
||||
{
|
||||
private static readonly DefaultDsseCanonicalizer Canonicalizer = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ThrowsWhenPayloadExceedsLimit()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints(
|
||||
maxPayloadBytes: 16,
|
||||
maxSignatures: 6,
|
||||
maxCertificateChainEntries: 6);
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
|
||||
var oversized = CreateValidRequest(payloadSize: 32);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(oversized));
|
||||
Assert.Equal("payload_too_large", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ThrowsWhenCertificateChainTooLong()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints(
|
||||
maxPayloadBytes: 2048,
|
||||
maxSignatures: 6,
|
||||
maxCertificateChainEntries: 2);
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
|
||||
var request = CreateValidRequest(certificateCount: 3);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
|
||||
Assert.Equal("certificate_chain_too_long", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FuzzedInputs_DoNotCrash()
|
||||
{
|
||||
var constraints = new AttestorSubmissionConstraints();
|
||||
var validator = new AttestorSubmissionValidator(Canonicalizer, constraints: constraints);
|
||||
var random = new Random(0x715f_c3a1);
|
||||
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var mutated = CreateValidRequest();
|
||||
Mutate(mutated, random);
|
||||
|
||||
try
|
||||
{
|
||||
await validator.ValidateAsync(mutated);
|
||||
}
|
||||
catch (AttestorValidationException)
|
||||
{
|
||||
// Expected for malformed inputs.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateValidRequest(int payloadSize = 16, int signatureCount = 1, int certificateCount = 0)
|
||||
{
|
||||
if (payloadSize <= 0)
|
||||
{
|
||||
payloadSize = 1;
|
||||
}
|
||||
|
||||
var payload = new byte[payloadSize];
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] = (byte)'A';
|
||||
}
|
||||
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
for (var i = 0; i < Math.Max(1, signatureCount); i++)
|
||||
{
|
||||
var signatureBytes = Encoding.UTF8.GetBytes($"sig-{i}");
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = $"sig-{i}",
|
||||
Signature = Convert.ToBase64String(signatureBytes)
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < certificateCount; i++)
|
||||
{
|
||||
request.Bundle.CertificateChain.Add($"-----BEGIN CERTIFICATE-----FAKE{i}-----END CERTIFICATE-----");
|
||||
}
|
||||
|
||||
var canonical = Canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void Mutate(AttestorSubmissionRequest request, Random random)
|
||||
{
|
||||
switch (random.Next(0, 7))
|
||||
{
|
||||
case 0:
|
||||
request.Bundle.Dsse.PayloadBase64 = RandomString(random, random.Next(0, 32));
|
||||
break;
|
||||
case 1:
|
||||
request.Meta.Artifact.Sha256 = RandomString(random, random.Next(0, 70));
|
||||
break;
|
||||
case 2:
|
||||
request.Bundle.Dsse.Signatures.Clear();
|
||||
break;
|
||||
case 3:
|
||||
request.Meta.BundleSha256 = RandomString(random, random.Next(10, 40));
|
||||
break;
|
||||
case 4:
|
||||
request.Meta.LogPreference = "invalid-" + random.Next(1, 9999);
|
||||
break;
|
||||
case 5:
|
||||
request.Bundle.CertificateChain.Add(RandomString(random, random.Next(5, 25)));
|
||||
break;
|
||||
default:
|
||||
request.Bundle.Dsse.PayloadType = RandomString(random, random.Next(0, 20));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RandomString(Random random, int length)
|
||||
{
|
||||
const string alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
||||
if (length <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
Span<char> buffer = stackalloc char[length];
|
||||
for (var i = 0; i < buffer.Length; i++)
|
||||
{
|
||||
buffer[i] = alphabet[random.Next(alphabet.Length)];
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,267 +1,610 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorVerificationServiceTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.True(verifyResult.Ok);
|
||||
Assert.Equal(response.Uuid, verifyResult.Uuid);
|
||||
Assert.Empty(verifyResult.Issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FlagsTamperedBundle()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.example/",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics);
|
||||
|
||||
var tamperedBundle = CloneBundle(submission.Bundle);
|
||||
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = tamperedBundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
|
||||
using (var hmac = new HMACSHA256(hmacSecret))
|
||||
{
|
||||
var signature = hmac.ComputeHash(preAuth);
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "kms-test",
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
});
|
||||
}
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
|
||||
{
|
||||
var clone = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = source.Dsse.PayloadType,
|
||||
PayloadBase64 = source.Dsse.PayloadBase64
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var certificate in source.CertificateChain)
|
||||
{
|
||||
clone.CertificateChain.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var signature in source.Dsse.Signatures)
|
||||
{
|
||||
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Signature = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using StellaOps.Attestor.Verify;
|
||||
using StellaOps.Attestor.Tests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class AttestorVerificationServiceTests
|
||||
{
|
||||
private static readonly byte[] HmacSecret = Encoding.UTF8.GetBytes("attestor-hmac-secret");
|
||||
private static readonly string HmacSecretBase64 = Convert.ToBase64String(HmacSecret);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsOk_ForExistingUuid()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var verifyResult = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.True(verifyResult.Ok);
|
||||
Assert.Equal(response.Uuid, verifyResult.Uuid);
|
||||
Assert.Contains("witness_missing", verifyResult.Issues);
|
||||
Assert.Contains("policy_warn:transparency", verifyResult.Issues);
|
||||
Assert.NotNull(verifyResult.Report);
|
||||
Assert.Equal(VerificationSectionStatus.Warn, verifyResult.Report!.OverallStatus);
|
||||
Assert.False(verifyResult.Report.Transparency.WitnessPresent);
|
||||
Assert.Equal("missing", verifyResult.Report.Transparency.WitnessStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FlagsTamperedBundle()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.example/",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var tamperedBundle = CloneBundle(submission.Bundle);
|
||||
tamperedBundle.Dsse.PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"tampered\":true}"));
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = tamperedBundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.Contains("signature_invalid", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.NotNull(result.Report);
|
||||
Assert.Equal(VerificationSectionStatus.Fail, result.Report!.Signatures.Status);
|
||||
Assert.Contains("signature_invalid", result.Report!.Signatures.Issues);
|
||||
}
|
||||
|
||||
private sealed class NullVerificationCache : IAttestorVerificationCache
|
||||
{
|
||||
public Task<AttestorVerificationResult?> GetAsync(string subject, string envelopeId, string policyVersion, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AttestorVerificationResult?>(null);
|
||||
|
||||
public Task SetAsync(string subject, string envelopeId, string policyVersion, AttestorVerificationResult result, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task InvalidateSubjectAsync(string subject, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest CreateSubmissionRequest(DefaultDsseCanonicalizer canonicalizer, byte[] hmacSecret)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("{}");
|
||||
var request = new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = "kms",
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
PayloadBase64 = Convert.ToBase64String(payload)
|
||||
}
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = new string('a', 64),
|
||||
Kind = "sbom"
|
||||
},
|
||||
LogPreference = "primary",
|
||||
Archive = false
|
||||
}
|
||||
};
|
||||
|
||||
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
|
||||
using (var hmac = new HMACSHA256(hmacSecret))
|
||||
{
|
||||
var signature = hmac.ComputeHash(preAuth);
|
||||
request.Bundle.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = "kms-test",
|
||||
Signature = Convert.ToBase64String(signature)
|
||||
});
|
||||
}
|
||||
|
||||
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
|
||||
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return request;
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.SubmissionBundle CloneBundle(AttestorSubmissionRequest.SubmissionBundle source)
|
||||
{
|
||||
var clone = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Dsse = new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = source.Dsse.PayloadType,
|
||||
PayloadBase64 = source.Dsse.PayloadBase64
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var certificate in source.CertificateChain)
|
||||
{
|
||||
clone.CertificateChain.Add(certificate);
|
||||
}
|
||||
|
||||
foreach (var signature in source.Dsse.Signatures)
|
||||
{
|
||||
clone.Dsse.Signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
KeyId = signature.KeyId,
|
||||
Signature = signature.Signature
|
||||
});
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncodingForTests(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_OfflineSkipsProofRefreshWhenMissing()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "offline-test",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "deadbeef",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = "abc123",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/offline-test"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true
|
||||
});
|
||||
|
||||
Assert.Contains("proof_missing", result.Issues);
|
||||
Assert.Equal(0, rekorClient.ProofRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_OfflineUsesImportedProof()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.stellaops.test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var rekorClient = new RecordingRekorClient();
|
||||
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.test\"}");
|
||||
var bundleHashBytes = SHA256.HashData(canonicalBytes);
|
||||
var bundleSha = Convert.ToHexString(bundleHashBytes).ToLowerInvariant();
|
||||
var siblingBytes = SHA256.HashData(Encoding.UTF8.GetBytes("sibling-node"));
|
||||
var rootHashBytes = ComputeMerkleNode(siblingBytes, bundleHashBytes);
|
||||
var rootHash = Convert.ToHexString(rootHashBytes).ToLowerInvariant();
|
||||
|
||||
var entry = new AttestorEntry
|
||||
{
|
||||
RekorUuid = "offline-proof-test",
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = "cafebabe",
|
||||
Kind = "sbom"
|
||||
},
|
||||
BundleSha256 = bundleSha,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Status = "included",
|
||||
Proof = new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = "rekor.stellaops.test",
|
||||
Size = 2,
|
||||
RootHash = rootHash,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = bundleSha,
|
||||
Path = new[] { $"L:{Convert.ToHexString(siblingBytes).ToLowerInvariant()}" }
|
||||
}
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = "primary",
|
||||
Url = "https://rekor.example/log/entries/offline-proof-test"
|
||||
}
|
||||
};
|
||||
|
||||
await repository.SaveAsync(entry);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
new NullTransparencyWitnessClient(),
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Offline = true
|
||||
});
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.DoesNotContain("proof_missing", result.Issues);
|
||||
Assert.Equal(0, rekorClient.ProofRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_FailsWhenWitnessRootMismatch()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = string.Empty
|
||||
},
|
||||
Rekor = new AttestorOptions.RekorOptions
|
||||
{
|
||||
Primary = new AttestorOptions.RekorBackendOptions
|
||||
{
|
||||
Url = "https://rekor.witness.test",
|
||||
ProofTimeoutMs = 1000,
|
||||
PollIntervalMs = 50,
|
||||
MaxAttempts = 2
|
||||
}
|
||||
},
|
||||
Security = new AttestorOptions.SecurityOptions
|
||||
{
|
||||
SignerIdentity = new AttestorOptions.SignerIdentityOptions
|
||||
{
|
||||
Mode = { "kms" },
|
||||
KmsKeys = { HmacSecretBase64 }
|
||||
}
|
||||
},
|
||||
Verification = new AttestorOptions.VerificationOptions
|
||||
{
|
||||
RequireWitnessEndorsement = true
|
||||
},
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.stellaops.test"
|
||||
}
|
||||
});
|
||||
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
var canonicalizer = new DefaultDsseCanonicalizer();
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient
|
||||
{
|
||||
DefaultObservation = new TransparencyWitnessObservation
|
||||
{
|
||||
Aggregator = "stub-aggregator",
|
||||
Status = "endorsed",
|
||||
RootHash = "mismatched",
|
||||
RetrievedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
new AttestorSubmissionValidator(canonicalizer),
|
||||
repository,
|
||||
dedupeStore,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
archiveStore,
|
||||
auditSink,
|
||||
new NullVerificationCache(),
|
||||
options,
|
||||
new NullLogger<AttestorSubmissionService>(),
|
||||
TimeProvider.System,
|
||||
metrics);
|
||||
|
||||
var submission = CreateSubmissionRequest(canonicalizer, HmacSecret);
|
||||
var context = new SubmissionContext
|
||||
{
|
||||
CallerSubject = "urn:stellaops:signer",
|
||||
CallerAudience = "attestor",
|
||||
CallerClientId = "signer-service",
|
||||
CallerTenant = "default"
|
||||
};
|
||||
|
||||
var response = await submissionService.SubmitAsync(submission, context);
|
||||
|
||||
var verificationService = new AttestorVerificationService(
|
||||
repository,
|
||||
canonicalizer,
|
||||
rekorClient,
|
||||
witnessClient,
|
||||
engine,
|
||||
options,
|
||||
new NullLogger<AttestorVerificationService>(),
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System);
|
||||
|
||||
var result = await verificationService.VerifyAsync(new AttestorVerificationRequest
|
||||
{
|
||||
Uuid = response.Uuid,
|
||||
Bundle = submission.Bundle
|
||||
});
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains(result.Issues, issue => issue.StartsWith("witness_root_mismatch", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(result.Report!.Transparency.WitnessPresent);
|
||||
Assert.False(result.Report.Transparency.WitnessMatchesRoot);
|
||||
Assert.Equal("stub-aggregator", result.Report.Transparency.WitnessAggregator);
|
||||
Assert.Equal("endorsed", result.Report.Transparency.WitnessStatus);
|
||||
Assert.NotEmpty(witnessClient.Requests);
|
||||
}
|
||||
|
||||
private static byte[] ComputeMerkleNode(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private sealed class RecordingRekorClient : IRekorClient
|
||||
{
|
||||
public int ProofRequests { get; private set; }
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ProofRequests++;
|
||||
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = string.Empty,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class BulkVerificationContractsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryBuildJob_ReturnsError_WhenItemsMissing()
|
||||
{
|
||||
var options = new AttestorOptions();
|
||||
var context = new BulkVerificationJobContext();
|
||||
|
||||
var success = BulkVerificationContracts.TryBuildJob(null, options, context, out var job, out var error);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(job);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuildJob_AppliesDefaults()
|
||||
{
|
||||
var options = new AttestorOptions
|
||||
{
|
||||
Quotas = new AttestorOptions.QuotaOptions
|
||||
{
|
||||
Bulk = new AttestorOptions.BulkVerificationQuotaOptions
|
||||
{
|
||||
MaxItemsPerJob = 10
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var dto = new BulkVerificationRequestDto
|
||||
{
|
||||
PolicyVersion = "policy@1",
|
||||
RefreshProof = true,
|
||||
Items = new[]
|
||||
{
|
||||
new BulkVerificationRequestItemDto
|
||||
{
|
||||
Subject = "pkg:docker/example",
|
||||
EnvelopeId = "envelope-1"
|
||||
},
|
||||
new BulkVerificationRequestItemDto
|
||||
{
|
||||
Uuid = "rekor-123",
|
||||
RefreshProof = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var context = new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
RequestedBy = "user-1"
|
||||
};
|
||||
|
||||
var success = BulkVerificationContracts.TryBuildJob(dto, options, context, out var job, out var error);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(job);
|
||||
Assert.Equal(2, job!.Items.Count);
|
||||
Assert.Equal("policy@1", job.Items[0].Request.PolicyVersion);
|
||||
Assert.True(job.Items[0].Request.RefreshProof);
|
||||
Assert.False(job.Items[1].Request.RefreshProof);
|
||||
Assert.Equal("tenant-a", job.Context.Tenant);
|
||||
Assert.Equal(BulkVerificationJobStatus.Queued, job.Status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Bulk;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class BulkVerificationWorkerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessJobAsync_CompletesAllItems()
|
||||
{
|
||||
var jobStore = new InMemoryBulkVerificationJobStore();
|
||||
var verificationService = new StubVerificationService();
|
||||
using var metrics = new AttestorMetrics();
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
BulkVerification = new AttestorOptions.BulkVerificationOptions
|
||||
{
|
||||
WorkerPollSeconds = 1,
|
||||
ItemDelayMilliseconds = 0
|
||||
},
|
||||
Quotas = new AttestorOptions.QuotaOptions
|
||||
{
|
||||
Bulk = new AttestorOptions.BulkVerificationQuotaOptions
|
||||
{
|
||||
MaxItemsPerJob = 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var job = new BulkVerificationJob
|
||||
{
|
||||
Id = "job-1",
|
||||
Items = new List<BulkVerificationJobItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Index = 0,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Subject = "pkg:docker/example",
|
||||
EnvelopeId = "env-1",
|
||||
PolicyVersion = "policy"
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Index = 1,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Uuid = "rekor-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await jobStore.CreateAsync(job);
|
||||
var worker = new BulkVerificationWorker(
|
||||
jobStore,
|
||||
verificationService,
|
||||
metrics,
|
||||
options,
|
||||
TimeProvider.System,
|
||||
NullLogger<BulkVerificationWorker>.Instance);
|
||||
|
||||
var acquired = await jobStore.TryAcquireAsync();
|
||||
Assert.NotNull(acquired);
|
||||
|
||||
await worker.ProcessJobAsync(acquired!, CancellationToken.None);
|
||||
|
||||
var stored = await jobStore.GetAsync(job.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(BulkVerificationJobStatus.Completed, stored!.Status);
|
||||
Assert.Equal(2, stored.ProcessedCount);
|
||||
Assert.Equal(2, stored.SucceededCount);
|
||||
Assert.Equal(0, stored.FailedCount);
|
||||
Assert.All(stored.Items, item => Assert.Equal(BulkVerificationItemStatus.Succeeded, item.Status));
|
||||
}
|
||||
|
||||
private sealed class StubVerificationService : IAttestorVerificationService
|
||||
{
|
||||
public Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new AttestorVerificationResult
|
||||
{
|
||||
Ok = true,
|
||||
Uuid = request.Uuid ?? "uuid-placeholder",
|
||||
Status = "verified"
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryBulkVerificationJobStore : IBulkVerificationJobStore
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, BulkVerificationJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
job.Version = 0;
|
||||
job.Status = BulkVerificationJobStatus.Queued;
|
||||
_jobs[job.Id] = Clone(job);
|
||||
return Task.FromResult(Clone(job));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return Task.FromResult(_jobs.TryGetValue(jobId, out var stored) ? Clone(stored) : null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var entry in _jobs.Values.OrderBy(job => job.CreatedAt))
|
||||
{
|
||||
if (entry.Status != BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Status = BulkVerificationJobStatus.Running;
|
||||
entry.StartedAt = DateTimeOffset.UtcNow;
|
||||
entry.Version += 1;
|
||||
_jobs[entry.Id] = Clone(entry);
|
||||
return Task.FromResult<BulkVerificationJob?>(Clone(entry)!);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<BulkVerificationJob?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_jobs.TryGetValue(job.Id, out var current))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (current.Version != job.Version)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
job.Version += 1;
|
||||
_jobs[job.Id] = Clone(job);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> CountQueuedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var count = _jobs.Values.Count(job => job.Status == BulkVerificationJobStatus.Queued);
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
|
||||
private static BulkVerificationJob Clone(BulkVerificationJob job)
|
||||
{
|
||||
var context = job.Context ?? new BulkVerificationJobContext();
|
||||
return new BulkVerificationJob
|
||||
{
|
||||
Id = job.Id,
|
||||
Version = job.Version,
|
||||
Status = job.Status,
|
||||
CreatedAt = job.CreatedAt,
|
||||
StartedAt = job.StartedAt,
|
||||
CompletedAt = job.CompletedAt,
|
||||
Context = new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = context.Tenant,
|
||||
RequestedBy = context.RequestedBy,
|
||||
ClientId = context.ClientId,
|
||||
Scopes = context.Scopes?.ToList() ?? new List<string>()
|
||||
},
|
||||
ProcessedCount = job.ProcessedCount,
|
||||
SucceededCount = job.SucceededCount,
|
||||
FailedCount = job.FailedCount,
|
||||
FailureReason = job.FailureReason,
|
||||
Items = (job.Items ?? Array.Empty<BulkVerificationJobItem>()).Select(CloneItem).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static BulkVerificationJobItem CloneItem(BulkVerificationJobItem item)
|
||||
{
|
||||
var request = item.Request ?? new BulkVerificationItemRequest();
|
||||
return new BulkVerificationJobItem
|
||||
{
|
||||
Index = item.Index,
|
||||
Request = new BulkVerificationItemRequest
|
||||
{
|
||||
Uuid = request.Uuid,
|
||||
ArtifactSha256 = request.ArtifactSha256,
|
||||
Subject = request.Subject,
|
||||
EnvelopeId = request.EnvelopeId,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
RefreshProof = request.RefreshProof
|
||||
},
|
||||
Status = item.Status,
|
||||
StartedAt = item.StartedAt,
|
||||
CompletedAt = item.CompletedAt,
|
||||
Result = item.Result is null ? null : new AttestorVerificationResult
|
||||
{
|
||||
Ok = item.Result.Ok,
|
||||
Uuid = item.Result.Uuid,
|
||||
Index = item.Result.Index,
|
||||
LogUrl = item.Result.LogUrl,
|
||||
CheckedAt = item.Result.CheckedAt,
|
||||
Status = item.Result.Status,
|
||||
Issues = item.Result.Issues.ToArray(),
|
||||
Report = item.Result.Report
|
||||
},
|
||||
Error = item.Error
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class CachedAttestorVerificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsCachedResult_OnRepeatedCalls()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123",
|
||||
PolicyVersion = "policy-v1"
|
||||
};
|
||||
|
||||
var first = await service.VerifyAsync(request);
|
||||
var second = await service.VerifyAsync(request);
|
||||
|
||||
Assert.True(first.Ok);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal(1, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_BypassesCache_WhenRefreshProofRequested()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123",
|
||||
PolicyVersion = "policy-v1",
|
||||
RefreshProof = true
|
||||
};
|
||||
|
||||
var first = await service.VerifyAsync(request);
|
||||
var second = await service.VerifyAsync(request);
|
||||
|
||||
Assert.True(first.Ok);
|
||||
Assert.True(second.Ok);
|
||||
Assert.Equal(2, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_BypassesCache_WhenDescriptorIncomplete()
|
||||
{
|
||||
var options = Options.Create(new AttestorOptions());
|
||||
using var memoryCache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
var cache = new InMemoryAttestorVerificationCache(memoryCache, options, new NullLogger<InMemoryAttestorVerificationCache>());
|
||||
var inner = new StubVerificationService();
|
||||
var service = new CachedAttestorVerificationService(
|
||||
inner,
|
||||
cache,
|
||||
metrics,
|
||||
options,
|
||||
new NullLogger<CachedAttestorVerificationService>());
|
||||
|
||||
var request = new AttestorVerificationRequest
|
||||
{
|
||||
Subject = "urn:stellaops:test",
|
||||
EnvelopeId = "bundle-123"
|
||||
};
|
||||
|
||||
await service.VerifyAsync(request);
|
||||
await service.VerifyAsync(request);
|
||||
|
||||
Assert.Equal(2, inner.VerifyCallCount);
|
||||
}
|
||||
|
||||
private sealed class StubVerificationService : IAttestorVerificationService
|
||||
{
|
||||
public int VerifyCallCount { get; private set; }
|
||||
|
||||
public Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
VerifyCallCount++;
|
||||
return Task.FromResult<AttestorVerificationResult>(new()
|
||||
{
|
||||
Ok = true,
|
||||
Uuid = "uuid"
|
||||
});
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
using StellaOps.Attestor.Infrastructure.Transparency;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class HttpTransparencyWitnessClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_CachesSuccessfulResponses()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ =>
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
aggregator = "aggregator.test",
|
||||
status = "endorsed",
|
||||
rootHash = "abc123",
|
||||
statement = "test-statement",
|
||||
signature = new { keyId = "sig-key", value = "sig-value" },
|
||||
timestamp = "2025-11-02T00:00:00Z"
|
||||
});
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(payload)
|
||||
};
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test",
|
||||
CacheTtlSeconds = 60
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-1",
|
||||
"primary",
|
||||
new Uri("https://rekor.example"),
|
||||
"abc123");
|
||||
|
||||
var first = await sut.GetObservationAsync(request);
|
||||
var second = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal("aggregator.test", first!.Aggregator);
|
||||
Assert.Equal("endorsed", first.Status);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_ReturnsErrorObservation_OnNonSuccess()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway));
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test"
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-2",
|
||||
"primary",
|
||||
new Uri("https://rekor.example"),
|
||||
"root-hash");
|
||||
|
||||
var observation = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(observation);
|
||||
Assert.Equal("primary", observation!.Aggregator);
|
||||
Assert.Equal("http_502", observation.Status);
|
||||
Assert.Equal("root-hash", observation.RootHash);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetObservationAsync_ReturnsCachedErrorObservation_OnException()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(_ => throw new HttpRequestException("boom"));
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
using var metrics = new AttestorMetrics();
|
||||
using var activitySource = new AttestorActivitySource();
|
||||
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
TransparencyWitness = new AttestorOptions.TransparencyWitnessOptions
|
||||
{
|
||||
Enabled = true,
|
||||
BaseUrl = "https://witness.test",
|
||||
CacheTtlSeconds = 30
|
||||
}
|
||||
});
|
||||
|
||||
var sut = new HttpTransparencyWitnessClient(
|
||||
client,
|
||||
cache,
|
||||
options,
|
||||
metrics,
|
||||
activitySource,
|
||||
TimeProvider.System,
|
||||
NullLogger<HttpTransparencyWitnessClient>.Instance);
|
||||
|
||||
var request = new TransparencyWitnessRequest(
|
||||
"uuid-3",
|
||||
"mirror",
|
||||
new Uri("https://rekor.mirror"),
|
||||
null);
|
||||
|
||||
var first = await sut.GetObservationAsync(request);
|
||||
var second = await sut.GetObservationAsync(request);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.Same(first, second);
|
||||
Assert.Equal("mirror", first!.Aggregator);
|
||||
Assert.Equal("HttpRequestException", first.Status);
|
||||
Assert.Equal("boom", first.Error);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult(_handler(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
public sealed class LiveDedupeStoreTests
|
||||
{
|
||||
private const string Category = "LiveTTL";
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Mongo_dedupe_document_expires_via_ttl_index()
|
||||
{
|
||||
var mongoUri = Environment.GetEnvironmentVariable("ATTESTOR_LIVE_MONGO_URI");
|
||||
if (string.IsNullOrWhiteSpace(mongoUri))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var mongoUrl = new MongoUrl(mongoUri);
|
||||
var client = new MongoClient(mongoUrl);
|
||||
var databaseName = $"{(string.IsNullOrWhiteSpace(mongoUrl.DatabaseName) ? "attestor_live_ttl" : mongoUrl.DatabaseName)}_{Guid.NewGuid():N}";
|
||||
var database = client.GetDatabase(databaseName);
|
||||
var collection = database.GetCollection<MongoAttestorDedupeStore.AttestorDedupeDocument>("dedupe");
|
||||
|
||||
try
|
||||
{
|
||||
var store = new MongoAttestorDedupeStore(collection, TimeProvider.System);
|
||||
|
||||
var indexes = await (await collection.Indexes.ListAsync()).ToListAsync();
|
||||
Assert.Contains(indexes, doc => doc.TryGetElement("name", out var element) && element.Value == "dedupe_ttl");
|
||||
|
||||
var bundle = Guid.NewGuid().ToString("N");
|
||||
var ttl = TimeSpan.FromSeconds(20);
|
||||
await store.SetAsync(bundle, "rekor-live", ttl);
|
||||
|
||||
var filter = Builders<MongoAttestorDedupeStore.AttestorDedupeDocument>.Filter.Eq(x => x.Key, $"bundle:{bundle}");
|
||||
Assert.True(await collection.Find(filter).AnyAsync(), "Seed document was not written.");
|
||||
|
||||
var deadline = DateTime.UtcNow + ttl + TimeSpan.FromMinutes(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (!await collection.Find(filter).AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
throw new TimeoutException("TTL document remained in MongoDB after waiting for expiry.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DropDatabaseAsync(databaseName);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", Category)]
|
||||
public async Task Redis_dedupe_entry_sets_time_to_live()
|
||||
{
|
||||
var redisConnection = Environment.GetEnvironmentVariable("ATTESTOR_LIVE_REDIS_URI");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var options = Options.Create(new AttestorOptions
|
||||
{
|
||||
Redis = new AttestorOptions.RedisOptions
|
||||
{
|
||||
Url = redisConnection,
|
||||
DedupePrefix = "attestor:ttl:live:"
|
||||
}
|
||||
});
|
||||
|
||||
var multiplexer = await ConnectionMultiplexer.ConnectAsync(redisConnection);
|
||||
try
|
||||
{
|
||||
var store = new RedisAttestorDedupeStore(multiplexer, options);
|
||||
var database = multiplexer.GetDatabase();
|
||||
|
||||
var bundle = Guid.NewGuid().ToString("N");
|
||||
var ttl = TimeSpan.FromSeconds(30);
|
||||
|
||||
await store.SetAsync(bundle, "rekor-redis", ttl);
|
||||
var value = await store.TryGetExistingAsync(bundle);
|
||||
Assert.Equal("rekor-redis", value);
|
||||
|
||||
var redisKey = (RedisKey)(options.Value.Redis.DedupePrefix + $"bundle:{bundle}");
|
||||
var timeToLive = await database.KeyTimeToLiveAsync(redisKey);
|
||||
|
||||
Assert.NotNull(timeToLive);
|
||||
Assert.InRange(timeToLive!.Value, TimeSpan.Zero, ttl);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await multiplexer.CloseAsync();
|
||||
await multiplexer.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -11,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="3.1.3" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
@@ -22,5 +20,6 @@
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,54 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
|
||||
|
||||
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = _entries.Values.FirstOrDefault(e => string.Equals(e.BundleSha256, bundleSha256, StringComparison.OrdinalIgnoreCase));
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(rekorUuid, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = _entries.Values
|
||||
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries[entry.RekorUuid] = entry;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
public List<AttestorAuditRecord> Records { get; } = new();
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Tests;
|
||||
|
||||
internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorEntry> _entries = new();
|
||||
private readonly Dictionary<string, string> _bundleIndex = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _sync = new();
|
||||
|
||||
public Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? uuid;
|
||||
lock (_sync)
|
||||
{
|
||||
_bundleIndex.TryGetValue(bundleSha256, out uuid);
|
||||
}
|
||||
|
||||
if (uuid is not null && _entries.TryGetValue(uuid, out var entry))
|
||||
{
|
||||
return Task.FromResult<AttestorEntry?>(entry);
|
||||
}
|
||||
|
||||
return Task.FromResult<AttestorEntry?>(null);
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_entries.TryGetValue(rekorUuid, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<AttestorEntry> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = _entries.Values.ToList();
|
||||
}
|
||||
|
||||
var entries = snapshot
|
||||
.Where(e => string.Equals(e.Artifact.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AttestorEntry>>(entries);
|
||||
}
|
||||
|
||||
public Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_bundleIndex.TryGetValue(entry.BundleSha256, out var existingUuid) &&
|
||||
!string.Equals(existingUuid, entry.RekorUuid, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Bundle SHA '{entry.BundleSha256}' already exists.");
|
||||
}
|
||||
|
||||
if (_entries.TryGetValue(entry.RekorUuid, out var existing) &&
|
||||
!string.Equals(existing.BundleSha256, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_bundleIndex.Remove(existing.BundleSha256);
|
||||
}
|
||||
|
||||
_entries[entry.RekorUuid] = entry;
|
||||
_bundleIndex[entry.BundleSha256] = entry.RekorUuid;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestorEntryQueryResult> QueryAsync(AttestorEntryQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var pageSize = query.PageSize <= 0 ? 50 : Math.Min(query.PageSize, 200);
|
||||
|
||||
List<AttestorEntry> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = _entries.Values.ToList();
|
||||
}
|
||||
|
||||
IEnumerable<AttestorEntry> sequence = snapshot;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Subject))
|
||||
{
|
||||
var subject = query.Subject;
|
||||
sequence = sequence.Where(e =>
|
||||
string.Equals(e.Artifact.Sha256, subject, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(e.Artifact.ImageDigest, subject, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(e.Artifact.SubjectUri, subject, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Type))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.Artifact.Kind, query.Type, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Issuer))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.SignerIdentity.SubjectAlternativeName, query.Issuer, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Scope))
|
||||
{
|
||||
sequence = sequence.Where(e => string.Equals(e.SignerIdentity.Issuer, query.Scope, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.CreatedAfter is { } createdAfter)
|
||||
{
|
||||
sequence = sequence.Where(e => e.CreatedAt >= createdAfter);
|
||||
}
|
||||
|
||||
if (query.CreatedBefore is { } createdBefore)
|
||||
{
|
||||
sequence = sequence.Where(e => e.CreatedAt <= createdBefore);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.ContinuationToken))
|
||||
{
|
||||
var continuation = AttestorEntryContinuationToken.Parse(query.ContinuationToken);
|
||||
sequence = sequence.Where(e =>
|
||||
{
|
||||
var createdAt = e.CreatedAt;
|
||||
if (createdAt < continuation.CreatedAt)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (createdAt > continuation.CreatedAt)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.CompareOrdinal(e.RekorUuid, continuation.RekorUuid) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
var ordered = sequence
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenBy(e => e.RekorUuid, StringComparer.Ordinal);
|
||||
|
||||
var page = ordered.Take(pageSize + 1).ToList();
|
||||
AttestorEntry? next = null;
|
||||
if (page.Count > pageSize)
|
||||
{
|
||||
next = page[^1];
|
||||
page.RemoveAt(page.Count - 1);
|
||||
}
|
||||
|
||||
var result = new AttestorEntryQueryResult
|
||||
{
|
||||
Items = page,
|
||||
ContinuationToken = next is null
|
||||
? null
|
||||
: AttestorEntryContinuationToken.Encode(next.CreatedAt, next.RekorUuid)
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
public List<AttestorAuditRecord> Records { get; } = new();
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAttestorArchiveStore : IAttestorArchiveStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AttestorArchiveBundle> _bundles = new();
|
||||
|
||||
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_bundles[bundle.BundleSha256] = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = bundle.RekorUuid,
|
||||
ArtifactSha256 = bundle.ArtifactSha256,
|
||||
BundleSha256 = bundle.BundleSha256,
|
||||
CanonicalBundleJson = bundle.CanonicalBundleJson,
|
||||
ProofJson = bundle.ProofJson,
|
||||
Metadata = bundle.Metadata
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AttestorArchiveBundle?> GetBundleAsync(string bundleSha256, string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_bundles.TryGetValue(bundleSha256, out var bundle))
|
||||
{
|
||||
return Task.FromResult<AttestorArchiveBundle?>(bundle);
|
||||
}
|
||||
|
||||
return Task.FromResult<AttestorArchiveBundle?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Transparency;
|
||||
|
||||
namespace StellaOps.Attestor.Tests.Support;
|
||||
|
||||
internal sealed class TestTransparencyWitnessClient : ITransparencyWitnessClient
|
||||
{
|
||||
public List<TransparencyWitnessRequest> Requests { get; } = new();
|
||||
|
||||
public TransparencyWitnessObservation? DefaultObservation { get; set; }
|
||||
|
||||
public Func<TransparencyWitnessRequest, TransparencyWitnessObservation?>? OnRequest { get; set; }
|
||||
|
||||
public Task<TransparencyWitnessObservation?> GetObservationAsync(TransparencyWitnessRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
if (OnRequest is not null)
|
||||
{
|
||||
return Task.FromResult(OnRequest(request));
|
||||
}
|
||||
|
||||
return Task.FromResult(DefaultObservation);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestAttestationSigningService : IAttestationSigningService
|
||||
{
|
||||
public List<AttestationSignRequest> Requests { get; } = new();
|
||||
|
||||
public AttestationSignResult Result { get; set; } = new();
|
||||
|
||||
public Func<AttestationSignRequest, SubmissionContext, AttestationSignResult>? OnSign { get; set; }
|
||||
|
||||
public Task<AttestationSignResult> SignAsync(AttestationSignRequest request, SubmissionContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Requests.Add(request);
|
||||
if (OnSign is not null)
|
||||
{
|
||||
return Task.FromResult(OnSign(request, context));
|
||||
}
|
||||
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TestBulkVerificationJobStore : IBulkVerificationJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, BulkVerificationJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Func<Task<BulkVerificationJob?>>? OnTryAcquireAsync { get; set; }
|
||||
|
||||
public Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(job.Id) ? Guid.NewGuid().ToString("N") : job.Id;
|
||||
job.Id = id;
|
||||
_jobs[id] = job;
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob?> TryAcquireAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (OnTryAcquireAsync is not null)
|
||||
{
|
||||
return OnTryAcquireAsync();
|
||||
}
|
||||
|
||||
foreach (var job in _jobs.Values)
|
||||
{
|
||||
if (job.Status == BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
return Task.FromResult<BulkVerificationJob?>(job);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<BulkVerificationJob?>(null);
|
||||
}
|
||||
|
||||
public Task<bool> TryUpdateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs[job.Id] = job;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<int> CountQueuedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var job in _jobs.Values)
|
||||
{
|
||||
if (job.Status == BulkVerificationJobStatus.Queued)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user