save progress
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user