feat: Initialize Zastava Webhook service with TLS and Authority authentication
- Added Program.cs to set up the web application with Serilog for logging, health check endpoints, and a placeholder admission endpoint. - Configured Kestrel server to use TLS 1.3 and handle client certificates appropriately. - Created StellaOps.Zastava.Webhook.csproj with necessary dependencies including Serilog and Polly. - Documented tasks in TASKS.md for the Zastava Webhook project, outlining current work and exit criteria for each task.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Attestor.Tests")]
|
||||
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
|
||||
internal sealed class HttpRekorClient : IRekorClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpRekorClient> _logger;
|
||||
|
||||
public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
|
||||
{
|
||||
Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
|
||||
};
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||
{
|
||||
var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Rekor reported a conflict: {message}");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var root = document.RootElement;
|
||||
|
||||
long? index = null;
|
||||
if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
|
||||
{
|
||||
index = indexValue;
|
||||
}
|
||||
|
||||
return new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
|
||||
Index = index,
|
||||
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
|
||||
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
|
||||
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Rekor proof for {Uuid} not found", 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 TryParseProof(document.RootElement);
|
||||
}
|
||||
|
||||
private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
|
||||
{
|
||||
var signatures = new List<object>();
|
||||
foreach (var sig in request.Bundle.Dsse.Signatures)
|
||||
{
|
||||
signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
dsseEnvelope = new
|
||||
{
|
||||
payload = request.Bundle.Dsse.PayloadBase64,
|
||||
payloadType = request.Bundle.Dsse.PayloadType,
|
||||
signatures
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static RekorProofResponse? TryParseProof(JsonElement proofElement)
|
||||
{
|
||||
if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
|
||||
var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
|
||||
|
||||
return new RekorProofResponse
|
||||
{
|
||||
Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
|
||||
? new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
|
||||
Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
|
||||
RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
|
||||
Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
|
||||
}
|
||||
: null,
|
||||
Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
|
||||
? new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
|
||||
Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
|
||||
? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
|
||||
: Array.Empty<string>()
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static Uri BuildUri(Uri baseUri, string relative)
|
||||
{
|
||||
if (!relative.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
relative = "/" + relative;
|
||||
}
|
||||
|
||||
return new Uri(baseUri, relative);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Rekor;
|
||||
|
||||
internal sealed class StubRekorClient : IRekorClient
|
||||
{
|
||||
private readonly ILogger<StubRekorClient> _logger;
|
||||
|
||||
public StubRekorClient(ILogger<StubRekorClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var uuid = Guid.NewGuid().ToString();
|
||||
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
|
||||
|
||||
var proof = new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = request.Meta.BundleSha256,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = request.Meta.BundleSha256,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
};
|
||||
|
||||
var response = new RekorSubmissionResponse
|
||||
{
|
||||
Uuid = uuid,
|
||||
Index = Random.Shared.NextInt64(1, long.MaxValue),
|
||||
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
|
||||
Status = "included",
|
||||
Proof = proof
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
|
||||
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
|
||||
{
|
||||
Checkpoint = new RekorProofResponse.RekorCheckpoint
|
||||
{
|
||||
Origin = backend.Url.Host,
|
||||
Size = 1,
|
||||
RootHash = string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
},
|
||||
Inclusion = new RekorProofResponse.RekorInclusionProof
|
||||
{
|
||||
LeafHash = string.Empty,
|
||||
Path = Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Infrastructure.Rekor;
|
||||
using StellaOps.Attestor.Infrastructure.Storage;
|
||||
using StellaOps.Attestor.Infrastructure.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton<AttestorSubmissionValidator>();
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
services.AddHttpClient<HttpRekorClient>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
services.AddSingleton<IRekorClient>(sp => sp.GetRequiredService<HttpRekorClient>());
|
||||
|
||||
services.AddSingleton<IMongoClient>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Mongo.Uri))
|
||||
{
|
||||
throw new InvalidOperationException("Attestor MongoDB connection string is not configured.");
|
||||
}
|
||||
|
||||
return new MongoClient(options.Mongo.Uri);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
var databaseName = MongoUrl.Create(opts.Mongo.Uri).DatabaseName ?? opts.Mongo.Database;
|
||||
return client.GetDatabase(databaseName);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<MongoAttestorEntryRepository.AttestorEntryDocument>(opts.Mongo.EntriesCollection);
|
||||
});
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
return database.GetCollection<MongoAttestorAuditSink.AttestorAuditDocument>(opts.Mongo.AuditCollection);
|
||||
});
|
||||
|
||||
services.AddSingleton<IAttestorEntryRepository, MongoAttestorEntryRepository>();
|
||||
services.AddSingleton<IAttestorAuditSink, MongoAttestorAuditSink>();
|
||||
|
||||
|
||||
services.AddSingleton<IAttestorDedupeStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Redis.Url))
|
||||
{
|
||||
return new InMemoryAttestorDedupeStore();
|
||||
}
|
||||
|
||||
var multiplexer = sp.GetRequiredService<IConnectionMultiplexer>();
|
||||
return new RedisAttestorDedupeStore(multiplexer, sp.GetRequiredService<IOptions<AttestorOptions>>());
|
||||
});
|
||||
|
||||
services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(options.Redis.Url))
|
||||
{
|
||||
throw new InvalidOperationException("Redis connection string is required when redis dedupe is enabled.");
|
||||
}
|
||||
|
||||
return ConnectionMultiplexer.Connect(options.Redis.Url);
|
||||
});
|
||||
|
||||
services.AddSingleton<IAttestorArchiveStore>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
if (options.S3.Enabled && !string.IsNullOrWhiteSpace(options.S3.Endpoint) && !string.IsNullOrWhiteSpace(options.S3.Bucket))
|
||||
{
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
ServiceURL = options.S3.Endpoint,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = !options.S3.UseTls
|
||||
};
|
||||
|
||||
var client = new AmazonS3Client(FallbackCredentialsFactory.GetCredentials(), config);
|
||||
return new S3AttestorArchiveStore(client, sp.GetRequiredService<IOptions<AttestorOptions>>(), sp.GetRequiredService<ILogger<S3AttestorArchiveStore>>());
|
||||
}
|
||||
|
||||
return new NullAttestorArchiveStore(sp.GetRequiredService<ILogger<NullAttestorArchiveStore>>());
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.307.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
|
||||
|
||||
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.TryGetValue(bundleSha256, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Task.FromResult<string?>(entry.Uuid);
|
||||
}
|
||||
|
||||
_store.TryRemove(bundleSha256, out _);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class MongoAttestorAuditSink : IAttestorAuditSink
|
||||
{
|
||||
private readonly IMongoCollection<AttestorAuditDocument> _collection;
|
||||
|
||||
public MongoAttestorAuditSink(IMongoCollection<AttestorAuditDocument> collection)
|
||||
{
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
public Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = AttestorAuditDocument.FromRecord(record);
|
||||
return _collection.InsertOneAsync(document, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class AttestorAuditDocument
|
||||
{
|
||||
[BsonId]
|
||||
public ObjectId Id { get; set; }
|
||||
|
||||
[BsonElement("ts")]
|
||||
public BsonDateTime Timestamp { get; set; } = BsonDateTime.Create(DateTime.UtcNow);
|
||||
|
||||
[BsonElement("action")]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("result")]
|
||||
public string Result { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("rekorUuid")]
|
||||
public string? RekorUuid { get; set; }
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("artifactSha256")]
|
||||
public string ArtifactSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("bundleSha256")]
|
||||
public string BundleSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("latencyMs")]
|
||||
public long LatencyMs { get; set; }
|
||||
|
||||
[BsonElement("caller")]
|
||||
public CallerDocument Caller { get; set; } = new();
|
||||
|
||||
[BsonElement("metadata")]
|
||||
public BsonDocument Metadata { get; set; } = new();
|
||||
|
||||
public static AttestorAuditDocument FromRecord(AttestorAuditRecord record)
|
||||
{
|
||||
var metadata = new BsonDocument();
|
||||
foreach (var kvp in record.Metadata)
|
||||
{
|
||||
metadata[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return new AttestorAuditDocument
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
Timestamp = BsonDateTime.Create(record.Timestamp.UtcDateTime),
|
||||
Action = record.Action,
|
||||
Result = record.Result,
|
||||
RekorUuid = record.RekorUuid,
|
||||
Index = record.Index,
|
||||
ArtifactSha256 = record.ArtifactSha256,
|
||||
BundleSha256 = record.BundleSha256,
|
||||
Backend = record.Backend,
|
||||
LatencyMs = record.LatencyMs,
|
||||
Caller = new CallerDocument
|
||||
{
|
||||
Subject = record.Caller.Subject,
|
||||
Audience = record.Caller.Audience,
|
||||
ClientId = record.Caller.ClientId,
|
||||
MtlsThumbprint = record.Caller.MtlsThumbprint,
|
||||
Tenant = record.Caller.Tenant
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class CallerDocument
|
||||
{
|
||||
[BsonElement("subject")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[BsonElement("audience")]
|
||||
public string? Audience { get; set; }
|
||||
|
||||
[BsonElement("clientId")]
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
[BsonElement("mtlsThumbprint")]
|
||||
public string? MtlsThumbprint { get; set; }
|
||||
|
||||
[BsonElement("tenant")]
|
||||
public string? Tenant { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
{
|
||||
private readonly IMongoCollection<AttestorEntryDocument> _entries;
|
||||
|
||||
public MongoAttestorEntryRepository(IMongoCollection<AttestorEntryDocument> entries)
|
||||
{
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public async Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.BundleSha256, bundleSha256);
|
||||
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToDomain();
|
||||
}
|
||||
|
||||
public async Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, rekorUuid);
|
||||
var document = await _entries.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document?.ToDomain();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Artifact.Sha256, artifactSha256);
|
||||
var documents = await _entries.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return documents.ConvertAll(static doc => doc.ToDomain());
|
||||
}
|
||||
|
||||
public async Task SaveAsync(AttestorEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = AttestorEntryDocument.FromDomain(entry);
|
||||
var filter = Builders<AttestorEntryDocument>.Filter.Eq(x => x.Id, document.Id);
|
||||
await _entries.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
internal sealed class AttestorEntryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("artifact")]
|
||||
public ArtifactDocument Artifact { get; set; } = new();
|
||||
|
||||
[BsonElement("bundleSha256")]
|
||||
public string BundleSha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("proof")]
|
||||
public ProofDocument? Proof { get; set; }
|
||||
|
||||
[BsonElement("log")]
|
||||
public LogDocument Log { get; set; } = new();
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public BsonDateTime CreatedAt { get; set; } = BsonDateTime.Create(System.DateTimeOffset.UtcNow);
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[BsonElement("signerIdentity")]
|
||||
public SignerIdentityDocument SignerIdentity { get; set; } = new();
|
||||
|
||||
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntryDocument
|
||||
{
|
||||
Id = entry.RekorUuid,
|
||||
Artifact = new ArtifactDocument
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind,
|
||||
ImageDigest = entry.Artifact.ImageDigest,
|
||||
SubjectUri = entry.Artifact.SubjectUri
|
||||
},
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = entry.Proof is null ? null : new ProofDocument
|
||||
{
|
||||
Checkpoint = entry.Proof.Checkpoint is null ? null : new CheckpointDocument
|
||||
{
|
||||
Origin = entry.Proof.Checkpoint.Origin,
|
||||
Size = entry.Proof.Checkpoint.Size,
|
||||
RootHash = entry.Proof.Checkpoint.RootHash,
|
||||
Timestamp = entry.Proof.Checkpoint.Timestamp is null
|
||||
? null
|
||||
: BsonDateTime.Create(entry.Proof.Checkpoint.Timestamp.Value)
|
||||
},
|
||||
Inclusion = entry.Proof.Inclusion is null ? null : new InclusionDocument
|
||||
{
|
||||
LeafHash = entry.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Log = new LogDocument
|
||||
{
|
||||
Url = entry.Log.Url,
|
||||
LogId = entry.Log.LogId
|
||||
},
|
||||
CreatedAt = BsonDateTime.Create(entry.CreatedAt.UtcDateTime),
|
||||
Status = entry.Status,
|
||||
SignerIdentity = new SignerIdentityDocument
|
||||
{
|
||||
Mode = entry.SignerIdentity.Mode,
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public AttestorEntry ToDomain()
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = Id,
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = Artifact.Sha256,
|
||||
Kind = Artifact.Kind,
|
||||
ImageDigest = Artifact.ImageDigest,
|
||||
SubjectUri = Artifact.SubjectUri
|
||||
},
|
||||
BundleSha256 = BundleSha256,
|
||||
Index = Index,
|
||||
Proof = Proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = Proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = Proof.Checkpoint.Origin,
|
||||
Size = Proof.Checkpoint.Size,
|
||||
RootHash = Proof.Checkpoint.RootHash,
|
||||
Timestamp = Proof.Checkpoint.Timestamp?.ToUniversalTime()
|
||||
},
|
||||
Inclusion = Proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = Proof.Inclusion.LeafHash,
|
||||
Path = Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Url = Log.Url,
|
||||
LogId = Log.LogId
|
||||
},
|
||||
CreatedAt = CreatedAt.ToUniversalTime(),
|
||||
Status = Status,
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = SignerIdentity.Mode,
|
||||
Issuer = SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = SignerIdentity.KeyId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class ArtifactDocument
|
||||
{
|
||||
[BsonElement("sha256")]
|
||||
public string Sha256 { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("kind")]
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("imageDigest")]
|
||||
public string? ImageDigest { get; set; }
|
||||
|
||||
[BsonElement("subjectUri")]
|
||||
public string? SubjectUri { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ProofDocument
|
||||
{
|
||||
[BsonElement("checkpoint")]
|
||||
public CheckpointDocument? Checkpoint { get; set; }
|
||||
|
||||
[BsonElement("inclusion")]
|
||||
public InclusionDocument? Inclusion { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class CheckpointDocument
|
||||
{
|
||||
[BsonElement("origin")]
|
||||
public string? Origin { get; set; }
|
||||
|
||||
[BsonElement("size")]
|
||||
public long Size { get; set; }
|
||||
|
||||
[BsonElement("rootHash")]
|
||||
public string? RootHash { get; set; }
|
||||
|
||||
[BsonElement("timestamp")]
|
||||
public BsonDateTime? Timestamp { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InclusionDocument
|
||||
{
|
||||
[BsonElement("leafHash")]
|
||||
public string? LeafHash { get; set; }
|
||||
|
||||
[BsonElement("path")]
|
||||
public IReadOnlyList<string> Path { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed class LogDocument
|
||||
{
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("logId")]
|
||||
public string? LogId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SignerIdentityDocument
|
||||
{
|
||||
[BsonElement("mode")]
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("issuer")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[BsonElement("san")]
|
||||
public string? SubjectAlternativeName { get; set; }
|
||||
|
||||
[BsonElement("kid")]
|
||||
public string? KeyId { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
|
||||
{
|
||||
private readonly ILogger<NullAttestorArchiveStore> _logger;
|
||||
|
||||
public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
|
||||
{
|
||||
private readonly IDatabase _database;
|
||||
private readonly string _prefix;
|
||||
|
||||
public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
|
||||
{
|
||||
_database = multiplexer.GetDatabase();
|
||||
_prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
|
||||
}
|
||||
|
||||
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
|
||||
return value.HasValue ? value.ToString() : null;
|
||||
}
|
||||
|
||||
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
|
||||
}
|
||||
|
||||
private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Storage;
|
||||
|
||||
internal sealed class S3AttestorArchiveStore : IAttestorArchiveStore, IDisposable
|
||||
{
|
||||
private readonly IAmazonS3 _s3;
|
||||
private readonly AttestorOptions.S3Options _options;
|
||||
private readonly ILogger<S3AttestorArchiveStore> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public S3AttestorArchiveStore(IAmazonS3 s3, IOptions<AttestorOptions> options, ILogger<S3AttestorArchiveStore> logger)
|
||||
{
|
||||
_s3 = s3;
|
||||
_options = options.Value.S3;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.Bucket))
|
||||
{
|
||||
_logger.LogWarning("S3 archive bucket is not configured; skipping archive for bundle {Bundle}", bundle.BundleSha256);
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = _options.Prefix ?? "attest/";
|
||||
|
||||
await PutObjectAsync(prefix + "dsse/" + bundle.BundleSha256 + ".json", bundle.CanonicalBundleJson, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle.ProofJson.Length > 0)
|
||||
{
|
||||
await PutObjectAsync(prefix + "proof/" + bundle.RekorUuid + ".json", bundle.ProofJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var metadataObject = JsonSerializer.SerializeToUtf8Bytes(bundle.Metadata);
|
||||
await PutObjectAsync(prefix + "meta/" + bundle.RekorUuid + ".json", metadataObject, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task PutObjectAsync(string key, byte[] content, CancellationToken cancellationToken)
|
||||
{
|
||||
using var stream = new MemoryStream(content);
|
||||
var request = new PutObjectRequest
|
||||
{
|
||||
BucketName = _options.Bucket,
|
||||
Key = key,
|
||||
InputStream = stream,
|
||||
AutoCloseStream = false
|
||||
};
|
||||
return _s3.PutObjectAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_s3.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Audit;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Submission;
|
||||
|
||||
internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
{
|
||||
private static readonly TimeSpan DedupeTtl = TimeSpan.FromHours(48);
|
||||
|
||||
private readonly AttestorSubmissionValidator _validator;
|
||||
private readonly IAttestorEntryRepository _repository;
|
||||
private readonly IAttestorDedupeStore _dedupeStore;
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly IAttestorArchiveStore _archiveStore;
|
||||
private readonly IAttestorAuditSink _auditSink;
|
||||
private readonly ILogger<AttestorSubmissionService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorSubmissionService(
|
||||
AttestorSubmissionValidator validator,
|
||||
IAttestorEntryRepository repository,
|
||||
IAttestorDedupeStore dedupeStore,
|
||||
IRekorClient rekorClient,
|
||||
IAttestorArchiveStore archiveStore,
|
||||
IAttestorAuditSink auditSink,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorSubmissionService> logger,
|
||||
TimeProvider timeProvider,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_validator = validator;
|
||||
_repository = repository;
|
||||
_dedupeStore = dedupeStore;
|
||||
_rekorClient = rekorClient;
|
||||
_archiveStore = archiveStore;
|
||||
_auditSink = auditSink;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorSubmissionResult> SubmitAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
|
||||
var validation = await _validator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var canonicalBundle = validation.CanonicalBundle;
|
||||
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(dedupeUuid))
|
||||
{
|
||||
_logger.LogInformation("Dedupe hit for bundle {BundleSha256} -> {RekorUuid}", request.Meta.BundleSha256, dedupeUuid);
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "hit"));
|
||||
var existing = await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
return ToResult(existing);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
}
|
||||
|
||||
var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
RekorSubmissionResponse submissionResponse;
|
||||
try
|
||||
{
|
||||
submissionResponse = await _rekorClient.SubmitAsync(request, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, primaryBackend.Name);
|
||||
throw;
|
||||
}
|
||||
|
||||
var proof = submissionResponse.Proof;
|
||||
if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submissionResponse.Uuid, primaryBackend, cancellationToken).ConfigureAwait(false);
|
||||
_metrics.ProofFetchTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", proof is null ? "missing" : "ok"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "proof_fetch"));
|
||||
_logger.LogWarning(ex, "Proof fetch failed for {Uuid} on backend {Backend}", submissionResponse.Uuid, primaryBackend.Name);
|
||||
}
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (request.Meta.Archive)
|
||||
{
|
||||
var archiveBundle = new AttestorArchiveBundle
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
ArtifactSha256 = entry.Artifact.Sha256,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
CanonicalBundleJson = canonicalBundle,
|
||||
ProofJson = proof is null ? Array.Empty<byte>() : JsonSerializer.SerializeToUtf8Bytes(proof, JsonSerializerOptions.Default),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _archiveStore.ArchiveBundleAsync(archiveBundle, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to archive bundle {BundleSha}", entry.BundleSha256);
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "archive"));
|
||||
}
|
||||
}
|
||||
|
||||
var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start, System.Diagnostics.Stopwatch.GetTimestamp());
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", submissionResponse.Status ?? "unknown"),
|
||||
new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
_metrics.SubmitLatency.Record(elapsed.TotalSeconds, new KeyValuePair<string, object?>("backend", primaryBackend.Name));
|
||||
await WriteAuditAsync(request, context, entry, submissionResponse, (long)elapsed.TotalMilliseconds, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ToResult(entry);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorSubmissionResult
|
||||
{
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof is null ? null : new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = entry.Proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
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 AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = entry.Proof.Inclusion.LeafHash,
|
||||
Path = entry.Proof.Inclusion.Path
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private AttestorEntry CreateEntry(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorSubmissionResponse submission,
|
||||
RekorProofResponse? proof,
|
||||
SubmissionContext context,
|
||||
byte[] canonicalBundle)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = submission.Uuid,
|
||||
Artifact = new AttestorEntry.ArtifactDescriptor
|
||||
{
|
||||
Sha256 = request.Meta.Artifact.Sha256,
|
||||
Kind = request.Meta.Artifact.Kind,
|
||||
ImageDigest = request.Meta.Artifact.ImageDigest,
|
||||
SubjectUri = request.Meta.Artifact.SubjectUri
|
||||
},
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Index = submission.Index,
|
||||
Proof = proof is null ? null : new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Url = submission.LogUrl ?? string.Empty,
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = now,
|
||||
Status = submission.Status ?? "included",
|
||||
SignerIdentity = new AttestorEntry.SignerIdentityDescriptor
|
||||
{
|
||||
Mode = request.Bundle.Mode,
|
||||
Issuer = context.CallerAudience,
|
||||
SubjectAlternativeName = context.CallerSubject,
|
||||
KeyId = context.CallerClientId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Task WriteAuditAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
AttestorEntry entry,
|
||||
RekorSubmissionResponse submission,
|
||||
long latencyMs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var record = new AttestorAuditRecord
|
||||
{
|
||||
Action = "submit",
|
||||
Result = submission.Status ?? "included",
|
||||
RekorUuid = submission.Uuid,
|
||||
Index = submission.Index,
|
||||
ArtifactSha256 = request.Meta.Artifact.Sha256,
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Backend = "primary",
|
||||
LatencyMs = latencyMs,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Caller = new AttestorAuditRecord.CallerDescriptor
|
||||
{
|
||||
Subject = context.CallerSubject,
|
||||
Audience = context.CallerAudience,
|
||||
ClientId = context.CallerClientId,
|
||||
MtlsThumbprint = context.MtlsThumbprint,
|
||||
Tenant = context.CallerTenant
|
||||
}
|
||||
};
|
||||
|
||||
return _auditSink.WriteAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Submission;
|
||||
|
||||
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var node = new JsonObject
|
||||
{
|
||||
["payloadType"] = request.Bundle.Dsse.PayloadType,
|
||||
["payload"] = request.Bundle.Dsse.PayloadBase64,
|
||||
["signatures"] = CreateSignaturesArray(request)
|
||||
};
|
||||
|
||||
var json = node.ToJsonString(SerializerOptions);
|
||||
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
|
||||
}
|
||||
|
||||
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var signature in request.Bundle.Dsse.Signatures)
|
||||
{
|
||||
var obj = new JsonObject
|
||||
{
|
||||
["sig"] = signature.Signature
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(signature.KeyId))
|
||||
{
|
||||
obj["keyid"] = signature.KeyId;
|
||||
}
|
||||
|
||||
array.Add(obj);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
{
|
||||
private readonly IAttestorEntryRepository _repository;
|
||||
private readonly IDsseCanonicalizer _canonicalizer;
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly ILogger<AttestorVerificationService> _logger;
|
||||
private readonly AttestorOptions _options;
|
||||
|
||||
public AttestorVerificationService(
|
||||
IAttestorEntryRepository repository,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_canonicalizer = canonicalizer;
|
||||
_rekorClient = rekorClient;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var entry = await ResolveEntryAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
throw new AttestorVerificationException("not_found", "No attestor entry matched the supplied query.");
|
||||
}
|
||||
|
||||
var issues = new List<string>();
|
||||
|
||||
if (request.Bundle is not null)
|
||||
{
|
||||
var canonicalBundle = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = request.Bundle,
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = entry.Artifact.Sha256,
|
||||
Kind = entry.Artifact.Kind
|
||||
},
|
||||
BundleSha256 = entry.BundleSha256
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("Bundle hash does not match stored canonical hash.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.RefreshProof || entry.Proof is null)
|
||||
{
|
||||
var backend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
try
|
||||
{
|
||||
var proof = await _rekorClient.GetProofAsync(entry.RekorUuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
if (proof is not null)
|
||||
{
|
||||
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
|
||||
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
entry = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", entry.RekorUuid);
|
||||
issues.Add("Proof refresh failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new AttestorVerificationResult
|
||||
{
|
||||
Ok = ok,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
LogUrl = entry.Log.Url,
|
||||
Status = entry.Status,
|
||||
Issues = issues,
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rekorUuid))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or whitespace.", nameof(rekorUuid));
|
||||
}
|
||||
|
||||
return ResolveEntryByUuidAsync(rekorUuid, refreshProof, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryAsync(AttestorVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.Uuid))
|
||||
{
|
||||
return await ResolveEntryByUuidAsync(request.Uuid, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (request.Bundle is not null)
|
||||
{
|
||||
var canonical = await _canonicalizer.CanonicalizeAsync(new AttestorSubmissionRequest
|
||||
{
|
||||
Bundle = request.Bundle,
|
||||
Meta = new AttestorSubmissionRequest.SubmissionMeta
|
||||
{
|
||||
Artifact = new AttestorSubmissionRequest.ArtifactInfo
|
||||
{
|
||||
Sha256 = string.Empty,
|
||||
Kind = string.Empty
|
||||
}
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundleSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonical)).ToLowerInvariant();
|
||||
return await ResolveEntryByBundleShaAsync(bundleSha, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactSha256))
|
||||
{
|
||||
return await ResolveEntryByArtifactAsync(request.ArtifactSha256, request.RefreshProof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new AttestorVerificationException("invalid_query", "At least one of uuid, bundle, or artifactSha256 must be provided.");
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryByUuidAsync(string uuid, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await _repository.GetByUuidAsync(uuid, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null || !refreshProof)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var backend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
try
|
||||
{
|
||||
var proof = await _rekorClient.GetProofAsync(uuid, backend, cancellationToken).ConfigureAwait(false);
|
||||
if (proof is not null)
|
||||
{
|
||||
var updated = CloneWithProof(entry, proof.ToProofDescriptor());
|
||||
await _repository.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
entry = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh proof for entry {Uuid}", uuid);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> ResolveEntryByBundleShaAsync(string bundleSha, bool refreshProof, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = await _repository.GetByBundleShaAsync(bundleSha, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null || !refreshProof)
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
return await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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();
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return refreshProof
|
||||
? await ResolveEntryByUuidAsync(entry.RekorUuid, true, cancellationToken).ConfigureAwait(false)
|
||||
: entry;
|
||||
}
|
||||
|
||||
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class RekorProofResponseExtensions
|
||||
{
|
||||
public static AttestorEntry.ProofDescriptor ToProofDescriptor(this RekorProofResponse response)
|
||||
{
|
||||
return new AttestorEntry.ProofDescriptor
|
||||
{
|
||||
Checkpoint = response.Checkpoint is null ? null : new AttestorEntry.CheckpointDescriptor
|
||||
{
|
||||
Origin = response.Checkpoint.Origin,
|
||||
Size = response.Checkpoint.Size,
|
||||
RootHash = response.Checkpoint.RootHash,
|
||||
Timestamp = response.Checkpoint.Timestamp
|
||||
},
|
||||
Inclusion = response.Inclusion is null ? null : new AttestorEntry.InclusionDescriptor
|
||||
{
|
||||
LeafHash = response.Inclusion.LeafHash,
|
||||
Path = response.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user