Add channel test providers for Email, Slack, Teams, and Webhook
- Implemented EmailChannelTestProvider to generate email preview payloads. - Implemented SlackChannelTestProvider to create Slack message previews. - Implemented TeamsChannelTestProvider for generating Teams Adaptive Card previews. - Implemented WebhookChannelTestProvider to create webhook payloads. - Added INotifyChannelTestProvider interface for channel-specific preview generation. - Created ChannelTestPreviewContracts for request and response models. - Developed NotifyChannelTestService to handle test send requests and generate previews. - Added rate limit policies for test sends and delivery history. - Implemented unit tests for service registration and binding. - Updated project files to include necessary dependencies and configurations.
This commit is contained in:
@@ -24,7 +24,12 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddAttestorInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDsseCanonicalizer, DefaultDsseCanonicalizer>();
|
||||
services.AddSingleton<AttestorSubmissionValidator>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var canonicalizer = sp.GetRequiredService<IDsseCanonicalizer>();
|
||||
var options = sp.GetRequiredService<IOptions<AttestorOptions>>().Value;
|
||||
return new AttestorSubmissionValidator(canonicalizer, options.Security.SignerIdentity.Mode);
|
||||
});
|
||||
services.AddSingleton<AttestorMetrics>();
|
||||
services.AddSingleton<IAttestorSubmissionService, AttestorSubmissionService>();
|
||||
services.AddSingleton<IAttestorVerificationService, AttestorVerificationService>();
|
||||
|
||||
@@ -76,6 +76,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("signerIdentity")]
|
||||
public SignerIdentityDocument SignerIdentity { get; set; } = new();
|
||||
|
||||
[BsonElement("mirror")]
|
||||
public MirrorDocument? Mirror { get; set; }
|
||||
|
||||
public static AttestorEntryDocument FromDomain(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntryDocument
|
||||
@@ -109,6 +112,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new LogDocument
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
LogId = entry.Log.LogId
|
||||
},
|
||||
@@ -120,7 +124,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = entry.SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = entry.SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = entry.SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = entry.Mirror is null ? null : MirrorDocument.FromDomain(entry.Mirror)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,6 +160,7 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
},
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = Log.Backend,
|
||||
Url = Log.Url,
|
||||
LogId = Log.LogId
|
||||
},
|
||||
@@ -166,7 +172,8 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
Issuer = SignerIdentity.Issuer,
|
||||
SubjectAlternativeName = SignerIdentity.SubjectAlternativeName,
|
||||
KeyId = SignerIdentity.KeyId
|
||||
}
|
||||
},
|
||||
Mirror = Mirror?.ToDomain()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,6 +227,9 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
|
||||
internal sealed class LogDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = "primary";
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
@@ -241,5 +251,92 @@ internal sealed class MongoAttestorEntryRepository : IAttestorEntryRepository
|
||||
[BsonElement("kid")]
|
||||
public string? KeyId { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class MirrorDocument
|
||||
{
|
||||
[BsonElement("backend")]
|
||||
public string Backend { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("uuid")]
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
[BsonElement("index")]
|
||||
public long? Index { get; set; }
|
||||
|
||||
[BsonElement("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
[BsonElement("proof")]
|
||||
public ProofDocument? Proof { get; set; }
|
||||
|
||||
[BsonElement("logId")]
|
||||
public string? LogId { get; set; }
|
||||
|
||||
[BsonElement("error")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
public static MirrorDocument FromDomain(AttestorEntry.LogReplicaDescriptor mirror)
|
||||
{
|
||||
return new MirrorDocument
|
||||
{
|
||||
Backend = mirror.Backend,
|
||||
Url = mirror.Url,
|
||||
Uuid = mirror.Uuid,
|
||||
Index = mirror.Index,
|
||||
Status = mirror.Status,
|
||||
Proof = mirror.Proof is null ? null : new ProofDocument
|
||||
{
|
||||
Checkpoint = mirror.Proof.Checkpoint is null ? null : new CheckpointDocument
|
||||
{
|
||||
Origin = mirror.Proof.Checkpoint.Origin,
|
||||
Size = mirror.Proof.Checkpoint.Size,
|
||||
RootHash = mirror.Proof.Checkpoint.RootHash,
|
||||
Timestamp = mirror.Proof.Checkpoint.Timestamp is null
|
||||
? null
|
||||
: BsonDateTime.Create(mirror.Proof.Checkpoint.Timestamp.Value)
|
||||
},
|
||||
Inclusion = mirror.Proof.Inclusion is null ? null : new InclusionDocument
|
||||
{
|
||||
LeafHash = mirror.Proof.Inclusion.LeafHash,
|
||||
Path = mirror.Proof.Inclusion.Path
|
||||
}
|
||||
},
|
||||
LogId = mirror.LogId,
|
||||
Error = mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
public AttestorEntry.LogReplicaDescriptor ToDomain()
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = Backend,
|
||||
Url = Url,
|
||||
Uuid = Uuid,
|
||||
Index = Index,
|
||||
Status = Status,
|
||||
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
|
||||
}
|
||||
},
|
||||
LogId = LogId,
|
||||
Error = Error
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -58,137 +59,136 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
SubmissionContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var start = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
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))
|
||||
var preference = NormalizeLogPreference(request.Meta.LogPreference);
|
||||
var requiresPrimary = preference is "primary" or "both";
|
||||
var requiresMirror = preference is "mirror" or "both";
|
||||
|
||||
if (!requiresPrimary && !requiresMirror)
|
||||
{
|
||||
requiresPrimary = true;
|
||||
}
|
||||
|
||||
if (requiresMirror && !_options.Rekor.Mirror.Enabled)
|
||||
{
|
||||
throw new AttestorValidationException("mirror_disabled", "Mirror log requested but not configured.");
|
||||
}
|
||||
|
||||
var existing = await TryGetExistingEntryAsync(request.Meta.BundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
_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 updated = await EnsureBackendsAsync(existing, request, context, requiresPrimary, requiresMirror, cancellationToken).ConfigureAwait(false);
|
||||
return ToResult(updated);
|
||||
}
|
||||
|
||||
var primaryBackend = BuildBackend("primary", _options.Rekor.Primary);
|
||||
RekorSubmissionResponse submissionResponse;
|
||||
try
|
||||
_metrics.DedupeHitsTotal.Add(1, new KeyValuePair<string, object?>("result", "miss"));
|
||||
|
||||
SubmissionOutcome? canonicalOutcome = null;
|
||||
SubmissionOutcome? mirrorOutcome = null;
|
||||
|
||||
if (requiresPrimary)
|
||||
{
|
||||
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;
|
||||
canonicalOutcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var proof = submissionResponse.Proof;
|
||||
if (proof is null && string.Equals(submissionResponse.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
if (requiresMirror)
|
||||
{
|
||||
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"));
|
||||
var mirror = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
canonicalOutcome = mirror;
|
||||
}
|
||||
else
|
||||
{
|
||||
mirrorOutcome = mirror;
|
||||
}
|
||||
}
|
||||
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);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
mirrorOutcome = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(mirrorOutcome);
|
||||
}
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, submissionResponse, proof, context, canonicalBundle);
|
||||
if (canonicalOutcome is null)
|
||||
{
|
||||
throw new InvalidOperationException("No Rekor submission outcome was produced.");
|
||||
}
|
||||
|
||||
var entry = CreateEntry(request, context, canonicalOutcome, mirrorOutcome);
|
||||
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"));
|
||||
}
|
||||
await ArchiveAsync(entry, canonicalBundle, canonicalOutcome.Proof, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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);
|
||||
await WriteAuditAsync(request, context, entry, canonicalOutcome, cancellationToken).ConfigureAwait(false);
|
||||
if (mirrorOutcome is not null)
|
||||
{
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return ToResult(entry);
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult ToResult(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorSubmissionResult
|
||||
var result = 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
|
||||
}
|
||||
}
|
||||
Proof = ToResultProof(entry.Proof)
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
result.Mirror = new AttestorSubmissionResult.MirrorLog
|
||||
{
|
||||
Uuid = entry.Mirror.Uuid,
|
||||
Index = entry.Mirror.Index,
|
||||
LogUrl = entry.Mirror.Url,
|
||||
Status = entry.Mirror.Status,
|
||||
Proof = ToResultProof(entry.Mirror.Proof),
|
||||
Error = entry.Mirror.Error
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private AttestorEntry CreateEntry(
|
||||
AttestorSubmissionRequest request,
|
||||
RekorSubmissionResponse submission,
|
||||
RekorProofResponse? proof,
|
||||
SubmissionContext context,
|
||||
byte[] canonicalBundle)
|
||||
SubmissionOutcome canonicalOutcome,
|
||||
SubmissionOutcome? mirrorOutcome)
|
||||
{
|
||||
if (canonicalOutcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Canonical submission outcome must include a Rekor response.");
|
||||
}
|
||||
|
||||
var submission = canonicalOutcome.Submission;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = submission.Uuid,
|
||||
@@ -201,24 +201,11 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
Proof = ConvertProof(canonicalOutcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Url = submission.LogUrl ?? string.Empty,
|
||||
Backend = canonicalOutcome.Backend,
|
||||
Url = submission.LogUrl ?? canonicalOutcome.Url,
|
||||
LogId = null
|
||||
},
|
||||
CreatedAt = now,
|
||||
@@ -229,28 +216,233 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
Issuer = context.CallerAudience,
|
||||
SubjectAlternativeName = context.CallerSubject,
|
||||
KeyId = context.CallerClientId
|
||||
}
|
||||
},
|
||||
Mirror = mirrorOutcome is null ? null : CreateMirrorDescriptor(mirrorOutcome)
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeLogPreference(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "primary";
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"primary" => "primary",
|
||||
"mirror" => "mirror",
|
||||
"both" => "both",
|
||||
_ => "primary"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry?> TryGetExistingEntryAsync(string bundleSha256, CancellationToken cancellationToken)
|
||||
{
|
||||
var dedupeUuid = await _dedupeStore.TryGetExistingAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(dedupeUuid))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _repository.GetByUuidAsync(dedupeUuid, cancellationToken).ConfigureAwait(false)
|
||||
?? await _repository.GetByBundleShaAsync(bundleSha256, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<AttestorEntry> EnsureBackendsAsync(
|
||||
AttestorEntry existing,
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
bool requiresPrimary,
|
||||
bool requiresMirror,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = existing;
|
||||
var updated = false;
|
||||
|
||||
if (requiresPrimary && !IsPrimary(entry))
|
||||
{
|
||||
var outcome = await SubmitToBackendAsync(request, "primary", _options.Rekor.Primary, cancellationToken).ConfigureAwait(false);
|
||||
entry = PromoteToPrimary(entry, outcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await _dedupeStore.SetAsync(request.Meta.BundleSha256, entry.RekorUuid, DedupeTtl, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, outcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (requiresMirror)
|
||||
{
|
||||
var mirrorSatisfied = entry.Mirror is not null
|
||||
&& entry.Mirror.Error is null
|
||||
&& string.Equals(entry.Mirror.Status, "included", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrEmpty(entry.Mirror.Uuid);
|
||||
|
||||
if (!mirrorSatisfied)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mirrorOutcome = await SubmitToBackendAsync(request, "mirror", _options.Rekor.Mirror, cancellationToken).ConfigureAwait(false);
|
||||
entry = WithMirror(entry, mirrorOutcome);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, mirrorOutcome, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", "submit_mirror"));
|
||||
_logger.LogWarning(ex, "Mirror submission failed for deduplicated bundle {BundleSha}", request.Meta.BundleSha256);
|
||||
var failure = SubmissionOutcome.Failure("mirror", _options.Rekor.Mirror.Url, ex, TimeSpan.Zero);
|
||||
RecordSubmissionMetrics(failure);
|
||||
entry = WithMirror(entry, failure);
|
||||
await _repository.SaveAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||
await WriteAuditAsync(request, context, entry, failure, cancellationToken).ConfigureAwait(false);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
{
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", "dedupe"),
|
||||
new KeyValuePair<string, object?>("backend", "cache"));
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static bool IsPrimary(AttestorEntry entry) =>
|
||||
string.Equals(entry.Log.Backend, "primary", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<SubmissionOutcome> SubmitToBackendAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
string backendName,
|
||||
AttestorOptions.RekorBackendOptions backendOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = BuildBackend(backendName, backendOptions);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var submission = await _rekorClient.SubmitAsync(request, backend, cancellationToken).ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var proof = submission.Proof;
|
||||
if (proof is null && string.Equals(submission.Status, "included", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
proof = await _rekorClient.GetProofAsync(submission.Uuid, backend, 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}", submission.Uuid, backendName);
|
||||
}
|
||||
}
|
||||
|
||||
var outcome = SubmissionOutcome.Success(backendName, backend.Url, submission, proof, stopwatch.Elapsed);
|
||||
RecordSubmissionMetrics(outcome);
|
||||
return outcome;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_metrics.ErrorTotal.Add(1, new KeyValuePair<string, object?>("type", $"submit_{backendName}"));
|
||||
_logger.LogError(ex, "Failed to submit bundle {BundleSha} to Rekor backend {Backend}", request.Meta.BundleSha256, backendName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordSubmissionMetrics(SubmissionOutcome outcome)
|
||||
{
|
||||
var result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "unknown"
|
||||
: "failed";
|
||||
|
||||
_metrics.SubmitTotal.Add(1,
|
||||
new KeyValuePair<string, object?>("result", result),
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
|
||||
if (outcome.Latency > TimeSpan.Zero)
|
||||
{
|
||||
_metrics.SubmitLatency.Record(outcome.Latency.TotalSeconds,
|
||||
new KeyValuePair<string, object?>("backend", outcome.Backend));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ArchiveAsync(
|
||||
AttestorEntry entry,
|
||||
byte[] canonicalBundle,
|
||||
RekorProofResponse? proof,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["logUrl"] = entry.Log.Url,
|
||||
["status"] = entry.Status
|
||||
};
|
||||
|
||||
if (entry.Mirror is not null)
|
||||
{
|
||||
metadata["mirror.backend"] = entry.Mirror.Backend;
|
||||
metadata["mirror.uuid"] = entry.Mirror.Uuid ?? string.Empty;
|
||||
metadata["mirror.status"] = entry.Mirror.Status;
|
||||
}
|
||||
|
||||
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 = metadata
|
||||
};
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteAuditAsync(
|
||||
AttestorSubmissionRequest request,
|
||||
SubmissionContext context,
|
||||
AttestorEntry entry,
|
||||
RekorSubmissionResponse submission,
|
||||
long latencyMs,
|
||||
SubmissionOutcome outcome,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (!outcome.IsSuccess && outcome.Error is not null)
|
||||
{
|
||||
metadata["error"] = outcome.Error.Message;
|
||||
}
|
||||
|
||||
var record = new AttestorAuditRecord
|
||||
{
|
||||
Action = "submit",
|
||||
Result = submission.Status ?? "included",
|
||||
RekorUuid = submission.Uuid,
|
||||
Index = submission.Index,
|
||||
Result = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
RekorUuid = outcome.IsSuccess
|
||||
? outcome.Submission!.Uuid
|
||||
: string.Equals(outcome.Backend, "primary", StringComparison.OrdinalIgnoreCase)
|
||||
? entry.RekorUuid
|
||||
: entry.Mirror?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
ArtifactSha256 = request.Meta.Artifact.Sha256,
|
||||
BundleSha256 = request.Meta.BundleSha256,
|
||||
Backend = "primary",
|
||||
LatencyMs = latencyMs,
|
||||
Backend = outcome.Backend,
|
||||
LatencyMs = (long)outcome.Latency.TotalMilliseconds,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
Caller = new AttestorAuditRecord.CallerDescriptor
|
||||
{
|
||||
@@ -259,12 +451,160 @@ internal sealed class AttestorSubmissionService : IAttestorSubmissionService
|
||||
ClientId = context.CallerClientId,
|
||||
MtlsThumbprint = context.MtlsThumbprint,
|
||||
Tenant = context.CallerTenant
|
||||
}
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
return _auditSink.WriteAsync(record, cancellationToken);
|
||||
}
|
||||
|
||||
private static AttestorEntry.ProofDescriptor? ConvertProof(RekorProofResponse? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return 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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorSubmissionResult.RekorProof? ToResultProof(AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
if (proof is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AttestorSubmissionResult.RekorProof
|
||||
{
|
||||
Checkpoint = proof.Checkpoint is null ? null : new AttestorSubmissionResult.Checkpoint
|
||||
{
|
||||
Origin = proof.Checkpoint.Origin,
|
||||
Size = proof.Checkpoint.Size,
|
||||
RootHash = proof.Checkpoint.RootHash,
|
||||
Timestamp = proof.Checkpoint.Timestamp?.ToString("O")
|
||||
},
|
||||
Inclusion = proof.Inclusion is null ? null : new AttestorSubmissionResult.InclusionProof
|
||||
{
|
||||
LeafHash = proof.Inclusion.LeafHash,
|
||||
Path = proof.Inclusion.Path
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptor(SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.IsSuccess
|
||||
? outcome.Submission!.LogUrl ?? outcome.Url
|
||||
: outcome.Url,
|
||||
Uuid = outcome.Submission?.Uuid,
|
||||
Index = outcome.Submission?.Index,
|
||||
Status = outcome.IsSuccess
|
||||
? outcome.Submission!.Status ?? "included"
|
||||
: "failed",
|
||||
Proof = outcome.IsSuccess ? ConvertProof(outcome.Proof) : null,
|
||||
Error = outcome.Error?.Message
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry WithMirror(AttestorEntry entry, SubmissionOutcome outcome)
|
||||
{
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = entry.RekorUuid,
|
||||
Artifact = entry.Artifact,
|
||||
BundleSha256 = entry.BundleSha256,
|
||||
Index = entry.Index,
|
||||
Proof = entry.Proof,
|
||||
Log = entry.Log,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
Status = entry.Status,
|
||||
SignerIdentity = entry.SignerIdentity,
|
||||
Mirror = CreateMirrorDescriptor(outcome)
|
||||
};
|
||||
}
|
||||
|
||||
private AttestorEntry PromoteToPrimary(AttestorEntry existing, SubmissionOutcome outcome)
|
||||
{
|
||||
if (outcome.Submission is null)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot promote to primary without a successful submission.");
|
||||
}
|
||||
|
||||
var mirrorDescriptor = existing.Mirror;
|
||||
if (mirrorDescriptor is null && !string.Equals(existing.Log.Backend, outcome.Backend, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
mirrorDescriptor = CreateMirrorDescriptorFromEntry(existing);
|
||||
}
|
||||
|
||||
return new AttestorEntry
|
||||
{
|
||||
RekorUuid = outcome.Submission.Uuid,
|
||||
Artifact = existing.Artifact,
|
||||
BundleSha256 = existing.BundleSha256,
|
||||
Index = outcome.Submission.Index,
|
||||
Proof = ConvertProof(outcome.Proof),
|
||||
Log = new AttestorEntry.LogDescriptor
|
||||
{
|
||||
Backend = outcome.Backend,
|
||||
Url = outcome.Submission.LogUrl ?? outcome.Url,
|
||||
LogId = existing.Log.LogId
|
||||
},
|
||||
CreatedAt = existing.CreatedAt,
|
||||
Status = outcome.Submission.Status ?? "included",
|
||||
SignerIdentity = existing.SignerIdentity,
|
||||
Mirror = mirrorDescriptor
|
||||
};
|
||||
}
|
||||
|
||||
private static AttestorEntry.LogReplicaDescriptor CreateMirrorDescriptorFromEntry(AttestorEntry entry)
|
||||
{
|
||||
return new AttestorEntry.LogReplicaDescriptor
|
||||
{
|
||||
Backend = entry.Log.Backend,
|
||||
Url = entry.Log.Url,
|
||||
Uuid = entry.RekorUuid,
|
||||
Index = entry.Index,
|
||||
Status = entry.Status,
|
||||
Proof = entry.Proof,
|
||||
LogId = entry.Log.LogId
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record SubmissionOutcome(
|
||||
string Backend,
|
||||
string Url,
|
||||
RekorSubmissionResponse? Submission,
|
||||
RekorProofResponse? Proof,
|
||||
TimeSpan Latency,
|
||||
Exception? Error)
|
||||
{
|
||||
public bool IsSuccess => Submission is not null && Error is null;
|
||||
|
||||
public static SubmissionOutcome Success(string backend, Uri backendUrl, RekorSubmissionResponse submission, RekorProofResponse? proof, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, backendUrl.ToString(), submission, proof, latency, null);
|
||||
|
||||
public static SubmissionOutcome Failure(string backend, string? url, Exception error, TimeSpan latency) =>
|
||||
new SubmissionOutcome(backend, url ?? string.Empty, null, null, latency, error);
|
||||
}
|
||||
|
||||
private static RekorBackend BuildBackend(string name, AttestorOptions.RekorBackendOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Url))
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -10,7 +16,7 @@ using StellaOps.Attestor.Core.Rekor;
|
||||
using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
|
||||
namespace StellaOps.Attestor.Infrastructure.Verification;
|
||||
|
||||
@@ -21,19 +27,22 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly ILogger<AttestorVerificationService> _logger;
|
||||
private readonly AttestorOptions _options;
|
||||
private readonly AttestorMetrics _metrics;
|
||||
|
||||
public AttestorVerificationService(
|
||||
IAttestorEntryRepository repository,
|
||||
IDsseCanonicalizer canonicalizer,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<AttestorOptions> options,
|
||||
ILogger<AttestorVerificationService> logger)
|
||||
ILogger<AttestorVerificationService> logger,
|
||||
AttestorMetrics metrics)
|
||||
{
|
||||
_repository = repository;
|
||||
_canonicalizer = canonicalizer;
|
||||
_rekorClient = rekorClient;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public async Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -67,11 +76,25 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var computedHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
var computedHash = Convert.ToHexString(SHA256.HashData(canonicalBundle)).ToLowerInvariant();
|
||||
if (!string.Equals(computedHash, entry.BundleSha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add("Bundle hash does not match stored canonical hash.");
|
||||
issues.Add("bundle_hash_mismatch");
|
||||
}
|
||||
|
||||
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payloadBytes))
|
||||
{
|
||||
issues.Add("bundle_payload_invalid_base64");
|
||||
}
|
||||
else
|
||||
{
|
||||
var preAuth = ComputePreAuthEncoding(request.Bundle.Dsse.PayloadType, payloadBytes);
|
||||
VerifySignatures(entry, request.Bundle, preAuth, issues);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No DSSE bundle supplied for verification of {Uuid}; signature checks skipped.", entry.RekorUuid);
|
||||
}
|
||||
|
||||
if (request.RefreshProof || entry.Proof is null)
|
||||
@@ -94,8 +117,12 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
}
|
||||
}
|
||||
|
||||
VerifyMerkleProof(entry, issues);
|
||||
|
||||
var ok = issues.Count == 0 && string.Equals(entry.Status, "included", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_metrics.VerifyTotal.Add(1, new KeyValuePair<string, object?>("result", ok ? "ok" : "failed"));
|
||||
|
||||
return new AttestorVerificationResult
|
||||
{
|
||||
Ok = ok,
|
||||
@@ -204,6 +231,472 @@ internal sealed class AttestorVerificationService : IAttestorVerificationService
|
||||
: entry;
|
||||
}
|
||||
|
||||
private void VerifySignatures(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
var mode = (entry.SignerIdentity.Mode ?? bundle.Mode ?? string.Empty).ToLowerInvariant();
|
||||
|
||||
if (mode == "kms")
|
||||
{
|
||||
if (!VerifyKmsSignature(bundle, preAuthEncoding, issues))
|
||||
{
|
||||
issues.Add("signature_invalid_kms");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == "keyless")
|
||||
{
|
||||
VerifyKeylessSignature(entry, bundle, preAuthEncoding, issues);
|
||||
return;
|
||||
}
|
||||
|
||||
issues.Add(string.IsNullOrEmpty(mode)
|
||||
? "signer_mode_unknown"
|
||||
: $"signer_mode_unsupported:{mode}");
|
||||
}
|
||||
|
||||
private bool VerifyKmsSignature(AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (_options.Security.SignerIdentity.KmsKeys.Count == 0)
|
||||
{
|
||||
issues.Add("kms_key_missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
signatures.Add(signatureBytes);
|
||||
}
|
||||
|
||||
foreach (var secret in _options.Security.SignerIdentity.KmsKeys)
|
||||
{
|
||||
if (!TryDecodeSecret(secret, out var secretBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(secretBytes);
|
||||
var computed = hmac.ComputeHash(preAuthEncoding);
|
||||
|
||||
foreach (var signatureBytes in signatures)
|
||||
{
|
||||
if (CryptographicOperations.FixedTimeEquals(computed, signatureBytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void VerifyKeylessSignature(AttestorEntry entry, AttestorSubmissionRequest.SubmissionBundle bundle, byte[] preAuthEncoding, IList<string> issues)
|
||||
{
|
||||
if (bundle.CertificateChain.Count == 0)
|
||||
{
|
||||
issues.Add("certificate_chain_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
var certificates = new List<X509Certificate2>();
|
||||
try
|
||||
{
|
||||
foreach (var pem in bundle.CertificateChain)
|
||||
{
|
||||
certificates.Add(X509Certificate2.CreateFromPem(pem));
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
||||
{
|
||||
issues.Add("certificate_chain_invalid");
|
||||
_logger.LogWarning(ex, "Failed to parse certificate chain for {Uuid}", entry.RekorUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
var leafCertificate = certificates[0];
|
||||
|
||||
if (_options.Security.SignerIdentity.FulcioRoots.Count > 0)
|
||||
{
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag,
|
||||
TrustMode = X509ChainTrustMode.CustomRootTrust
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var rootPath in _options.Security.SignerIdentity.FulcioRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(rootPath))
|
||||
{
|
||||
var rootCertificate = X509CertificateLoader.LoadCertificateFromFile(rootPath);
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCertificate);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load Fulcio root {Root}", rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chain.Build(leafCertificate))
|
||||
{
|
||||
var status = string.Join(";", chain.ChainStatus.Select(s => s.StatusInformation.Trim()))
|
||||
.Trim(';');
|
||||
issues.Add(string.IsNullOrEmpty(status) ? "certificate_chain_untrusted" : $"certificate_chain_untrusted:{status}");
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.Security.SignerIdentity.AllowedSans.Count > 0)
|
||||
{
|
||||
var sans = GetSubjectAlternativeNames(leafCertificate);
|
||||
if (!sans.Any(san => _options.Security.SignerIdentity.AllowedSans.Contains(san, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
issues.Add("certificate_san_untrusted");
|
||||
}
|
||||
}
|
||||
|
||||
var signatureVerified = false;
|
||||
foreach (var signature in bundle.Dsse.Signatures)
|
||||
{
|
||||
if (!TryDecodeBase64(signature.Signature, out var signatureBytes))
|
||||
{
|
||||
issues.Add("signature_invalid_base64");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryVerifyWithCertificate(leafCertificate, preAuthEncoding, signatureBytes))
|
||||
{
|
||||
signatureVerified = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureVerified)
|
||||
{
|
||||
issues.Add("signature_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithCertificate(X509Certificate2 certificate, byte[] preAuthEncoding, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ecdsa = certificate.GetECDsaPublicKey();
|
||||
if (ecdsa is not null)
|
||||
{
|
||||
using (ecdsa)
|
||||
{
|
||||
return ecdsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256);
|
||||
}
|
||||
}
|
||||
|
||||
var rsa = certificate.GetRSAPublicKey();
|
||||
if (rsa is not null)
|
||||
{
|
||||
using (rsa)
|
||||
{
|
||||
return rsa.VerifyData(preAuthEncoding, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
foreach (var extension in certificate.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, "2.5.29.17", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var formatted = extension.Format(true);
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('=');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return parts[1].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ComputePreAuthEncoding(string payloadType, byte[] payload)
|
||||
{
|
||||
var headerBytes = Encoding.UTF8.GetBytes(payloadType ?? string.Empty);
|
||||
var buffer = new byte[6 + 8 + headerBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Encoding.ASCII.GetBytes("DSSEv1", 0, 6, buffer, offset);
|
||||
offset += 6;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)headerBytes.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(headerBytes, 0, buffer, offset, headerBytes.Length);
|
||||
offset += headerBytes.Length;
|
||||
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buffer.AsSpan(offset, 8), (ulong)payload.Length);
|
||||
offset += 8;
|
||||
Buffer.BlockCopy(payload, 0, buffer, offset, payload.Length);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void VerifyMerkleProof(AttestorEntry entry, IList<string> issues)
|
||||
{
|
||||
if (entry.Proof is null)
|
||||
{
|
||||
issues.Add("proof_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.BundleSha256, out var bundleHash))
|
||||
{
|
||||
issues.Add("bundle_hash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion is null)
|
||||
{
|
||||
issues.Add("proof_inclusion_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.Proof.Inclusion.LeafHash is not null)
|
||||
{
|
||||
if (!TryDecodeHash(entry.Proof.Inclusion.LeafHash, out var proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(bundleHash, proofLeaf))
|
||||
{
|
||||
issues.Add("proof_leafhash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
var current = bundleHash;
|
||||
|
||||
if (entry.Proof.Inclusion.Path.Count > 0)
|
||||
{
|
||||
var nodes = new List<ProofPathNode>();
|
||||
foreach (var element in entry.Proof.Inclusion.Path)
|
||||
{
|
||||
if (!ProofPathNode.TryParse(element, out var node))
|
||||
{
|
||||
issues.Add("proof_path_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!node.HasOrientation)
|
||||
{
|
||||
issues.Add("proof_path_orientation_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.Add(node);
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
current = node.Left
|
||||
? HashInternal(node.Hash, current)
|
||||
: HashInternal(current, node.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Proof.Checkpoint is null)
|
||||
{
|
||||
issues.Add("checkpoint_missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(entry.Proof.Checkpoint.RootHash, out var rootHash))
|
||||
{
|
||||
issues.Add("checkpoint_root_decode_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(current, rootHash))
|
||||
{
|
||||
issues.Add("proof_root_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] HashInternal(byte[] left, byte[] right)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buffer = new byte[1 + left.Length + right.Length];
|
||||
buffer[0] = 0x01;
|
||||
Buffer.BlockCopy(left, 0, buffer, 1, left.Length);
|
||||
Buffer.BlockCopy(right, 0, buffer, 1 + left.Length, right.Length);
|
||||
return sha.ComputeHash(buffer);
|
||||
}
|
||||
|
||||
private static bool TryDecodeSecret(string value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value.Trim();
|
||||
|
||||
if (value.StartsWith("base64:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeBase64(value[7..], out bytes);
|
||||
}
|
||||
|
||||
if (value.StartsWith("hex:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryDecodeHex(value[4..], out bytes);
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeHex(value, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHex(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromHexString(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeHash(string? value, out byte[] bytes)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
|
||||
if (TryDecodeHex(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(trimmed, out bytes))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly struct ProofPathNode
|
||||
{
|
||||
private ProofPathNode(bool hasOrientation, bool left, byte[] hash)
|
||||
{
|
||||
HasOrientation = hasOrientation;
|
||||
Left = left;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public bool HasOrientation { get; }
|
||||
|
||||
public bool Left { get; }
|
||||
|
||||
public byte[] Hash { get; }
|
||||
|
||||
public static bool TryParse(string value, out ProofPathNode node)
|
||||
{
|
||||
node = default;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var parts = trimmed.Split(':', 2);
|
||||
bool hasOrientation = false;
|
||||
bool left = false;
|
||||
string hashPart = trimmed;
|
||||
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var prefix = parts[0].Trim().ToLowerInvariant();
|
||||
if (prefix is "l" or "left")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = true;
|
||||
}
|
||||
else if (prefix is "r" or "right")
|
||||
{
|
||||
hasOrientation = true;
|
||||
left = false;
|
||||
}
|
||||
|
||||
hashPart = parts[1].Trim();
|
||||
}
|
||||
|
||||
if (!TryDecodeHash(hashPart, out var hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = new ProofPathNode(hasOrientation, left, hash);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttestorEntry CloneWithProof(AttestorEntry entry, AttestorEntry.ProofDescriptor? proof)
|
||||
{
|
||||
return new AttestorEntry
|
||||
|
||||
Reference in New Issue
Block a user