save progress
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Attestor.Core.Signing;
|
||||
@@ -10,27 +11,33 @@ namespace StellaOps.Attestor.Core.Signing;
|
||||
public static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private static readonly byte[] Prefix = Encoding.ASCII.GetBytes("DSSEv1");
|
||||
private static readonly byte[] Space = new byte[] { (byte)' ' };
|
||||
|
||||
public static byte[] Compute(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var header = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[Prefix.Length + sizeof(long) + header.Length + sizeof(long) + payload.Length];
|
||||
var offset = 0;
|
||||
var payloadTypeValue = payloadType ?? string.Empty;
|
||||
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadTypeValue);
|
||||
var payloadTypeLength = Encoding.ASCII.GetBytes(payloadTypeBytes.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var payloadLength = Encoding.ASCII.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
Prefix.CopyTo(buffer, offset);
|
||||
offset += Prefix.Length;
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
Write(buffer, Prefix);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadTypeLength);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadTypeBytes);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payloadLength);
|
||||
Write(buffer, Space);
|
||||
Write(buffer, payload);
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, sizeof(long)), (ulong)header.Length);
|
||||
offset += sizeof(long);
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
header.CopyTo(buffer, offset);
|
||||
offset += header.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, sizeof(long)), (ulong)payload.Length);
|
||||
offset += sizeof(long);
|
||||
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
|
||||
return buffer;
|
||||
private static void Write(ArrayBufferWriter<byte> writer, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var span = writer.GetSpan(bytes.Length);
|
||||
bytes.CopyTo(span);
|
||||
writer.Advance(bytes.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0049-M | DONE | Maintainability audit for StellaOps.Attestor.Core. |
|
||||
| AUDIT-0049-T | DONE | Test coverage audit for StellaOps.Attestor.Core. |
|
||||
| AUDIT-0049-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0049-A | DOING | Pending approval for changes. |
|
||||
|
||||
@@ -11,6 +11,17 @@ internal sealed class InMemoryBulkVerificationJobStore : IBulkVerificationJobSto
|
||||
{
|
||||
private readonly ConcurrentQueue<BulkVerificationJob> _queue = new();
|
||||
private readonly ConcurrentDictionary<string, BulkVerificationJob> _jobs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryBulkVerificationJobStore()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryBulkVerificationJobStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<BulkVerificationJob> CreateAsync(BulkVerificationJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -36,7 +47,7 @@ internal sealed class InMemoryBulkVerificationJobStore : IBulkVerificationJobSto
|
||||
}
|
||||
|
||||
job.Status = BulkVerificationJobStatus.Running;
|
||||
job.StartedAt ??= DateTimeOffset.UtcNow;
|
||||
job.StartedAt ??= _timeProvider.GetUtcNow();
|
||||
return Task.FromResult<BulkVerificationJob?>(job);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Infrastructure.Tests")]
|
||||
|
||||
@@ -499,6 +499,10 @@ public sealed class PostgresRekorSubmissionQueue : IRekorSubmissionQueue
|
||||
|
||||
private static RekorQueueItem ReadQueueItem(NpgsqlDataReader reader)
|
||||
{
|
||||
var nextRetryAtOrdinal = reader.GetOrdinal("next_retry_at");
|
||||
var createdAtOrdinal = reader.GetOrdinal("created_at");
|
||||
var updatedAtOrdinal = reader.GetOrdinal("updated_at");
|
||||
|
||||
return new RekorQueueItem
|
||||
{
|
||||
Id = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
@@ -509,9 +513,11 @@ public sealed class PostgresRekorSubmissionQueue : IRekorSubmissionQueue
|
||||
Status = Enum.Parse<RekorSubmissionStatus>(reader.GetString(reader.GetOrdinal("status")), ignoreCase: true),
|
||||
AttemptCount = reader.GetInt32(reader.GetOrdinal("attempt_count")),
|
||||
MaxAttempts = reader.GetInt32(reader.GetOrdinal("max_attempts")),
|
||||
NextRetryAt = reader.GetDateTime(reader.GetOrdinal("next_retry_at")),
|
||||
CreatedAt = reader.GetDateTime(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetDateTime(reader.GetOrdinal("updated_at")),
|
||||
NextRetryAt = reader.IsDBNull(nextRetryAtOrdinal)
|
||||
? null
|
||||
: reader.GetFieldValue<DateTimeOffset>(nextRetryAtOrdinal),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(createdAtOrdinal),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(updatedAtOrdinal),
|
||||
LastError = reader.IsDBNull(reader.GetOrdinal("last_error"))
|
||||
? null
|
||||
: reader.GetString(reader.GetOrdinal("last_error")),
|
||||
|
||||
@@ -205,6 +205,13 @@ internal sealed class HttpRekorClient : IRekorClient
|
||||
|
||||
try
|
||||
{
|
||||
var logIndex = await GetLogIndexAsync(rekorUuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
if (!logIndex.HasValue)
|
||||
{
|
||||
return RekorInclusionVerificationResult.Failure(
|
||||
"Failed to resolve Rekor log index for inclusion proof");
|
||||
}
|
||||
|
||||
// Compute expected leaf hash from payload
|
||||
var expectedLeafHash = MerkleProofVerifier.HashLeaf(payloadDigest);
|
||||
var actualLeafHash = MerkleProofVerifier.HexToBytes(proof.Inclusion.LeafHash);
|
||||
@@ -225,13 +232,10 @@ internal sealed class HttpRekorClient : IRekorClient
|
||||
|
||||
var expectedRootHash = MerkleProofVerifier.HexToBytes(proof.Checkpoint.RootHash);
|
||||
|
||||
// Extract leaf index from UUID (last 8 bytes are the index in hex)
|
||||
var leafIndex = ExtractLeafIndex(rekorUuid);
|
||||
|
||||
// Compute root from path
|
||||
var computedRoot = MerkleProofVerifier.ComputeRootFromPath(
|
||||
actualLeafHash,
|
||||
leafIndex,
|
||||
logIndex.Value,
|
||||
proof.Checkpoint.Size,
|
||||
proofPath);
|
||||
|
||||
@@ -248,7 +252,7 @@ internal sealed class HttpRekorClient : IRekorClient
|
||||
// Verify root hash matches checkpoint
|
||||
var verified = MerkleProofVerifier.VerifyInclusion(
|
||||
actualLeafHash,
|
||||
leafIndex,
|
||||
logIndex.Value,
|
||||
proof.Checkpoint.Size,
|
||||
proofPath,
|
||||
expectedRootHash);
|
||||
@@ -263,13 +267,13 @@ internal sealed class HttpRekorClient : IRekorClient
|
||||
|
||||
_logger.LogInformation(
|
||||
"Successfully verified Rekor inclusion for UUID {Uuid} at index {Index}",
|
||||
rekorUuid, leafIndex);
|
||||
rekorUuid, logIndex);
|
||||
|
||||
return RekorInclusionVerificationResult.Success(
|
||||
leafIndex,
|
||||
logIndex.Value,
|
||||
computedRootHex,
|
||||
proof.Checkpoint.RootHash,
|
||||
checkpointSignatureValid: true); // TODO: Implement checkpoint signature verification
|
||||
checkpointSignatureValid: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
@@ -279,36 +283,47 @@ internal sealed class HttpRekorClient : IRekorClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the leaf index from a Rekor UUID.
|
||||
/// Rekor UUIDs are formatted as: <entry-hash>-<tree-id>-<log-index-hex>
|
||||
/// </summary>
|
||||
private static long ExtractLeafIndex(string rekorUuid)
|
||||
private async Task<long?> GetLogIndexAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken)
|
||||
{
|
||||
// Try to parse as hex number from the end of the UUID
|
||||
// Rekor v1 format: 64 hex chars for entry hash + log index suffix
|
||||
if (rekorUuid.Length >= 16)
|
||||
var entryUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, entryUri);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Take last 16 chars as potential hex index
|
||||
var indexPart = rekorUuid[^16..];
|
||||
if (long.TryParse(indexPart, System.Globalization.NumberStyles.HexNumber, null, out var index))
|
||||
_logger.LogDebug("Rekor entry {Uuid} not found when resolving log index", rekorUuid);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return TryGetLogIndex(document.RootElement, out var logIndex) ? logIndex : null;
|
||||
}
|
||||
|
||||
private static bool TryGetLogIndex(JsonElement element, out long logIndex)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (element.TryGetProperty("logIndex", out var logIndexElement)
|
||||
&& logIndexElement.TryGetInt64(out logIndex))
|
||||
{
|
||||
return index;
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (TryGetLogIndex(property.Value, out logIndex))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try parsing UUID parts separated by dashes
|
||||
var parts = rekorUuid.Split('-');
|
||||
if (parts.Length >= 1)
|
||||
{
|
||||
var lastPart = parts[^1];
|
||||
if (long.TryParse(lastPart, System.Globalization.NumberStyles.HexNumber, null, out var index))
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 0 if we can't parse
|
||||
return 0;
|
||||
logIndex = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,15 +13,18 @@ namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
internal sealed class StubRekorClient : IRekorClient
|
||||
{
|
||||
private readonly ILogger<StubRekorClient> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public StubRekorClient(ILogger<StubRekorClient> logger)
|
||||
public StubRekorClient(ILogger<StubRekorClient> logger, TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var uuid = Guid.NewGuid().ToString();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(request.Meta.BundleSha256 ?? string.Empty));
|
||||
var uuid = new Guid(hash.AsSpan(0, 16)).ToString();
|
||||
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
|
||||
|
||||
var proof = new RekorProofResponse
|
||||
@@ -28,7 +34,7 @@ internal sealed class StubRekorClient : IRekorClient
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = request.Meta.BundleSha256,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
@@ -40,7 +46,7 @@ internal sealed class StubRekorClient : IRekorClient
|
||||
var response = new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = uuid,
|
||||
Index = Random.Shared.NextInt64(1, long.MaxValue),
|
||||
Index = ComputeDeterministicIndex(hash),
|
||||
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
|
||||
Status = "included",
|
||||
Proof = proof
|
||||
@@ -59,7 +65,7 @@ internal sealed class StubRekorClient : IRekorClient
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
@@ -85,4 +91,20 @@ internal sealed class StubRekorClient : IRekorClient
|
||||
expectedRootHash: "stub-root-hash",
|
||||
checkpointSignatureValid: true));
|
||||
}
|
||||
|
||||
private static long ComputeDeterministicIndex(byte[] hash)
|
||||
{
|
||||
if (hash.Length < sizeof(long))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var value = BinaryPrimitives.ReadInt64BigEndian(hash.AsSpan(0, sizeof(long)));
|
||||
if (value == long.MinValue)
|
||||
{
|
||||
return long.MaxValue;
|
||||
}
|
||||
|
||||
return Math.Abs(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton(sp =>
|
||||
@@ -66,9 +67,21 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IAttestorBundleService, AttestorBundleService>();
|
||||
services.AddSingleton<AttestorSigningKeyRegistry>();
|
||||
services.AddSingleton<IAttestationSigningService, AttestorSigningService>();
|
||||
services.AddHttpClient<HttpRekorClient>(client =>
|
||||
services.AddHttpClient<HttpRekorClient>((sp, client) =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var timeoutMs = options.Rekor.Primary.ProofTimeoutMs;
|
||||
if (options.Rekor.Mirror.Enabled)
|
||||
{
|
||||
timeoutMs = Math.Max(timeoutMs, options.Rekor.Mirror.ProofTimeoutMs);
|
||||
}
|
||||
|
||||
if (timeoutMs <= 0)
|
||||
{
|
||||
timeoutMs = 15_000;
|
||||
}
|
||||
|
||||
client.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
|
||||
});
|
||||
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
|
||||
|
||||
@@ -104,7 +117,7 @@ public static class ServiceCollectionExtensions
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Redis.Url))
|
||||
{
|
||||
return new InMemoryAttestorDedupeStore();
|
||||
return ActivatorUtilities.CreateInstance<InMemoryAttestorDedupeStore>(sp);
|
||||
}
|
||||
|
||||
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
|
||||
@@ -185,27 +185,22 @@ internal sealed class AttestorSigningKeyRegistry : IDisposable
|
||||
throw new InvalidOperationException($"Signing key '{key.KeyId}' must specify kmsVersionId when using mode 'kms'.");
|
||||
}
|
||||
|
||||
var material = kmsClient.ExportAsync(providerKeyId, versionId, default).GetAwaiter().GetResult();
|
||||
var parameters = new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
D = material.D,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = material.Qx,
|
||||
Y = material.Qy
|
||||
}
|
||||
};
|
||||
|
||||
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["kms.version"] = material.VersionId
|
||||
["kms.version"] = versionId
|
||||
};
|
||||
|
||||
var privateHandle = System.Text.Encoding.UTF8.GetBytes(
|
||||
string.IsNullOrWhiteSpace(versionId) ? providerKeyId : versionId);
|
||||
if (privateHandle.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Signing key '{key.KeyId}' must supply a non-empty KMS reference.");
|
||||
}
|
||||
|
||||
var signingKey = new CryptoSigningKey(
|
||||
new CryptoKeyReference(providerKeyId, providerName),
|
||||
normalizedAlgorithm,
|
||||
in parameters,
|
||||
privateHandle,
|
||||
now,
|
||||
expiresAt: null,
|
||||
metadata: metadata);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Verify\StellaOps.Attestor.Verify.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -8,11 +8,15 @@ namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class InMemoryAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
public List<AttestorAuditRecord> Records { get; } = new();
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Records.Add(record);
|
||||
lock (_sync)
|
||||
{
|
||||
Records.Add(record);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,23 @@ namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryAttestorDedupeStore()
|
||||
: this(TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryAttestorDedupeStore(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.TryGetValue(bundleSha256, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
if (entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
return Task.FromResult<string?>(entry.Uuid);
|
||||
}
|
||||
@@ -27,7 +38,7 @@ internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
|
||||
|
||||
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
|
||||
_store[bundleSha256] = (rekorUuid, _timeProvider.GetUtcNow().Add(ttl));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.CompareOrdinal(e.RekorUuid, continuation.RekorUuid) >= 0;
|
||||
return string.CompareOrdinal(e.RekorUuid, continuation.RekorUuid) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,19 +150,19 @@ internal sealed class InMemoryAttestorEntryRepository : IAttestorEntryRepository
|
||||
.ThenBy(e => e.RekorUuid, StringComparer.Ordinal);
|
||||
|
||||
var page = ordered.Take(pageSize + 1).ToList();
|
||||
AttestorEntry? next = null;
|
||||
AttestorEntry? continuationSource = null;
|
||||
if (page.Count > pageSize)
|
||||
{
|
||||
next = page[^1];
|
||||
page.RemoveAt(page.Count - 1);
|
||||
continuationSource = page[^1];
|
||||
}
|
||||
|
||||
var result = new AttestorEntryQueryResult
|
||||
{
|
||||
Items = page,
|
||||
ContinuationToken = next is null
|
||||
ContinuationToken = continuationSource is null
|
||||
? null
|
||||
: AttestorEntryContinuationToken.Encode(next.CreatedAt, next.RekorUuid)
|
||||
: AttestorEntryContinuationToken.Encode(continuationSource.CreatedAt, continuationSource.RekorUuid)
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
|
||||
@@ -54,7 +54,8 @@ internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposabl
|
||||
metadata["bundle.sha256"] = bundle.BundleSha256;
|
||||
metadata["rekor.uuid"] = bundle.RekorUuid;
|
||||
|
||||
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(metadata);
|
||||
var orderedMetadata = new SortedDictionary<string, string>(metadata, StringComparer.Ordinal);
|
||||
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(orderedMetadata);
|
||||
await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
|
||||
await PutObjectAsync(prefix + "meta/" + bundle.BundleSha256 + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
@@ -16,6 +17,8 @@ public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
|
||||
|
||||
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var node = new JsonObject
|
||||
{
|
||||
["payloadType"] = request.Bundle.Dsse.PayloadType,
|
||||
@@ -23,14 +26,16 @@ public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
|
||||
["signatures"] = CreateSignaturesArray(request)
|
||||
};
|
||||
|
||||
var json = node.ToJsonString(SerializerOptions);
|
||||
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(node, SerializerOptions);
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var signature in request.Bundle.Dsse.Signatures)
|
||||
foreach (var signature in request.Bundle.Dsse.Signatures
|
||||
.OrderBy(s => s.KeyId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Signature, StringComparer.Ordinal))
|
||||
{
|
||||
var obj = new JsonObject
|
||||
{
|
||||
|
||||
@@ -7,4 +7,5 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0055-M | DONE | Maintainability audit for StellaOps.Attestor.Infrastructure. |
|
||||
| AUDIT-0055-T | DONE | Test coverage audit for StellaOps.Attestor.Infrastructure. |
|
||||
| AUDIT-0055-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0055-A | DONE | Applied audit remediation and added infrastructure tests. |
|
||||
| VAL-SMOKE-001 | DONE | Fixed continuation token behavior; unit tests pass. |
|
||||
|
||||
@@ -214,7 +214,10 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
private async Task<AttestorEntry?> ResolveEntryByArtifactAsync(string artifactSha256, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await _repository.GetByArtifactShaAsync(artifactSha256, cancellationToken).ConfigureAwait(false);
|
||||
var entry = entries.OrderByDescending(e => e.CreatedAt).FirstOrDefault();
|
||||
var entry = entries
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ThenBy(e => e.RekorUuid, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#if STELLAOPS_EXPERIMENTAL_REKOR_QUEUE
|
||||
|
||||
using System;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -15,6 +16,7 @@ using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Queue;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Workers;
|
||||
|
||||
@@ -190,41 +192,90 @@ public sealed class RekorRetryWorker : BackgroundService
|
||||
{
|
||||
return backend.ToLowerInvariant() switch
|
||||
{
|
||||
"primary" => new RekorBackend(
|
||||
_attestorOptions.Rekor.Primary.Url ?? throw new InvalidOperationException("Primary Rekor URL not configured"),
|
||||
"primary"),
|
||||
"mirror" => new RekorBackend(
|
||||
_attestorOptions.Rekor.Mirror.Url ?? throw new InvalidOperationException("Mirror Rekor URL not configured"),
|
||||
"mirror"),
|
||||
"primary" => BuildBackend("primary", _attestorOptions.Rekor.Primary),
|
||||
"mirror" => BuildBackend("mirror", _attestorOptions.Rekor.Mirror),
|
||||
_ => throw new InvalidOperationException($"Unknown Rekor backend: {backend}")
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest BuildSubmissionRequest(RekorQueueItem item)
|
||||
{
|
||||
// Reconstruct the submission request from the stored payload
|
||||
var dsseEnvelope = ParseDsseEnvelope(item.DssePayload);
|
||||
return new AttestorSubmissionRequest
|
||||
{
|
||||
TenantId = item.TenantId,
|
||||
BundleSha256 = item.BundleSha256,
|
||||
DssePayload = item.DssePayload
|
||||
Bundle = new AttestorSubmissionRequest.SubmissionBundle
|
||||
{
|
||||
Dsse = dsseEnvelope
|
||||
},
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
BundleSha256 = item.BundleSha256,
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionRequest.DsseEnvelope ParseDsseEnvelope(byte[] payload)
|
||||
{
|
||||
if (payload.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Queue item DSSE payload is empty.");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
|
||||
var payloadType = root.GetProperty("payloadType").GetString()
|
||||
?? throw new InvalidOperationException("Queue item DSSE payload missing payloadType.");
|
||||
var payloadBase64 = root.GetProperty("payload").GetString()
|
||||
?? throw new InvalidOperationException("Queue item DSSE payload missing payload.");
|
||||
|
||||
var signatures = new List<AttestorSubmissionRequest.DsseSignature>();
|
||||
if (root.TryGetProperty("signatures", out var signaturesElement) && signaturesElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var signatureElement in signaturesElement.EnumerateArray())
|
||||
{
|
||||
var signatureValue = signatureElement.GetProperty("sig").GetString()
|
||||
?? throw new InvalidOperationException("Queue item DSSE signature missing sig.");
|
||||
signatureElement.TryGetProperty("keyid", out var keyIdElement);
|
||||
|
||||
signatures.Add(new AttestorSubmissionRequest.DsseSignature
|
||||
{
|
||||
Signature = signatureValue,
|
||||
KeyId = keyIdElement.ValueKind == JsonValueKind.String ? keyIdElement.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (signatures.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Queue item DSSE payload missing signatures.");
|
||||
}
|
||||
|
||||
return new AttestorSubmissionRequest.DsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
PayloadBase64 = payloadBase64,
|
||||
Signatures = signatures
|
||||
};
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
{
|
||||
throw new InvalidOperationException($"Rekor backend '{name}' is not configured.");
|
||||
}
|
||||
|
||||
return new RekorBackend
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(options.Url, UriKind.Absolute),
|
||||
ProofTimeout = TimeSpan.FromMilliseconds(options.ProofTimeoutMs),
|
||||
PollInterval = TimeSpan.FromMilliseconds(options.PollIntervalMs),
|
||||
MaxAttempts = options.MaxAttempts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple Rekor backend configuration.
|
||||
/// </summary>
|
||||
public sealed record RekorBackend(string Url, string Name);
|
||||
|
||||
/// <summary>
|
||||
/// Submission request for the retry worker.
|
||||
/// </summary>
|
||||
public sealed class AttestorSubmissionRequest
|
||||
{
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
public string BundleSha256 { get; init; } = string.Empty;
|
||||
public byte[] DssePayload { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -194,7 +194,8 @@ internal sealed class AttestorWebApplicationFactory : WebApplicationFactory<Prog
|
||||
["attestor:s3:useTls"] = "false",
|
||||
["attestor:redis:url"] = string.Empty,
|
||||
["attestor:postgres:connectionString"] = "Host=localhost;Port=5432;Database=attestor-tests",
|
||||
["attestor:postgres:database"] = "attestor-tests"
|
||||
["attestor:postgres:database"] = "attestor-tests",
|
||||
["EvidenceLocker:BaseUrl"] = "http://localhost"
|
||||
};
|
||||
|
||||
configuration.AddInMemoryCollection(settings!);
|
||||
|
||||
@@ -49,7 +49,7 @@ 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 rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
@@ -131,7 +131,7 @@ 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 rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
@@ -199,7 +199,7 @@ 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 rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
@@ -270,7 +270,7 @@ 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 rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -67,7 +66,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
@@ -163,7 +162,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
@@ -250,7 +249,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var submissionService = new AttestorSubmissionService(
|
||||
@@ -416,19 +415,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
|
||||
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;
|
||||
return StellaOps.Attestor.Core.Signing.DssePreAuthenticationEncoding.Compute(payloadType, payload);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
@@ -629,7 +616,7 @@ public sealed class AttestorVerificationServiceTests
|
||||
var engine = new AttestorVerificationEngine(canonicalizer, new TestCryptoHash(), options, NullLogger<AttestorVerificationEngine>.Instance);
|
||||
var repository = new InMemoryAttestorEntryRepository();
|
||||
var dedupeStore = new InMemoryAttestorDedupeStore();
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
|
||||
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>(), TimeProvider.System);
|
||||
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
|
||||
var auditSink = new InMemoryAttestorAuditSink();
|
||||
var witnessClient = new TestTransparencyWitnessClient
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Offline;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Attestor.WebService;
|
||||
|
||||
internal static class AttestorWebServiceEndpoints
|
||||
{
|
||||
public static void MapAttestorEndpoints(this WebApplication app, AttestorOptions attestorOptions)
|
||||
{
|
||||
app.MapGet("/api/v1/attestations", async (HttpRequest request, IAttestorEntryRepository repository, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!AttestationListContracts.TryBuildQuery(request, out var query, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var result = await repository.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AttestationListResponseDto
|
||||
{
|
||||
Items = result.Items.Select(MapToListItem).ToList(),
|
||||
ContinuationToken = result.ContinuationToken
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapPost("/api/v1/attestations:export", async (HttpContext httpContext, AttestationExportRequestDto? requestDto, IAttestorBundleService bundleService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (httpContext.Request.ContentLength > 0 && !IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
AttestorBundleExportRequest request;
|
||||
if (requestDto is null)
|
||||
{
|
||||
request = new AttestorBundleExportRequest();
|
||||
}
|
||||
else if (!requestDto.TryToDomain(out request, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var package = await bundleService.ExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(package);
|
||||
})
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads")
|
||||
.Produces<AttestorBundlePackage>(StatusCodes.Status200OK);
|
||||
|
||||
app.MapPost("/api/v1/attestations:import", async (HttpContext httpContext, AttestorBundlePackage package, IAttestorBundleService bundleService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var result = await bundleService.ImportAsync(package, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions")
|
||||
.Produces<AttestorBundleImportResult>(StatusCodes.Status200OK);
|
||||
|
||||
app.MapPost("/api/v1/attestations:sign", async (AttestationSignRequestDto? requestDto, HttpContext httpContext, IAttestationSigningService signingService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (requestDto is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Request body is required.");
|
||||
}
|
||||
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
||||
}
|
||||
|
||||
var signingRequest = new AttestationSignRequest
|
||||
{
|
||||
KeyId = requestDto.KeyId ?? string.Empty,
|
||||
PayloadType = requestDto.PayloadType ?? string.Empty,
|
||||
PayloadBase64 = requestDto.Payload ?? string.Empty,
|
||||
Mode = requestDto.Mode,
|
||||
CertificateChain = requestDto.CertificateChain ?? new List<string>(),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = requestDto.Artifact?.Sha256 ?? string.Empty,
|
||||
Kind = requestDto.Artifact?.Kind ?? string.Empty,
|
||||
ImageDigest = requestDto.Artifact?.ImageDigest,
|
||||
SubjectUri = requestDto.Artifact?.SubjectUri
|
||||
},
|
||||
LogPreference = requestDto.LogPreference ?? "primary",
|
||||
Archive = requestDto.Archive ?? true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var submissionContext = BuildSubmissionContext(user, certificate);
|
||||
var result = await signingService.SignAsync(signingRequest, submissionContext, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AttestationSignResponseDto
|
||||
{
|
||||
Bundle = result.Bundle,
|
||||
Meta = result.Meta,
|
||||
Key = new AttestationSignKeyDto
|
||||
{
|
||||
KeyId = result.KeyId,
|
||||
Algorithm = result.Algorithm,
|
||||
Mode = result.Mode,
|
||||
Provider = result.Provider,
|
||||
SignedAt = result.SignedAt.ToString("O")
|
||||
}
|
||||
};
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (AttestorSigningException signingEx)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: signingEx.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = signingEx.Code
|
||||
});
|
||||
}
|
||||
}).RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
||||
}
|
||||
|
||||
var submissionContext = BuildSubmissionContext(user, certificate);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await submissionService.SubmitAsync(request, submissionContext, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorValidationException validationEx)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: validationEx.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = validationEx.Code
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapGet("/api/v1/attestations/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapPost("/api/v1/rekor/verify", async (HttpContext httpContext, AttestorVerificationRequest verifyRequest, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorVerificationException ex)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:verify")
|
||||
.RequireRateLimiting("attestor-verifications");
|
||||
|
||||
app.MapPost("/api/v1/rekor/verify:bulk", async (
|
||||
BulkVerificationRequestDto? requestDto,
|
||||
HttpContext httpContext,
|
||||
IBulkVerificationJobStore jobStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var context = BuildBulkJobContext(httpContext.User);
|
||||
|
||||
if (!BulkVerificationContracts.TryBuildJob(requestDto, attestorOptions, context, out var job, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var queued = await jobStore.CountQueuedAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (queued >= Math.Max(1, attestorOptions.Quotas.Bulk.MaxQueuedJobs))
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests, title: "Too many bulk verification jobs queued. Try again later.");
|
||||
}
|
||||
|
||||
job = await jobStore.CreateAsync(job!, cancellationToken).ConfigureAwait(false);
|
||||
var response = BulkVerificationContracts.MapJob(job);
|
||||
return Results.Accepted($"/api/v1/rekor/verify:bulk/{job.Id}", response);
|
||||
}).RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-bulk");
|
||||
|
||||
app.MapGet("/api/v1/rekor/verify:bulk/{jobId}", async (
|
||||
string jobId,
|
||||
HttpContext httpContext,
|
||||
IBulkVerificationJobStore jobStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var job = await jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null || !IsAuthorizedForJob(job, httpContext.User))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(BulkVerificationContracts.MapJob(job));
|
||||
}).RequireAuthorization("attestor:write");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAttestationDetailResultAsync(
|
||||
string uuid,
|
||||
bool refresh,
|
||||
IAttestorVerificationService verificationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await verificationService.GetEntryAsync(uuid, refresh, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapAttestationDetail(entry));
|
||||
}
|
||||
|
||||
private static AttestationDetailResponseDto MapAttestationDetail(AttestorEntry entry)
|
||||
{
|
||||
return new AttestationDetailResponseDto
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
Backend = entry.Log.Backend,
|
||||
Proof = entry.Proof is null ? null : new AttestationProofDto
|
||||
{
|
||||
Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestationCheckpointDto
|
||||
{
|
||||
Origin = entry.Proof.Checkpoint.Origin,
|
||||
Size = entry.Proof.Checkpoint.Size,
|
||||
RootHash = entry.Proof.Checkpoint.RootHash,
|
||||
Timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = entry.Proof.Inclusion is null ? null : new AttestationInclusionDto
|
||||
{
|
||||
LeafHash = entry.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Mirror = entry.Mirror is null ? null : new AttestationMirrorDto
|
||||
{
|
||||
Backend = entry.Mirror.Backend,
|
||||
Uuid = entry.Mirror.Uuid,
|
||||
Index = entry.Mirror.Index,
|
||||
LogUrl = entry.Mirror.Url,
|
||||
Status = entry.Mirror.Status,
|
||||
Proof = entry.Mirror.Proof is null ? null : new AttestationProofDto
|
||||
{
|
||||
Checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new AttestationCheckpointDto
|
||||
{
|
||||
Origin = entry.Mirror.Proof.Checkpoint.Origin,
|
||||
Size = entry.Mirror.Proof.Checkpoint.Size,
|
||||
RootHash = entry.Mirror.Proof.Checkpoint.RootHash,
|
||||
Timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = entry.Mirror.Proof.Inclusion is null ? null : new AttestationInclusionDto
|
||||
{
|
||||
LeafHash = entry.Mirror.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Error = entry.Mirror.Error
|
||||
},
|
||||
Artifact = new AttestationArtifactDto
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind,
|
||||
ImageDigest = entry.Artifact.ImageDigest,
|
||||
SubjectUri = entry.Artifact.SubjectUri
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestationListItemDto MapToListItem(AttestorEntry entry)
|
||||
{
|
||||
return new AttestationListItemDto
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Status = entry.Status,
|
||||
CreatedAt = entry.CreatedAt.ToString("O"),
|
||||
Artifact = new AttestationArtifactDto
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind,
|
||||
ImageDigest = entry.Artifact.ImageDigest,
|
||||
SubjectUri = entry.Artifact.SubjectUri
|
||||
},
|
||||
Signer = new AttestationSignerDto
|
||||
{
|
||||
Mode = entry.SignerIdentity.Mode,
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
Subject = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
},
|
||||
Log = new AttestationLogDto
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status
|
||||
},
|
||||
Mirror = entry.Mirror is null ? null : new AttestationLogDto
|
||||
{
|
||||
Backend = entry.Mirror.Backend,
|
||||
Url = entry.Mirror.Url,
|
||||
Index = entry.Mirror.Index,
|
||||
Status = entry.Mirror.Status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certificate2 certificate)
|
||||
{
|
||||
var subject = user.FindFirst("sub")?.Value ?? certificate.Subject;
|
||||
var audience = user.FindFirst("aud")?.Value ?? string.Empty;
|
||||
var clientId = user.FindFirst("client_id")?.Value;
|
||||
var tenant = user.FindFirst("tenant")?.Value;
|
||||
|
||||
return new SubmissionContext
|
||||
{
|
||||
CallerSubject = subject,
|
||||
CallerAudience = audience,
|
||||
CallerClientId = clientId,
|
||||
CallerTenant = tenant,
|
||||
ClientCertificate = certificate,
|
||||
MtlsThumbprint = certificate.Thumbprint
|
||||
};
|
||||
}
|
||||
|
||||
private static BulkVerificationJobContext BuildBulkJobContext(ClaimsPrincipal user)
|
||||
{
|
||||
var scopes = user.FindAll("scope")
|
||||
.Select(claim => claim.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
return new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = user.FindFirst("tenant")?.Value,
|
||||
RequestedBy = user.FindFirst("sub")?.Value,
|
||||
ClientId = user.FindFirst("client_id")?.Value,
|
||||
Scopes = scopes
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAuthorizedForJob(BulkVerificationJob job, ClaimsPrincipal user)
|
||||
{
|
||||
var tenant = user.FindFirst("tenant")?.Value;
|
||||
if (!string.IsNullOrEmpty(job.Context.Tenant) &&
|
||||
!string.Equals(job.Context.Tenant, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subject = user.FindFirst("sub")?.Value;
|
||||
if (!string.IsNullOrEmpty(job.Context.RequestedBy) &&
|
||||
!string.Equals(job.Context.RequestedBy, subject, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsJsonContentType(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mediaType = contentType.Split(';', 2)[0].Trim();
|
||||
if (mediaType.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return mediaType.EndsWith("/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| mediaType.Contains("+json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IResult UnsupportedMediaTypeResult()
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status415UnsupportedMediaType,
|
||||
title: "Unsupported content type. Submit application/json payloads.",
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = "unsupported_media_type"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
public sealed class AttestationDetailResponseDto
|
||||
{
|
||||
[JsonPropertyName("uuid")]
|
||||
public required string Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; init; }
|
||||
|
||||
[JsonPropertyName("backend")]
|
||||
public required string Backend { get; init; }
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public AttestationProofDto? Proof { get; init; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public required string LogUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("mirror")]
|
||||
public AttestationMirrorDto? Mirror { get; init; }
|
||||
|
||||
[JsonPropertyName("artifact")]
|
||||
public required AttestationArtifactDto Artifact { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationProofDto
|
||||
{
|
||||
[JsonPropertyName("checkpoint")]
|
||||
public AttestationCheckpointDto? Checkpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("inclusion")]
|
||||
public AttestationInclusionDto? Inclusion { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationCheckpointDto
|
||||
{
|
||||
[JsonPropertyName("origin")]
|
||||
public string? Origin { get; init; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
[JsonPropertyName("rootHash")]
|
||||
public string? RootHash { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public string? Timestamp { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AttestationInclusionDto
|
||||
{
|
||||
[JsonPropertyName("leafHash")]
|
||||
public string? LeafHash { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public IReadOnlyList<string> Path { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed class AttestationMirrorDto
|
||||
{
|
||||
[JsonPropertyName("backend")]
|
||||
public required string Backend { get; init; }
|
||||
|
||||
[JsonPropertyName("uuid")]
|
||||
public string? Uuid { get; init; }
|
||||
|
||||
[JsonPropertyName("index")]
|
||||
public long? Index { get; init; }
|
||||
|
||||
[JsonPropertyName("logURL")]
|
||||
public required string LogUrl { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("proof")]
|
||||
public AttestationProofDto? Proof { get; init; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Attestor.WebService.Contracts.Anchors;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
@@ -25,14 +27,13 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of trust anchors.</returns>
|
||||
[HttpGet]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto[]), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TrustAnchorDto[]>> GetAnchorsAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting all trust anchors");
|
||||
|
||||
// TODO: Implement using IProofChainRepository.GetActiveTrustAnchorsAsync
|
||||
|
||||
return Ok(Array.Empty<TrustAnchorDto>());
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,6 +43,8 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trust anchor.</returns>
|
||||
[HttpGet("{anchorId}")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -49,26 +52,8 @@ public class AnchorsController : ControllerBase
|
||||
[FromRoute] string anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!Guid.TryParse(anchorId, out var parsedAnchorId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid anchor ID",
|
||||
Detail = "Anchor ID must be a valid GUID.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Getting trust anchor {AnchorId}", parsedAnchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.GetTrustAnchorAsync
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {parsedAnchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
_logger.LogInformation("Getting trust anchor {AnchorId}", anchorId);
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -78,6 +63,8 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created trust anchor.</returns>
|
||||
[HttpPost]
|
||||
[Authorize("attestor:write")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
@@ -86,26 +73,7 @@ public class AnchorsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Creating trust anchor for pattern {Pattern}", request.PurlPattern);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.SaveTrustAnchorAsync
|
||||
// 1. Check for existing anchor with same pattern
|
||||
// 2. Create new anchor entity
|
||||
// 3. Save to repository
|
||||
// 4. Log audit entry
|
||||
|
||||
var anchor = new TrustAnchorDto
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
PurlPattern = request.PurlPattern,
|
||||
AllowedKeyIds = request.AllowedKeyIds,
|
||||
AllowedPredicateTypes = request.AllowedPredicateTypes,
|
||||
PolicyRef = request.PolicyRef,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
return CreatedAtAction(nameof(GetAnchorAsync), new { anchorId = anchor.AnchorId }, anchor);
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,6 +84,8 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The updated trust anchor.</returns>
|
||||
[HttpPatch("{anchorId:guid}")]
|
||||
[Authorize("attestor:write")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(TrustAnchorDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TrustAnchorDto>> UpdateAnchorAsync(
|
||||
@@ -124,19 +94,7 @@ public class AnchorsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Updating trust anchor {AnchorId}", anchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository
|
||||
// 1. Get existing anchor
|
||||
// 2. Apply updates
|
||||
// 3. Save to repository
|
||||
// 4. Log audit entry
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -147,6 +105,8 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpPost("{anchorId:guid}/revoke-key")]
|
||||
[Authorize("attestor:write")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
@@ -156,20 +116,7 @@ public class AnchorsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Revoking key {KeyId} in anchor {AnchorId}", request.KeyId, anchorId);
|
||||
|
||||
// TODO: Implement using IProofChainRepository.RevokeKeyAsync
|
||||
// 1. Get existing anchor
|
||||
// 2. Add key to revoked_keys
|
||||
// 3. Remove from allowed_keyids
|
||||
// 4. Save to repository
|
||||
// 5. Log audit entry
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -179,6 +126,8 @@ public class AnchorsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>No content on success.</returns>
|
||||
[HttpDelete("{anchorId:guid}")]
|
||||
[Authorize("attestor:write")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteAnchorAsync(
|
||||
@@ -186,14 +135,19 @@ public class AnchorsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Deactivating trust anchor {AnchorId}", anchorId);
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
// TODO: Implement - set is_active = false (soft delete)
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
private static ObjectResult NotImplementedResult()
|
||||
{
|
||||
return new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Title = "Trust Anchor Not Found",
|
||||
Detail = $"No trust anchor found with ID {anchorId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
Title = "Trust anchor management is not implemented.",
|
||||
Status = StatusCodes.Status501NotImplemented,
|
||||
Extensions = { ["code"] = "feature_not_implemented" }
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status501NotImplemented
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
@@ -29,6 +29,8 @@ public class ProofsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created proof bundle ID.</returns>
|
||||
[HttpPost("{entry}/spine")]
|
||||
[Authorize("attestor:write")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(CreateSpineResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -39,49 +41,7 @@ public class ProofsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Creating proof spine for entry {Entry}", entry);
|
||||
|
||||
// Validate entry format
|
||||
if (!IsValidSbomEntryId(entry))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid SBOM Entry ID",
|
||||
Detail = "Entry ID must be in format sha256:<hex>:pkg:<purl>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement spine creation using IProofSpineAssembler
|
||||
// 1. Validate all evidence IDs exist
|
||||
// 2. Validate reasoning ID exists
|
||||
// 3. Validate VEX verdict ID exists
|
||||
// 4. Assemble spine using merkle tree
|
||||
// 5. Sign and store spine
|
||||
// 6. Return proof bundle ID
|
||||
|
||||
foreach (var evidenceId in request.EvidenceIds)
|
||||
{
|
||||
if (!IsValidSha256Id(evidenceId))
|
||||
{
|
||||
return UnprocessableEntity(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid evidence ID",
|
||||
Detail = "Evidence IDs must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status422UnprocessableEntity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var proofBundleId = ComputeProofBundleId(entry, request);
|
||||
|
||||
var receiptUrl = $"/proofs/{Uri.EscapeDataString(entry)}/receipt";
|
||||
var response = new CreateSpineResponse
|
||||
{
|
||||
ProofBundleId = proofBundleId,
|
||||
ReceiptUrl = receiptUrl
|
||||
};
|
||||
|
||||
return Created(receiptUrl, response);
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +51,8 @@ public class ProofsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
[HttpGet("{entry}/receipt")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(typeof(VerificationReceiptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VerificationReceiptDto>> GetReceiptAsync(
|
||||
@@ -98,18 +60,7 @@ public class ProofsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting receipt for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement receipt retrieval using IReceiptGenerator
|
||||
// 1. Get spine for entry
|
||||
// 2. Generate/retrieve verification receipt
|
||||
// 3. Return receipt
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Receipt Not Found",
|
||||
Detail = $"No verification receipt found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -119,6 +70,8 @@ public class ProofsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The proof spine details.</returns>
|
||||
[HttpGet("{entry}/spine")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> GetSpineAsync(
|
||||
@@ -126,15 +79,7 @@ public class ProofsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting spine for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement spine retrieval
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Spine Not Found",
|
||||
Detail = $"No proof spine found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -144,6 +89,8 @@ public class ProofsController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The VEX statement.</returns>
|
||||
[HttpGet("{entry}/vex")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> GetVexAsync(
|
||||
@@ -151,88 +98,19 @@ public class ProofsController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Getting VEX for entry {Entry}", entry);
|
||||
|
||||
// TODO: Implement VEX retrieval
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "VEX Not Found",
|
||||
Detail = $"No VEX statement found for entry {entry}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
private static bool IsValidSbomEntryId(string entry)
|
||||
private static ObjectResult NotImplementedResult()
|
||||
{
|
||||
// Format: sha256:<64-hex>:pkg:<purl>
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
return false;
|
||||
|
||||
var parts = entry.Split(':', 4);
|
||||
if (parts.Length < 4)
|
||||
return false;
|
||||
|
||||
return parts[0] == "sha256"
|
||||
&& parts[1].Length == 64
|
||||
&& parts[1].All(c => "0123456789abcdef".Contains(c))
|
||||
&& parts[2] == "pkg";
|
||||
}
|
||||
|
||||
private static string ComputeProofBundleId(string entry, CreateSpineRequest request)
|
||||
{
|
||||
var evidenceIds = request.EvidenceIds
|
||||
.Select(static value => (value ?? string.Empty).Trim())
|
||||
.Where(static value => value.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal);
|
||||
|
||||
var material = string.Join(
|
||||
"\n",
|
||||
new[]
|
||||
{
|
||||
entry.Trim(),
|
||||
request.PolicyVersion.Trim(),
|
||||
request.ReasoningId.Trim(),
|
||||
request.VexVerdictId.Trim()
|
||||
}.Concat(evidenceIds));
|
||||
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||
return $"sha256:{Convert.ToHexString(digest).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool IsValidSha256Id(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new ObjectResult(new ProblemDetails
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
Title = "Proof chain endpoints are not implemented.",
|
||||
Status = StatusCodes.Status501NotImplemented,
|
||||
Extensions = { ["code"] = "feature_not_implemented" }
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is >= '0' and <= '9')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is >= 'a' and <= 'f')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
StatusCode = StatusCodes.Status501NotImplemented
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Attestor.WebService.Contracts.Proofs;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
@@ -27,6 +29,8 @@ public class VerifyController : ControllerBase
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification receipt.</returns>
|
||||
[HttpPost("{proofBundleId}")]
|
||||
[Authorize("attestor:verify")]
|
||||
[EnableRateLimiting("attestor-verifications")]
|
||||
[ProducesResponseType(typeof(VerificationReceiptDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -35,88 +39,13 @@ public class VerifyController : ControllerBase
|
||||
[FromBody] VerifyProofRequest? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsValidSha256Id(proofBundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid proof bundle ID",
|
||||
Detail = "Proof bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
request ??= new VerifyProofRequest
|
||||
{
|
||||
ProofBundleId = proofBundleId
|
||||
};
|
||||
|
||||
_logger.LogInformation("Verifying proof bundle {BundleId}", proofBundleId);
|
||||
|
||||
// TODO: Implement using IVerificationPipeline per advisory §9.1
|
||||
// Pipeline steps:
|
||||
// 1. DSSE signature verification (for each envelope in chain)
|
||||
// 2. ID recomputation (verify content-addressed IDs match)
|
||||
// 3. Merkle root verification (recompute ProofBundleID)
|
||||
// 4. Trust anchor matching (verify signer key is allowed)
|
||||
// 5. Rekor inclusion proof verification (if enabled)
|
||||
// 6. Policy version compatibility check
|
||||
// 7. Key revocation check
|
||||
|
||||
var checks = new List<VerificationCheckDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Check = "dsse_signature",
|
||||
Status = "pass",
|
||||
KeyId = "example-key-id"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "id_recomputation",
|
||||
Status = "pass"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "merkle_root",
|
||||
Status = "pass"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Check = "trust_anchor",
|
||||
Status = "pass"
|
||||
}
|
||||
};
|
||||
|
||||
if (request.VerifyRekor)
|
||||
{
|
||||
checks.Add(new VerificationCheckDto
|
||||
{
|
||||
Check = "rekor_inclusion",
|
||||
Status = "pass",
|
||||
LogIndex = 12345678
|
||||
});
|
||||
}
|
||||
|
||||
var receipt = new VerificationReceiptDto
|
||||
{
|
||||
ProofBundleId = proofBundleId,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
VerifierVersion = "1.0.0",
|
||||
AnchorId = request.AnchorId,
|
||||
Result = "pass",
|
||||
Checks = checks.ToArray()
|
||||
};
|
||||
|
||||
return Ok(receipt);
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify a DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelopeHash">The envelope body hash.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Signature verification result.</returns>
|
||||
[HttpGet("envelope/{envelopeHash}")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> VerifyEnvelopeAsync(
|
||||
@@ -124,24 +53,12 @@ public class VerifyController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying envelope {Hash}", envelopeHash);
|
||||
|
||||
// TODO: Implement DSSE envelope verification
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Envelope Not Found",
|
||||
Detail = $"No envelope found with hash {envelopeHash}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify Rekor inclusion for an envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelopeHash">The envelope body hash.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Rekor verification result.</returns>
|
||||
[HttpGet("rekor/{envelopeHash}")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> VerifyRekorAsync(
|
||||
@@ -149,50 +66,19 @@ public class VerifyController : ControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_logger.LogInformation("Verifying Rekor inclusion for {Hash}", envelopeHash);
|
||||
|
||||
// TODO: Implement Rekor inclusion proof verification
|
||||
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Rekor Entry Not Found",
|
||||
Detail = $"No Rekor entry found for envelope {envelopeHash}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
private static bool IsValidSha256Id(string value)
|
||||
private static ObjectResult NotImplementedResult()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new ObjectResult(new ProblemDetails
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
Title = "Verification endpoints are not implemented.",
|
||||
Status = StatusCodes.Status501NotImplemented,
|
||||
Extensions = { ["code"] = "feature_not_implemented" }
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is >= '0' and <= '9')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c is >= 'a' and <= 'f')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
StatusCode = StatusCodes.Status501NotImplemented
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ using OpenTelemetry.Trace;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.WebService;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||
@@ -161,13 +162,16 @@ builder.Services.AddScoped<StellaOps.Attestor.WebService.Services.IPredicateType
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Configure HttpClient for Evidence Locker integration
|
||||
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLocker:BaseUrl")
|
||||
?? builder.Configuration.GetValue<string>("EvidenceLockerUrl");
|
||||
if (string.IsNullOrWhiteSpace(evidenceLockerUrl))
|
||||
{
|
||||
throw new InvalidOperationException("EvidenceLocker base URL must be configured (EvidenceLocker:BaseUrl or EvidenceLockerUrl).");
|
||||
}
|
||||
|
||||
builder.Services.AddHttpClient("EvidenceLocker", client =>
|
||||
{
|
||||
// TODO: Configure base address from configuration
|
||||
// For now, use localhost default (will be overridden by actual configuration)
|
||||
var evidenceLockerUrl = builder.Configuration.GetValue<string>("EvidenceLockerUrl")
|
||||
?? "http://localhost:9090";
|
||||
client.BaseAddress = new Uri(evidenceLockerUrl);
|
||||
client.BaseAddress = new Uri(evidenceLockerUrl, UriKind.Absolute);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
@@ -374,419 +378,13 @@ app.MapHealthChecks("/health/live");
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.MapGet("/api/v1/attestations", async (HttpRequest request, IAttestorEntryRepository repository, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!AttestationListContracts.TryBuildQuery(request, out var query, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var result = await repository.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AttestationListResponseDto
|
||||
{
|
||||
Items = result.Items.Select(MapToListItem).ToList(),
|
||||
ContinuationToken = result.ContinuationToken
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapPost("/api/v1/attestations:export", async (HttpContext httpContext, AttestationExportRequestDto? requestDto, IAttestorBundleService bundleService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (httpContext.Request.ContentLength > 0 && !IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
AttestorBundleExportRequest request;
|
||||
if (requestDto is null)
|
||||
{
|
||||
request = new AttestorBundleExportRequest();
|
||||
}
|
||||
else if (!requestDto.TryToDomain(out request, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var package = await bundleService.ExportAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(package);
|
||||
})
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads")
|
||||
.Produces<AttestorBundlePackage>(StatusCodes.Status200OK);
|
||||
|
||||
app.MapPost("/api/v1/attestations:import", async (HttpContext httpContext, AttestorBundlePackage package, IAttestorBundleService bundleService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var result = await bundleService.ImportAsync(package, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions")
|
||||
.Produces<AttestorBundleImportResult>(StatusCodes.Status200OK);
|
||||
|
||||
app.MapPost("/api/v1/attestations:sign", async (AttestationSignRequestDto? requestDto, HttpContext httpContext, IAttestationSigningService signingService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (requestDto is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: "Request body is required.");
|
||||
}
|
||||
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
||||
}
|
||||
|
||||
var signingRequest = new AttestationSignRequest
|
||||
{
|
||||
KeyId = requestDto.KeyId ?? string.Empty,
|
||||
PayloadType = requestDto.PayloadType ?? string.Empty,
|
||||
PayloadBase64 = requestDto.Payload ?? string.Empty,
|
||||
Mode = requestDto.Mode,
|
||||
CertificateChain = requestDto.CertificateChain ?? new List<string>(),
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = requestDto.Artifact?.Sha256 ?? string.Empty,
|
||||
Kind = requestDto.Artifact?.Kind ?? string.Empty,
|
||||
ImageDigest = requestDto.Artifact?.ImageDigest,
|
||||
SubjectUri = requestDto.Artifact?.SubjectUri
|
||||
},
|
||||
LogPreference = requestDto.LogPreference ?? "primary",
|
||||
Archive = requestDto.Archive ?? true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var submissionContext = BuildSubmissionContext(user, certificate);
|
||||
var result = await signingService.SignAsync(signingRequest, submissionContext, cancellationToken).ConfigureAwait(false);
|
||||
var response = new AttestationSignResponseDto
|
||||
{
|
||||
Bundle = result.Bundle,
|
||||
Meta = result.Meta,
|
||||
Key = new AttestationSignKeyDto
|
||||
{
|
||||
KeyId = result.KeyId,
|
||||
Algorithm = result.Algorithm,
|
||||
Mode = result.Mode,
|
||||
Provider = result.Provider,
|
||||
SignedAt = result.SignedAt.ToString("O")
|
||||
}
|
||||
};
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (AttestorSigningException signingEx)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: signingEx.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = signingEx.Code
|
||||
});
|
||||
}
|
||||
}).RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
var certificate = httpContext.Connection.ClientCertificate;
|
||||
if (certificate is null)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Client certificate required");
|
||||
}
|
||||
|
||||
var user = httpContext.User;
|
||||
if (user?.Identity is not { IsAuthenticated: true })
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Authentication required");
|
||||
}
|
||||
|
||||
var submissionContext = BuildSubmissionContext(user, certificate);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await submissionService.SubmitAsync(request, submissionContext, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorValidationException validationEx)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: validationEx.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = validationEx.Code
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-submissions");
|
||||
|
||||
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapGet("/api/v1/attestations/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
|
||||
.RequireAuthorization("attestor:read")
|
||||
.RequireRateLimiting("attestor-reads");
|
||||
|
||||
app.MapPost("/api/v1/rekor/verify", async (HttpContext httpContext, AttestorVerificationRequest verifyRequest, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!IsJsonContentType(httpContext.Request.ContentType))
|
||||
{
|
||||
return UnsupportedMediaTypeResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await verificationService.VerifyAsync(verifyRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (AttestorVerificationException ex)
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest, title: ex.Message, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code
|
||||
});
|
||||
}
|
||||
})
|
||||
.RequireAuthorization("attestor:verify")
|
||||
.RequireRateLimiting("attestor-verifications");
|
||||
|
||||
app.MapPost("/api/v1/rekor/verify:bulk", async (
|
||||
BulkVerificationRequestDto? requestDto,
|
||||
HttpContext httpContext,
|
||||
IBulkVerificationJobStore jobStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var context = BuildBulkJobContext(httpContext.User);
|
||||
|
||||
if (!BulkVerificationContracts.TryBuildJob(requestDto, attestorOptions, context, out var job, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var queued = await jobStore.CountQueuedAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (queued >= Math.Max(1, attestorOptions.Quotas.Bulk.MaxQueuedJobs))
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status429TooManyRequests, title: "Too many bulk verification jobs queued. Try again later.");
|
||||
}
|
||||
|
||||
job = await jobStore.CreateAsync(job!, cancellationToken).ConfigureAwait(false);
|
||||
var response = BulkVerificationContracts.MapJob(job);
|
||||
return Results.Accepted($"/api/v1/rekor/verify:bulk/{job.Id}", response);
|
||||
}).RequireAuthorization("attestor:write")
|
||||
.RequireRateLimiting("attestor-bulk");
|
||||
|
||||
app.MapGet("/api/v1/rekor/verify:bulk/{jobId}", async (
|
||||
string jobId,
|
||||
HttpContext httpContext,
|
||||
IBulkVerificationJobStore jobStore,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var job = await jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
if (job is null || !IsAuthorizedForJob(job, httpContext.User))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(BulkVerificationContracts.MapJob(job));
|
||||
}).RequireAuthorization("attestor:write");
|
||||
app.MapAttestorEndpoints(attestorOptions);
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<IResult> GetAttestationDetailResultAsync(
|
||||
string uuid,
|
||||
bool refresh,
|
||||
IAttestorVerificationService verificationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await verificationService.GetEntryAsync(uuid, refresh, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(MapAttestationDetail(entry));
|
||||
}
|
||||
|
||||
static object MapAttestationDetail(AttestorEntry entry)
|
||||
{
|
||||
return new
|
||||
{
|
||||
uuid = entry.RekorUuid,
|
||||
index = entry.Index,
|
||||
backend = entry.Log.Backend,
|
||||
proof = entry.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Proof.Checkpoint.Origin,
|
||||
size = entry.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Proof.Inclusion.LeafHash,
|
||||
path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
logURL = entry.Log.Url,
|
||||
status = entry.Status,
|
||||
mirror = entry.Mirror is null ? null : new
|
||||
{
|
||||
backend = entry.Mirror.Backend,
|
||||
uuid = entry.Mirror.Uuid,
|
||||
index = entry.Mirror.Index,
|
||||
logURL = entry.Mirror.Url,
|
||||
status = entry.Mirror.Status,
|
||||
proof = entry.Mirror.Proof is null ? null : new
|
||||
{
|
||||
checkpoint = entry.Mirror.Proof.Checkpoint is null ? null : new
|
||||
{
|
||||
origin = entry.Mirror.Proof.Checkpoint.Origin,
|
||||
size = entry.Mirror.Proof.Checkpoint.Size,
|
||||
rootHash = entry.Mirror.Proof.Checkpoint.RootHash,
|
||||
timestamp = entry.Mirror.Proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
inclusion = entry.Mirror.Proof.Inclusion is null ? null : new
|
||||
{
|
||||
leafHash = entry.Mirror.Proof.Inclusion.LeafHash,
|
||||
path = entry.Mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
error = entry.Mirror.Error
|
||||
},
|
||||
artifact = new
|
||||
{
|
||||
sha256 = entry.Artifact.Sha256,
|
||||
kind = entry.Artifact.Kind,
|
||||
imageDigest = entry.Artifact.ImageDigest,
|
||||
subjectUri = entry.Artifact.SubjectUri
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static AttestationListItemDto MapToListItem(AttestorEntry entry)
|
||||
{
|
||||
return new AttestationListItemDto
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Status = entry.Status,
|
||||
CreatedAt = entry.CreatedAt.ToString("O"),
|
||||
Artifact = new AttestationArtifactDto
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind,
|
||||
ImageDigest = entry.Artifact.ImageDigest,
|
||||
SubjectUri = entry.Artifact.SubjectUri
|
||||
},
|
||||
Signer = new AttestationSignerDto
|
||||
{
|
||||
Mode = entry.SignerIdentity.Mode,
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
Subject = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
},
|
||||
Log = new AttestationLogDto
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status
|
||||
},
|
||||
Mirror = entry.Mirror is null ? null : new AttestationLogDto
|
||||
{
|
||||
Backend = entry.Mirror.Backend,
|
||||
Url = entry.Mirror.Url,
|
||||
Index = entry.Mirror.Index,
|
||||
Status = entry.Mirror.Status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static SubmissionContext BuildSubmissionContext(ClaimsPrincipal user, X509Certificate2 certificate)
|
||||
{
|
||||
var subject = user.FindFirst("sub")?.Value ?? certificate.Subject;
|
||||
var audience = user.FindFirst("aud")?.Value ?? string.Empty;
|
||||
var clientId = user.FindFirst("client_id")?.Value;
|
||||
var tenant = user.FindFirst("tenant")?.Value;
|
||||
|
||||
return new SubmissionContext
|
||||
{
|
||||
CallerSubject = subject,
|
||||
CallerAudience = audience,
|
||||
CallerClientId = clientId,
|
||||
CallerTenant = tenant,
|
||||
ClientCertificate = certificate,
|
||||
MtlsThumbprint = certificate.Thumbprint
|
||||
};
|
||||
}
|
||||
|
||||
static BulkVerificationJobContext BuildBulkJobContext(ClaimsPrincipal user)
|
||||
{
|
||||
var scopes = user.FindAll("scope")
|
||||
.Select(claim => claim.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.ToList();
|
||||
|
||||
return new BulkVerificationJobContext
|
||||
{
|
||||
Tenant = user.FindFirst("tenant")?.Value,
|
||||
RequestedBy = user.FindFirst("sub")?.Value,
|
||||
ClientId = user.FindFirst("client_id")?.Value,
|
||||
Scopes = scopes
|
||||
};
|
||||
}
|
||||
|
||||
static bool IsAuthorizedForJob(BulkVerificationJob job, ClaimsPrincipal user)
|
||||
{
|
||||
var tenant = user.FindFirst("tenant")?.Value;
|
||||
if (!string.IsNullOrEmpty(job.Context.Tenant) &&
|
||||
!string.Equals(job.Context.Tenant, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var subject = user.FindFirst("sub")?.Value;
|
||||
if (!string.IsNullOrEmpty(job.Context.RequestedBy) &&
|
||||
!string.Equals(job.Context.RequestedBy, subject, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
|
||||
{
|
||||
var certificates = new List<X509Certificate2>();
|
||||
@@ -857,34 +455,6 @@ static IEnumerable<string> ExtractScopes(ClaimsPrincipal user)
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsJsonContentType(string? contentType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mediaType = contentType.Split(';', 2)[0].Trim();
|
||||
if (mediaType.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return mediaType.EndsWith("/json", StringComparison.OrdinalIgnoreCase)
|
||||
|| mediaType.Contains("+json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static IResult UnsupportedMediaTypeResult()
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status415UnsupportedMediaType,
|
||||
title: "Unsupported content type. Submit application/json payloads.",
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = "unsupported_media_type"
|
||||
});
|
||||
}
|
||||
|
||||
internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "NoAuth";
|
||||
@@ -909,3 +479,7 @@ internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationScheme
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0072-M | DONE | Maintainability audit for StellaOps.Attestor.WebService. |
|
||||
| AUDIT-0072-T | DONE | Test coverage audit for StellaOps.Attestor.WebService. |
|
||||
| AUDIT-0072-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0072-A | DOING | Addressing WebService audit findings. |
|
||||
|
||||
Reference in New Issue
Block a user