up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,89 +1,89 @@
using System;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Client;
/// <summary>
/// Lightweight HTTP client for retrieving mirror index and domain artefacts.
/// </summary>
public sealed class MirrorManifestClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private readonly HttpClient _httpClient;
private readonly ILogger<MirrorManifestClient> _logger;
public MirrorManifestClient(HttpClient httpClient, ILogger<MirrorManifestClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<MirrorIndexDocument> GetIndexAsync(string indexPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(indexPath))
{
throw new ArgumentException("Index path must be provided.", nameof(indexPath));
}
using var request = new HttpRequestMessage(HttpMethod.Get, indexPath);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, indexPath, cancellationToken).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = await JsonSerializer.DeserializeAsync<MirrorIndexDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (document is null)
{
throw new InvalidOperationException("Mirror index payload was empty.");
}
return document;
}
public async Task<byte[]> DownloadAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must be provided.", nameof(path));
}
using var request = new HttpRequestMessage(HttpMethod.Get, path);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, path, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureSuccessAsync(HttpResponseMessage response, string path, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
var status = (int)response.StatusCode;
var body = string.Empty;
if (response.Content.Headers.ContentLength is long length && length > 0)
{
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogWarning(
"Mirror request to {Path} failed with {StatusCode}. Body: {Body}",
path,
status,
string.IsNullOrEmpty(body) ? "<empty>" : body);
throw new HttpRequestException($"Mirror request to '{path}' failed with status {(HttpStatusCode)status} ({status}).", null, response.StatusCode);
}
}
using System;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Client;
/// <summary>
/// Lightweight HTTP client for retrieving mirror index and domain artefacts.
/// </summary>
public sealed class MirrorManifestClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
private readonly HttpClient _httpClient;
private readonly ILogger<MirrorManifestClient> _logger;
public MirrorManifestClient(HttpClient httpClient, ILogger<MirrorManifestClient> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<MirrorIndexDocument> GetIndexAsync(string indexPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(indexPath))
{
throw new ArgumentException("Index path must be provided.", nameof(indexPath));
}
using var request = new HttpRequestMessage(HttpMethod.Get, indexPath);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, indexPath, cancellationToken).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = await JsonSerializer.DeserializeAsync<MirrorIndexDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
if (document is null)
{
throw new InvalidOperationException("Mirror index payload was empty.");
}
return document;
}
public async Task<byte[]> DownloadAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must be provided.", nameof(path));
}
using var request = new HttpRequestMessage(HttpMethod.Get, path);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
await EnsureSuccessAsync(response, path, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
private async Task EnsureSuccessAsync(HttpResponseMessage response, string path, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
var status = (int)response.StatusCode;
var body = string.Empty;
if (response.Content.Headers.ContentLength is long length && length > 0)
{
body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
_logger.LogWarning(
"Mirror request to {Path} failed with {StatusCode}. Body: {Body}",
path,
status,
string.IsNullOrEmpty(body) ? "<empty>" : body);
throw new HttpRequestException($"Mirror request to '{path}' failed with status {(HttpStatusCode)status} ({status}).", null, response.StatusCode);
}
}

View File

@@ -1,203 +1,203 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Globalization;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
internal static class MirrorAdvisoryMapper
{
private const string MirrorProvenanceKind = "map";
private static readonly string[] TopLevelFieldMask =
{
ProvenanceFieldMasks.Advisory,
ProvenanceFieldMasks.References,
ProvenanceFieldMasks.Credits,
ProvenanceFieldMasks.CvssMetrics,
ProvenanceFieldMasks.Weaknesses,
};
public static ImmutableArray<Advisory> Map(MirrorBundleDocument bundle)
{
if (bundle?.Advisories is null || bundle.Advisories.Count == 0)
{
return ImmutableArray<Advisory>.Empty;
}
var builder = ImmutableArray.CreateBuilder<Advisory>(bundle.Advisories.Count);
var recordedAt = bundle.GeneratedAt.ToUniversalTime();
var mirrorValue = BuildMirrorValue(bundle, recordedAt);
var topLevelProvenance = new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
MirrorProvenanceKind,
mirrorValue,
recordedAt,
TopLevelFieldMask);
foreach (var advisory in bundle.Advisories)
{
if (advisory is null)
{
continue;
}
var normalized = CanonicalJsonSerializer.Normalize(advisory);
var aliases = EnsureAliasCoverage(normalized);
var provenance = EnsureProvenance(normalized.Provenance, topLevelProvenance);
var packages = EnsurePackageProvenance(normalized.AffectedPackages, mirrorValue, recordedAt);
var updated = new Advisory(
normalized.AdvisoryKey,
normalized.Title,
normalized.Summary,
normalized.Language,
normalized.Published,
normalized.Modified,
normalized.Severity,
normalized.ExploitKnown,
aliases,
normalized.Credits,
normalized.References,
packages,
normalized.CvssMetrics,
provenance,
normalized.Description,
normalized.Cwes,
normalized.CanonicalMetricId);
builder.Add(updated);
}
return builder.ToImmutable();
}
private static IEnumerable<string> EnsureAliasCoverage(Advisory advisory)
{
var aliases = new List<string>(advisory.Aliases.Length + 1);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var alias in advisory.Aliases)
{
if (seen.Add(alias))
{
aliases.Add(alias);
}
}
if (seen.Add(advisory.AdvisoryKey))
{
aliases.Add(advisory.AdvisoryKey);
}
return aliases;
}
private static IEnumerable<AdvisoryProvenance> EnsureProvenance(
ImmutableArray<AdvisoryProvenance> existing,
AdvisoryProvenance mirrorProvenance)
{
if (!existing.IsDefaultOrEmpty
&& existing.Any(provenance =>
string.Equals(provenance.Source, mirrorProvenance.Source, StringComparison.Ordinal)
&& string.Equals(provenance.Kind, mirrorProvenance.Kind, StringComparison.Ordinal)
&& string.Equals(provenance.Value, mirrorProvenance.Value, StringComparison.Ordinal)))
{
return existing;
}
return existing.Add(mirrorProvenance);
}
private static IEnumerable<AffectedPackage> EnsurePackageProvenance(
ImmutableArray<AffectedPackage> packages,
string mirrorValue,
DateTimeOffset recordedAt)
{
if (packages.IsDefaultOrEmpty || packages.Length == 0)
{
return packages;
}
var results = new List<AffectedPackage>(packages.Length);
foreach (var package in packages)
{
var value = $"{mirrorValue};package={package.Identifier}";
if (!package.Provenance.IsDefaultOrEmpty
&& package.Provenance.Any(provenance =>
string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal)
&& string.Equals(provenance.Kind, MirrorProvenanceKind, StringComparison.Ordinal)
&& string.Equals(provenance.Value, value, StringComparison.Ordinal)))
{
results.Add(package);
continue;
}
var masks = BuildPackageFieldMask(package);
var packageProvenance = new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
MirrorProvenanceKind,
value,
recordedAt,
masks);
var provenance = package.Provenance.Add(packageProvenance);
var updated = new AffectedPackage(
package.Type,
package.Identifier,
package.Platform,
package.VersionRanges,
package.Statuses,
provenance,
package.NormalizedVersions);
results.Add(updated);
}
return results;
}
private static string[] BuildPackageFieldMask(AffectedPackage package)
{
var masks = new HashSet<string>(StringComparer.Ordinal)
{
ProvenanceFieldMasks.AffectedPackages,
};
if (!package.VersionRanges.IsDefaultOrEmpty && package.VersionRanges.Length > 0)
{
masks.Add(ProvenanceFieldMasks.VersionRanges);
}
if (!package.Statuses.IsDefaultOrEmpty && package.Statuses.Length > 0)
{
masks.Add(ProvenanceFieldMasks.PackageStatuses);
}
if (!package.NormalizedVersions.IsDefaultOrEmpty && package.NormalizedVersions.Length > 0)
{
masks.Add(ProvenanceFieldMasks.NormalizedVersions);
}
return masks.ToArray();
}
private static string BuildMirrorValue(MirrorBundleDocument bundle, DateTimeOffset recordedAt)
{
var segments = new List<string>
{
$"domain={bundle.DomainId}",
};
if (!string.IsNullOrWhiteSpace(bundle.TargetRepository))
{
segments.Add($"repository={bundle.TargetRepository}");
}
segments.Add($"generated={recordedAt.ToString("O", CultureInfo.InvariantCulture)}");
return string.Join(';', segments);
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Globalization;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
internal static class MirrorAdvisoryMapper
{
private const string MirrorProvenanceKind = "map";
private static readonly string[] TopLevelFieldMask =
{
ProvenanceFieldMasks.Advisory,
ProvenanceFieldMasks.References,
ProvenanceFieldMasks.Credits,
ProvenanceFieldMasks.CvssMetrics,
ProvenanceFieldMasks.Weaknesses,
};
public static ImmutableArray<Advisory> Map(MirrorBundleDocument bundle)
{
if (bundle?.Advisories is null || bundle.Advisories.Count == 0)
{
return ImmutableArray<Advisory>.Empty;
}
var builder = ImmutableArray.CreateBuilder<Advisory>(bundle.Advisories.Count);
var recordedAt = bundle.GeneratedAt.ToUniversalTime();
var mirrorValue = BuildMirrorValue(bundle, recordedAt);
var topLevelProvenance = new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
MirrorProvenanceKind,
mirrorValue,
recordedAt,
TopLevelFieldMask);
foreach (var advisory in bundle.Advisories)
{
if (advisory is null)
{
continue;
}
var normalized = CanonicalJsonSerializer.Normalize(advisory);
var aliases = EnsureAliasCoverage(normalized);
var provenance = EnsureProvenance(normalized.Provenance, topLevelProvenance);
var packages = EnsurePackageProvenance(normalized.AffectedPackages, mirrorValue, recordedAt);
var updated = new Advisory(
normalized.AdvisoryKey,
normalized.Title,
normalized.Summary,
normalized.Language,
normalized.Published,
normalized.Modified,
normalized.Severity,
normalized.ExploitKnown,
aliases,
normalized.Credits,
normalized.References,
packages,
normalized.CvssMetrics,
provenance,
normalized.Description,
normalized.Cwes,
normalized.CanonicalMetricId);
builder.Add(updated);
}
return builder.ToImmutable();
}
private static IEnumerable<string> EnsureAliasCoverage(Advisory advisory)
{
var aliases = new List<string>(advisory.Aliases.Length + 1);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var alias in advisory.Aliases)
{
if (seen.Add(alias))
{
aliases.Add(alias);
}
}
if (seen.Add(advisory.AdvisoryKey))
{
aliases.Add(advisory.AdvisoryKey);
}
return aliases;
}
private static IEnumerable<AdvisoryProvenance> EnsureProvenance(
ImmutableArray<AdvisoryProvenance> existing,
AdvisoryProvenance mirrorProvenance)
{
if (!existing.IsDefaultOrEmpty
&& existing.Any(provenance =>
string.Equals(provenance.Source, mirrorProvenance.Source, StringComparison.Ordinal)
&& string.Equals(provenance.Kind, mirrorProvenance.Kind, StringComparison.Ordinal)
&& string.Equals(provenance.Value, mirrorProvenance.Value, StringComparison.Ordinal)))
{
return existing;
}
return existing.Add(mirrorProvenance);
}
private static IEnumerable<AffectedPackage> EnsurePackageProvenance(
ImmutableArray<AffectedPackage> packages,
string mirrorValue,
DateTimeOffset recordedAt)
{
if (packages.IsDefaultOrEmpty || packages.Length == 0)
{
return packages;
}
var results = new List<AffectedPackage>(packages.Length);
foreach (var package in packages)
{
var value = $"{mirrorValue};package={package.Identifier}";
if (!package.Provenance.IsDefaultOrEmpty
&& package.Provenance.Any(provenance =>
string.Equals(provenance.Source, StellaOpsMirrorConnector.Source, StringComparison.Ordinal)
&& string.Equals(provenance.Kind, MirrorProvenanceKind, StringComparison.Ordinal)
&& string.Equals(provenance.Value, value, StringComparison.Ordinal)))
{
results.Add(package);
continue;
}
var masks = BuildPackageFieldMask(package);
var packageProvenance = new AdvisoryProvenance(
StellaOpsMirrorConnector.Source,
MirrorProvenanceKind,
value,
recordedAt,
masks);
var provenance = package.Provenance.Add(packageProvenance);
var updated = new AffectedPackage(
package.Type,
package.Identifier,
package.Platform,
package.VersionRanges,
package.Statuses,
provenance,
package.NormalizedVersions);
results.Add(updated);
}
return results;
}
private static string[] BuildPackageFieldMask(AffectedPackage package)
{
var masks = new HashSet<string>(StringComparer.Ordinal)
{
ProvenanceFieldMasks.AffectedPackages,
};
if (!package.VersionRanges.IsDefaultOrEmpty && package.VersionRanges.Length > 0)
{
masks.Add(ProvenanceFieldMasks.VersionRanges);
}
if (!package.Statuses.IsDefaultOrEmpty && package.Statuses.Length > 0)
{
masks.Add(ProvenanceFieldMasks.PackageStatuses);
}
if (!package.NormalizedVersions.IsDefaultOrEmpty && package.NormalizedVersions.Length > 0)
{
masks.Add(ProvenanceFieldMasks.NormalizedVersions);
}
return masks.ToArray();
}
private static string BuildMirrorValue(MirrorBundleDocument bundle, DateTimeOffset recordedAt)
{
var segments = new List<string>
{
$"domain={bundle.DomainId}",
};
if (!string.IsNullOrWhiteSpace(bundle.TargetRepository))
{
segments.Add($"repository={bundle.TargetRepository}");
}
segments.Add($"generated={recordedAt.ToString("O", CultureInfo.InvariantCulture)}");
return string.Join(';', segments);
}
}

