save progress

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

View File

@@ -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);
}
}

View File

@@ -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. |

View File

@@ -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);
}

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
[assembly: InternalsVisibleTo("StellaOps.Attestor.Infrastructure.Tests")]

View File

@@ -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")),

View File

@@ -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: &lt;entry-hash&gt;-&lt;tree-id&gt;-&lt;log-index-hex&gt;
/// </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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>();

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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
{

View File

@@ -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. |

View File

@@ -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;

View File

@@ -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

View File

@@ -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!);

View File

@@ -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();

View File

@@ -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

View File

@@ -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"
});
}
}

View File

@@ -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; }
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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
{
}

View File

@@ -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" />

View File

@@ -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. |