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

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