View File

@@ -1,14 +1,14 @@
using System.Text.Json.Serialization;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
public sealed record MirrorBundleDocument(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string DisplayName,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount,
[property: JsonPropertyName("advisories")] IReadOnlyList<Advisory> Advisories,
[property: JsonPropertyName("sources")] IReadOnlyList<MirrorSourceSummary> Sources);
using System.Text.Json.Serialization;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
public sealed record MirrorBundleDocument(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string DisplayName,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount,
[property: JsonPropertyName("advisories")] IReadOnlyList<Advisory> Advisories,
[property: JsonPropertyName("sources")] IReadOnlyList<MirrorSourceSummary> Sources);

View File

@@ -1,38 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
public sealed record MirrorIndexDocument(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domains")] IReadOnlyList<MirrorIndexDomainEntry> Domains);
public sealed record MirrorIndexDomainEntry(
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string DisplayName,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount,
[property: JsonPropertyName("manifest")] MirrorFileDescriptor Manifest,
[property: JsonPropertyName("bundle")] MirrorFileDescriptor Bundle,
[property: JsonPropertyName("sources")] IReadOnlyList<MirrorSourceSummary> Sources);
public sealed record MirrorFileDescriptor(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("sizeBytes")] long SizeBytes,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("signature")] MirrorSignatureDescriptor? Signature);
public sealed record MirrorSignatureDescriptor(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("keyId")] string KeyId,
[property: JsonPropertyName("provider")] string Provider,
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAt);
public sealed record MirrorSourceSummary(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("firstRecordedAt")] DateTimeOffset? FirstRecordedAt,
[property: JsonPropertyName("lastRecordedAt")] DateTimeOffset? LastRecordedAt,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount);
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
public sealed record MirrorIndexDocument(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] DateTimeOffset GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domains")] IReadOnlyList<MirrorIndexDomainEntry> Domains);
public sealed record MirrorIndexDomainEntry(
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string DisplayName,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount,
[property: JsonPropertyName("manifest")] MirrorFileDescriptor Manifest,
[property: JsonPropertyName("bundle")] MirrorFileDescriptor Bundle,
[property: JsonPropertyName("sources")] IReadOnlyList<MirrorSourceSummary> Sources);
public sealed record MirrorFileDescriptor(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("sizeBytes")] long SizeBytes,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("signature")] MirrorSignatureDescriptor? Signature);
public sealed record MirrorSignatureDescriptor(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("algorithm")] string Algorithm,
[property: JsonPropertyName("keyId")] string KeyId,
[property: JsonPropertyName("provider")] string Provider,
[property: JsonPropertyName("signedAt")] DateTimeOffset SignedAt);
public sealed record MirrorSourceSummary(
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("firstRecordedAt")] DateTimeOffset? FirstRecordedAt,
[property: JsonPropertyName("lastRecordedAt")] DateTimeOffset? LastRecordedAt,
[property: JsonPropertyName("advisoryCount")] int AdvisoryCount);

View File

@@ -1,8 +1,8 @@
using System.Linq;
using StellaOps.Concelier.Bson;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
using System.Linq;
using StellaOps.Concelier.Documents;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Internal;
internal sealed record StellaOpsMirrorCursor(
string? ExportId,
string? BundleDigest,
@@ -21,12 +21,12 @@ internal sealed record StellaOpsMirrorCursor(
PendingMappings: EmptyGuids,
CompletedFingerprint: null);
public BsonDocument ToBsonDocument()
public DocumentObject ToDocumentObject()
{
var document = new BsonDocument
var document = new DocumentObject
{
["pendingDocuments"] = new BsonArray(PendingDocuments.Select(id => id.ToString())),
["pendingMappings"] = new BsonArray(PendingMappings.Select(id => id.ToString())),
["pendingDocuments"] = new DocumentArray(PendingDocuments.Select(id => id.ToString())),
["pendingMappings"] = new DocumentArray(PendingMappings.Select(id => id.ToString())),
};
if (!string.IsNullOrWhiteSpace(ExportId))
@@ -52,22 +52,22 @@ internal sealed record StellaOpsMirrorCursor(
return document;
}
public static StellaOpsMirrorCursor FromBson(BsonDocument? document)
{
if (document is null || document.ElementCount == 0)
{
return Empty;
}
public static StellaOpsMirrorCursor FromBson(DocumentObject? document)
{
if (document is null || document.ElementCount == 0)
{
return Empty;
}
var exportId = document.TryGetValue("exportId", out var exportValue) && exportValue.IsString ? exportValue.AsString : null;
var digest = document.TryGetValue("bundleDigest", out var digestValue) && digestValue.IsString ? digestValue.AsString : null;
DateTimeOffset? generatedAt = null;
if (document.TryGetValue("generatedAt", out var generatedValue))
{
generatedAt = generatedValue.BsonType switch
generatedAt = generatedValue.DocumentType switch
{
BsonType.DateTime => DateTime.SpecifyKind(generatedValue.ToUniversalTime(), DateTimeKind.Utc),
BsonType.String when DateTimeOffset.TryParse(generatedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
DocumentType.DateTime => DateTime.SpecifyKind(generatedValue.ToUniversalTime(), DateTimeKind.Utc),
DocumentType.String when DateTimeOffset.TryParse(generatedValue.AsString, out var parsed) => parsed.ToUniversalTime(),
_ => null,
};
}
@@ -99,27 +99,27 @@ internal sealed record StellaOpsMirrorCursor(
public StellaOpsMirrorCursor WithCompletedFingerprint(string? fingerprint)
=> this with { CompletedFingerprint = string.IsNullOrWhiteSpace(fingerprint) ? null : fingerprint };
private static IReadOnlyCollection<Guid> ReadGuidArray(BsonDocument document, string field)
private static IReadOnlyCollection<Guid> ReadGuidArray(DocumentObject document, string field)
{
if (!document.TryGetValue(field, out var value) || value is not BsonArray array)
if (!document.TryGetValue(field, out var value) || value is not DocumentArray array)
{
return EmptyGuids;
}
var results = new List<Guid>(array.Count);
foreach (var element in array)
{
if (element is null)
{
continue;
}
if (Guid.TryParse(element.ToString(), out var guid))
{
results.Add(guid);
}
}
return results;
}
}
}
var results = new List<Guid>(array.Count);
foreach (var element in array)
{
if (element is null)
{
continue;
}
if (Guid.TryParse(element.ToString(), out var guid))
{
results.Add(guid);
}
}
return results;
}
}

View File

@@ -1,43 +1,43 @@
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
internal static class StellaOpsMirrorJobKinds
{
public const string Fetch = "source:stellaops-mirror:fetch";
public const string Parse = "source:stellaops-mirror:parse";
public const string Map = "source:stellaops-mirror:map";
}
internal sealed class StellaOpsMirrorFetchJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorFetchJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.FetchAsync(context.Services, cancellationToken);
}
internal sealed class StellaOpsMirrorParseJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorParseJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.ParseAsync(context.Services, cancellationToken);
}
internal sealed class StellaOpsMirrorMapJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorMapJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.MapAsync(context.Services, cancellationToken);
}
using StellaOps.Concelier.Core.Jobs;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
internal static class StellaOpsMirrorJobKinds
{
public const string Fetch = "source:stellaops-mirror:fetch";
public const string Parse = "source:stellaops-mirror:parse";
public const string Map = "source:stellaops-mirror:map";
}
internal sealed class StellaOpsMirrorFetchJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorFetchJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.FetchAsync(context.Services, cancellationToken);
}
internal sealed class StellaOpsMirrorParseJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorParseJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.ParseAsync(context.Services, cancellationToken);
}
internal sealed class StellaOpsMirrorMapJob : IJob
{
private readonly StellaOpsMirrorConnector _connector;
public StellaOpsMirrorMapJob(StellaOpsMirrorConnector connector)
=> _connector = connector ?? throw new ArgumentNullException(nameof(connector));
public Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
=> _connector.MapAsync(context.Services, cancellationToken);
}

View File

@@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.StellaOpsMirror.Tests")]
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Concelier.Connector.StellaOpsMirror.Tests")]

View File

@@ -1,273 +1,273 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Security;
/// <summary>
/// Validates detached JWS signatures emitted by mirror bundles.
/// </summary>
public sealed class MirrorSignatureVerifier
{
private const string CachePrefix = "stellaops:mirror:public-key:";
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly ICryptoProviderRegistry _providerRegistry;
private readonly ILogger<MirrorSignatureVerifier> _logger;
private readonly IMemoryCache? _memoryCache;
public MirrorSignatureVerifier(
ICryptoProviderRegistry providerRegistry,
ILogger<MirrorSignatureVerifier> logger,
IMemoryCache? memoryCache = null)
{
_providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_memoryCache = memoryCache;
}
public Task VerifyAsync(ReadOnlyMemory<byte> payload, string signatureValue, CancellationToken cancellationToken)
=> VerifyAsync(payload, signatureValue, expectedKeyId: null, expectedProvider: null, fallbackPublicKeyPath: null, cancellationToken);
public async Task VerifyAsync(
ReadOnlyMemory<byte> payload,
string signatureValue,
string? expectedKeyId,
string? expectedProvider,
string? fallbackPublicKeyPath,
CancellationToken cancellationToken)
{
if (payload.IsEmpty)
{
throw new ArgumentException("Payload must not be empty.", nameof(payload));
}
if (string.IsNullOrWhiteSpace(signatureValue))
{
throw new ArgumentException("Signature value must be provided.", nameof(signatureValue));
}
if (!TryParseDetachedJws(signatureValue, out var encodedHeader, out var encodedSignature))
{
throw new InvalidOperationException("Detached JWS signature is malformed.");
}
var headerJson = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(encodedHeader));
var header = JsonSerializer.Deserialize<MirrorSignatureHeader>(headerJson, HeaderSerializerOptions)
?? throw new InvalidOperationException("Detached JWS header could not be parsed.");
if (!header.Critical.Contains("b64", StringComparer.Ordinal))
{
throw new InvalidOperationException("Detached JWS header is missing required 'b64' critical parameter.");
}
if (header.Base64Payload)
{
throw new InvalidOperationException("Detached JWS header sets b64=true; expected unencoded payload.");
}
if (string.IsNullOrWhiteSpace(header.KeyId))
{
throw new InvalidOperationException("Detached JWS header missing key identifier.");
}
if (string.IsNullOrWhiteSpace(header.Algorithm))
{
throw new InvalidOperationException("Detached JWS header missing algorithm identifier.");
}
if (!string.IsNullOrWhiteSpace(expectedKeyId) &&
!string.Equals(header.KeyId, expectedKeyId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Mirror bundle signature key '{header.KeyId}' did not match expected key '{expectedKeyId}'.");
}
if (!string.IsNullOrWhiteSpace(expectedProvider) &&
!string.Equals(header.Provider, expectedProvider, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Mirror bundle signature provider '{header.Provider ?? "<null>"}' did not match expected provider '{expectedProvider}'.");
}
var signingInput = BuildSigningInput(encodedHeader, payload.Span);
var signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
var keyReference = new CryptoKeyReference(header.KeyId, header.Provider);
CryptoSignerResolution? resolution = null;
bool providerVerified = false;
try
{
resolution = _providerRegistry.ResolveSigner(
CryptoCapability.Verification,
header.Algorithm,
keyReference,
header.Provider);
providerVerified = await resolution.Signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
if (providerVerified)
{
return;
}
_logger.LogWarning(
"Detached JWS verification failed for key {KeyId} via provider {Provider}.",
header.KeyId,
resolution.ProviderName);
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
_logger.LogWarning(ex, "Unable to resolve signer for mirror signature key {KeyId} via provider {Provider}.", header.KeyId, header.Provider ?? "<null>");
}
if (providerVerified)
{
return;
}
if (!string.IsNullOrWhiteSpace(fallbackPublicKeyPath) &&
await TryVerifyWithFallbackAsync(signingInput, signatureBytes, header.Algorithm, fallbackPublicKeyPath!, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug(
"Detached JWS verification succeeded for key {KeyId} using fallback public key at {Path}.",
header.KeyId,
fallbackPublicKeyPath);
return;
}
throw new InvalidOperationException("Detached JWS signature verification failed.");
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
var parts = value.Split("..", StringSplitOptions.None);
if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
return false;
}
encodedHeader = parts[0];
encodedSignature = parts[1];
return true;
}
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
{
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
buffer[headerBytes.Length] = (byte)'.';
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
return buffer;
}
private async Task<bool> TryVerifyWithFallbackAsync(
ReadOnlyMemory<byte> signingInput,
ReadOnlyMemory<byte> signature,
string algorithm,
string fallbackPublicKeyPath,
CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var parameters = await GetFallbackPublicKeyAsync(fallbackPublicKeyPath, cancellationToken).ConfigureAwait(false);
if (parameters is null)
{
return false;
}
using var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(parameters.Value);
var hashAlgorithm = ResolveHashAlgorithm(algorithm);
return ecdsa.VerifyData(signingInput.Span, signature.Span, hashAlgorithm);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or ArgumentException)
{
_logger.LogWarning(ex, "Failed to verify mirror signature using fallback public key at {Path}.", fallbackPublicKeyPath);
return false;
}
}
private Task<ECParameters?> GetFallbackPublicKeyAsync(string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_memoryCache is null)
{
return Task.FromResult(LoadPublicKey(path));
}
if (_memoryCache.TryGetValue<Lazy<ECParameters?>>(CachePrefix + path, out var cached))
{
return Task.FromResult(cached?.Value);
}
if (!File.Exists(path))
{
_logger.LogWarning("Mirror signature fallback public key path {Path} was not found.", path);
return Task.FromResult<ECParameters?>(null);
}
var lazy = new Lazy<ECParameters?>(
() => LoadPublicKey(path),
LazyThreadSafetyMode.ExecutionAndPublication);
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(6),
SlidingExpiration = TimeSpan.FromMinutes(30),
};
_memoryCache.Set(CachePrefix + path, lazy, options);
return Task.FromResult(lazy.Value);
}
private ECParameters? LoadPublicKey(string path)
{
try
{
var pem = File.ReadAllText(path);
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(pem.AsSpan());
return ecdsa.ExportParameters(includePrivateParameters: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or ArgumentException)
{
_logger.LogWarning(ex, "Failed to load mirror fallback public key from {Path}.", path);
return null;
}
}
private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId)
=> algorithmId switch
{
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512,
_ => throw new InvalidOperationException($"Unsupported mirror signature algorithm '{algorithmId}'."),
};
private sealed record MirrorSignatureHeader(
[property: JsonPropertyName("alg")] string Algorithm,
[property: JsonPropertyName("kid")] string KeyId,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("typ")] string? Type,
[property: JsonPropertyName("b64")] bool Base64Payload,
[property: JsonPropertyName("crit")] string[] Critical);
}
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Cryptography;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Security;
/// <summary>
/// Validates detached JWS signatures emitted by mirror bundles.
/// </summary>
public sealed class MirrorSignatureVerifier
{
private const string CachePrefix = "stellaops:mirror:public-key:";
private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly ICryptoProviderRegistry _providerRegistry;
private readonly ILogger<MirrorSignatureVerifier> _logger;
private readonly IMemoryCache? _memoryCache;
public MirrorSignatureVerifier(
ICryptoProviderRegistry providerRegistry,
ILogger<MirrorSignatureVerifier> logger,
IMemoryCache? memoryCache = null)
{
_providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_memoryCache = memoryCache;
}
public Task VerifyAsync(ReadOnlyMemory<byte> payload, string signatureValue, CancellationToken cancellationToken)
=> VerifyAsync(payload, signatureValue, expectedKeyId: null, expectedProvider: null, fallbackPublicKeyPath: null, cancellationToken);
public async Task VerifyAsync(
ReadOnlyMemory<byte> payload,
string signatureValue,
string? expectedKeyId,
string? expectedProvider,
string? fallbackPublicKeyPath,
CancellationToken cancellationToken)
{
if (payload.IsEmpty)
{
throw new ArgumentException("Payload must not be empty.", nameof(payload));
}
if (string.IsNullOrWhiteSpace(signatureValue))
{
throw new ArgumentException("Signature value must be provided.", nameof(signatureValue));
}
if (!TryParseDetachedJws(signatureValue, out var encodedHeader, out var encodedSignature))
{
throw new InvalidOperationException("Detached JWS signature is malformed.");
}
var headerJson = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(encodedHeader));
var header = JsonSerializer.Deserialize<MirrorSignatureHeader>(headerJson, HeaderSerializerOptions)
?? throw new InvalidOperationException("Detached JWS header could not be parsed.");
if (!header.Critical.Contains("b64", StringComparer.Ordinal))
{
throw new InvalidOperationException("Detached JWS header is missing required 'b64' critical parameter.");
}
if (header.Base64Payload)
{
throw new InvalidOperationException("Detached JWS header sets b64=true; expected unencoded payload.");
}
if (string.IsNullOrWhiteSpace(header.KeyId))
{
throw new InvalidOperationException("Detached JWS header missing key identifier.");
}
if (string.IsNullOrWhiteSpace(header.Algorithm))
{
throw new InvalidOperationException("Detached JWS header missing algorithm identifier.");
}
if (!string.IsNullOrWhiteSpace(expectedKeyId) &&
!string.Equals(header.KeyId, expectedKeyId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Mirror bundle signature key '{header.KeyId}' did not match expected key '{expectedKeyId}'.");
}
if (!string.IsNullOrWhiteSpace(expectedProvider) &&
!string.Equals(header.Provider, expectedProvider, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Mirror bundle signature provider '{header.Provider ?? "<null>"}' did not match expected provider '{expectedProvider}'.");
}
var signingInput = BuildSigningInput(encodedHeader, payload.Span);
var signatureBytes = Base64UrlEncoder.DecodeBytes(encodedSignature);
var keyReference = new CryptoKeyReference(header.KeyId, header.Provider);
CryptoSignerResolution? resolution = null;
bool providerVerified = false;
try
{
resolution = _providerRegistry.ResolveSigner(
CryptoCapability.Verification,
header.Algorithm,
keyReference,
header.Provider);
providerVerified = await resolution.Signer.VerifyAsync(signingInput, signatureBytes, cancellationToken).ConfigureAwait(false);
if (providerVerified)
{
return;
}
_logger.LogWarning(
"Detached JWS verification failed for key {KeyId} via provider {Provider}.",
header.KeyId,
resolution.ProviderName);
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
_logger.LogWarning(ex, "Unable to resolve signer for mirror signature key {KeyId} via provider {Provider}.", header.KeyId, header.Provider ?? "<null>");
}
if (providerVerified)
{
return;
}
if (!string.IsNullOrWhiteSpace(fallbackPublicKeyPath) &&
await TryVerifyWithFallbackAsync(signingInput, signatureBytes, header.Algorithm, fallbackPublicKeyPath!, cancellationToken).ConfigureAwait(false))
{
_logger.LogDebug(
"Detached JWS verification succeeded for key {KeyId} using fallback public key at {Path}.",
header.KeyId,
fallbackPublicKeyPath);
return;
}
throw new InvalidOperationException("Detached JWS signature verification failed.");
}
private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature)
{
var parts = value.Split("..", StringSplitOptions.None);
if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1]))
{
encodedHeader = string.Empty;
encodedSignature = string.Empty;
return false;
}
encodedHeader = parts[0];
encodedSignature = parts[1];
return true;
}
private static ReadOnlyMemory<byte> BuildSigningInput(string encodedHeader, ReadOnlySpan<byte> payload)
{
var headerBytes = Encoding.ASCII.GetBytes(encodedHeader);
var buffer = new byte[headerBytes.Length + 1 + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
buffer[headerBytes.Length] = (byte)'.';
payload.CopyTo(buffer.AsSpan(headerBytes.Length + 1));
return buffer;
}
private async Task<bool> TryVerifyWithFallbackAsync(
ReadOnlyMemory<byte> signingInput,
ReadOnlyMemory<byte> signature,
string algorithm,
string fallbackPublicKeyPath,
CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var parameters = await GetFallbackPublicKeyAsync(fallbackPublicKeyPath, cancellationToken).ConfigureAwait(false);
if (parameters is null)
{
return false;
}
using var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(parameters.Value);
var hashAlgorithm = ResolveHashAlgorithm(algorithm);
return ecdsa.VerifyData(signingInput.Span, signature.Span, hashAlgorithm);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or ArgumentException)
{
_logger.LogWarning(ex, "Failed to verify mirror signature using fallback public key at {Path}.", fallbackPublicKeyPath);
return false;
}
}
private Task<ECParameters?> GetFallbackPublicKeyAsync(string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_memoryCache is null)
{
return Task.FromResult(LoadPublicKey(path));
}
if (_memoryCache.TryGetValue<Lazy<ECParameters?>>(CachePrefix + path, out var cached))
{
return Task.FromResult(cached?.Value);
}
if (!File.Exists(path))
{
_logger.LogWarning("Mirror signature fallback public key path {Path} was not found.", path);
return Task.FromResult<ECParameters?>(null);
}
var lazy = new Lazy<ECParameters?>(
() => LoadPublicKey(path),
LazyThreadSafetyMode.ExecutionAndPublication);
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(6),
SlidingExpiration = TimeSpan.FromMinutes(30),
};
_memoryCache.Set(CachePrefix + path, lazy, options);
return Task.FromResult(lazy.Value);
}
private ECParameters? LoadPublicKey(string path)
{
try
{
var pem = File.ReadAllText(path);
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(pem.AsSpan());
return ecdsa.ExportParameters(includePrivateParameters: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or ArgumentException)
{
_logger.LogWarning(ex, "Failed to load mirror fallback public key from {Path}.", path);
return null;
}
}
private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId)
=> algorithmId switch
{
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512,
_ => throw new InvalidOperationException($"Unsupported mirror signature algorithm '{algorithmId}'."),
};
private sealed record MirrorSignatureHeader(
[property: JsonPropertyName("alg")] string Algorithm,
[property: JsonPropertyName("kid")] string KeyId,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("typ")] string? Type,
[property: JsonPropertyName("b64")] bool Base64Payload,
[property: JsonPropertyName("crit")] string[] Critical);
}

View File

@@ -1,61 +1,61 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
/// <summary>
/// Configuration for the StellaOps mirror connector HTTP client.
/// </summary>
public sealed class StellaOpsMirrorConnectorOptions
{
/// <summary>
/// Base address of the mirror distribution endpoint (e.g., https://mirror.stella-ops.org).
/// </summary>
[Required]
public Uri BaseAddress { get; set; } = new("https://mirror.stella-ops.org", UriKind.Absolute);
/// <summary>
/// Relative path to the mirror index document. Defaults to <c>/concelier/exports/index.json</c>.
/// </summary>
[Required]
public string IndexPath { get; set; } = "/concelier/exports/index.json";
/// <summary>
/// Preferred mirror domain identifier when multiple domains are published in the index.
/// </summary>
[Required]
public string DomainId { get; set; } = "primary";
/// <summary>
/// Maximum duration to wait on HTTP requests.
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Signature verification configuration for downloaded bundles.
/// </summary>
public SignatureOptions Signature { get; set; } = new();
public sealed class SignatureOptions
{
/// <summary>
/// When <c>true</c>, downloaded bundles must include a detached JWS that validates successfully.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Expected signing key identifier (kid) emitted in the detached JWS header.
/// </summary>
public string KeyId { get; set; } = string.Empty;
/// <summary>
/// Optional crypto provider hint used to resolve verification keys.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// Optional path to a PEM-encoded EC public key used to verify signatures when registry resolution fails.
/// </summary>
public string? PublicKeyPath { get; set; }
}
}
using System;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
/// <summary>
/// Configuration for the StellaOps mirror connector HTTP client.
/// </summary>
public sealed class StellaOpsMirrorConnectorOptions
{
/// <summary>
/// Base address of the mirror distribution endpoint (e.g., https://mirror.stella-ops.org).
/// </summary>
[Required]
public Uri BaseAddress { get; set; } = new("https://mirror.stella-ops.org", UriKind.Absolute);
/// <summary>
/// Relative path to the mirror index document. Defaults to <c>/concelier/exports/index.json</c>.
/// </summary>
[Required]
public string IndexPath { get; set; } = "/concelier/exports/index.json";
/// <summary>
/// Preferred mirror domain identifier when multiple domains are published in the index.
/// </summary>
[Required]
public string DomainId { get; set; } = "primary";
/// <summary>
/// Maximum duration to wait on HTTP requests.
/// </summary>
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Signature verification configuration for downloaded bundles.
/// </summary>
public SignatureOptions Signature { get; set; } = new();
public sealed class SignatureOptions
{
/// <summary>
/// When <c>true</c>, downloaded bundles must include a detached JWS that validates successfully.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Expected signing key identifier (kid) emitted in the detached JWS header.
/// </summary>
public string KeyId { get; set; } = string.Empty;
/// <summary>
/// Optional crypto provider hint used to resolve verification keys.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// Optional path to a PEM-encoded EC public key used to verify signatures when registry resolution fails.
/// </summary>
public string? PublicKeyPath { get; set; }
}
}

View File

@@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Bson;
using StellaOps.Concelier.Documents;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.StellaOpsMirror.Client;
@@ -280,7 +280,7 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector
private async Task UpdateCursorAsync(StellaOpsMirrorCursor cursor, CancellationToken cancellationToken)
{
var document = cursor.ToBsonDocument();
var document = cursor.ToDocumentObject();
var now = _timeProvider.GetUtcNow();
await _stateRepository.UpdateCursorAsync(Source, document, now, cancellationToken).ConfigureAwait(false);
}
@@ -422,7 +422,7 @@ public sealed class StellaOpsMirrorConnector : IFeedConnector
continue;
}
var dtoBson = BsonDocument.Parse(json);
var dtoBson = DocumentObject.Parse(json);
var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, Source, BundleDtoSchemaVersion, dtoBson, now);
await _dtoStore.UpsertAsync(dtoRecord, cancellationToken).ConfigureAwait(false);
await _documentStore.UpdateStatusAsync(document.Id, DocumentStatuses.PendingMap, cancellationToken).ConfigureAwait(false);

View File

@@ -1,19 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
public sealed class StellaOpsMirrorConnectorPlugin : IConnectorPlugin
{
public const string SourceName = StellaOpsMirrorConnector.Source;
public string Name => SourceName;
public bool IsAvailable(IServiceProvider services) => services is not null;
public IFeedConnector Create(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return ActivatorUtilities.CreateInstance<StellaOpsMirrorConnector>(services);
}
}
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
public sealed class StellaOpsMirrorConnectorPlugin : IConnectorPlugin
{
public const string SourceName = StellaOpsMirrorConnector.Source;
public string Name => SourceName;
public bool IsAvailable(IServiceProvider services) => services is not null;
public IFeedConnector Create(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return ActivatorUtilities.CreateInstance<StellaOpsMirrorConnector>(services);
}
}

View File

@@ -1,37 +1,37 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.StellaOpsMirror.Client;
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.DependencyInjection;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
public sealed class StellaOpsMirrorDependencyInjectionRoutine : IDependencyInjectionRoutine
{
private const string ConfigurationSection = "concelier:sources:stellaopsMirror";
private const string HttpClientName = "stellaops-mirror";
private static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(4);
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.StellaOpsMirror.Client;
using StellaOps.Concelier.Connector.StellaOpsMirror.Security;
using StellaOps.Concelier.Connector.StellaOpsMirror.Settings;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.DependencyInjection;
namespace StellaOps.Concelier.Connector.StellaOpsMirror;
public sealed class StellaOpsMirrorDependencyInjectionRoutine : IDependencyInjectionRoutine
{
private const string ConfigurationSection = "concelier:sources:stellaopsMirror";
private const string HttpClientName = "stellaops-mirror";
private static readonly TimeSpan FetchTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(4);
public IServiceCollection Register(IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddOptions<StellaOpsMirrorConnectorOptions>()
.Bind(configuration.GetSection(ConfigurationSection))
.PostConfigure(options =>
{
if (options.BaseAddress is null)
{
throw new InvalidOperationException("stellaopsMirror.baseAddress must be configured.");
}
})
{
throw new InvalidOperationException("stellaopsMirror.baseAddress must be configured.");
}
})
.ValidateOnStart();
services.AddSourceCommon();
@@ -41,40 +41,40 @@ public sealed class StellaOpsMirrorDependencyInjectionRoutine : IDependencyInjec
{
var options = sp.GetRequiredService<IOptions<StellaOpsMirrorConnectorOptions>>().Value;
client.BaseAddress = options.BaseAddress;
client.Timeout = options.HttpTimeout;
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
});
services.AddTransient<MirrorManifestClient>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var httpClient = factory.CreateClient(HttpClientName);
return ActivatorUtilities.CreateInstance<MirrorManifestClient>(sp, httpClient);
});
services.TryAddSingleton<MirrorSignatureVerifier>();
services.AddTransient<StellaOpsMirrorConnector>();
var scheduler = new JobSchedulerBuilder(services);
scheduler.AddJob<StellaOpsMirrorFetchJob>(
StellaOpsMirrorJobKinds.Fetch,
cronExpression: "*/15 * * * *",
timeout: FetchTimeout,
leaseDuration: LeaseDuration);
scheduler.AddJob<StellaOpsMirrorParseJob>(
StellaOpsMirrorJobKinds.Parse,
cronExpression: null,
timeout: TimeSpan.FromMinutes(5),
leaseDuration: LeaseDuration,
enabled: false);
scheduler.AddJob<StellaOpsMirrorMapJob>(
StellaOpsMirrorJobKinds.Map,
cronExpression: null,
timeout: TimeSpan.FromMinutes(5),
leaseDuration: LeaseDuration,
enabled: false);
return services;
}
}
client.Timeout = options.HttpTimeout;
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
});
services.AddTransient<MirrorManifestClient>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var httpClient = factory.CreateClient(HttpClientName);
return ActivatorUtilities.CreateInstance<MirrorManifestClient>(sp, httpClient);
});
services.TryAddSingleton<MirrorSignatureVerifier>();
services.AddTransient<StellaOpsMirrorConnector>();
var scheduler = new JobSchedulerBuilder(services);
scheduler.AddJob<StellaOpsMirrorFetchJob>(
StellaOpsMirrorJobKinds.Fetch,
cronExpression: "*/15 * * * *",
timeout: FetchTimeout,
leaseDuration: LeaseDuration);
scheduler.AddJob<StellaOpsMirrorParseJob>(
StellaOpsMirrorJobKinds.Parse,
cronExpression: null,
timeout: TimeSpan.FromMinutes(5),
leaseDuration: LeaseDuration,
enabled: false);
scheduler.AddJob<StellaOpsMirrorMapJob>(
StellaOpsMirrorJobKinds.Map,
cronExpression: null,
timeout: TimeSpan.FromMinutes(5),
leaseDuration: LeaseDuration,
enabled: false);
return services;
}
}