consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -1,24 +1,49 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using StellaOps.Telemetry.Federation.Aggregation;
using StellaOps.Telemetry.Federation.Consent;
using StellaOps.Telemetry.Federation.Security;
namespace StellaOps.Telemetry.Federation.Bundles;
public sealed class FederatedTelemetryBundleBuilder : IFederatedTelemetryBundleBuilder
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = null,
WriteIndented = false
};
private readonly FederatedTelemetryOptions _options;
private readonly IFederationDsseEnvelopeSigner _envelopeSigner;
private readonly IFederationDsseEnvelopeVerifier _envelopeVerifier;
private readonly TimeProvider _timeProvider;
public FederatedTelemetryBundleBuilder(
IOptions<FederatedTelemetryOptions> options,
IFederationDsseEnvelopeSigner envelopeSigner,
IFederationDsseEnvelopeVerifier envelopeVerifier,
TimeProvider? timeProvider = null)
{
_options = options.Value;
_envelopeSigner = envelopeSigner ?? throw new ArgumentNullException(nameof(envelopeSigner));
_envelopeVerifier = envelopeVerifier ?? throw new ArgumentNullException(nameof(envelopeVerifier));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public FederatedTelemetryBundleBuilder(
IOptions<FederatedTelemetryOptions> options,
TimeProvider? timeProvider = null)
: this(
options,
new HmacFederationDsseEnvelopeService(options),
new HmacFederationDsseEnvelopeService(options),
timeProvider)
{
}
public Task<FederatedBundle> BuildAsync(
AggregationResult aggregation,
ConsentProof consent,
@@ -26,57 +51,172 @@ public sealed class FederatedTelemetryBundleBuilder : IFederatedTelemetryBundleB
{
ct.ThrowIfCancellationRequested();
var bundleId = Guid.NewGuid();
var canonicalBuckets = aggregation.Buckets
.Where(static bucket => !bucket.Suppressed)
.OrderBy(static bucket => bucket.CveId, StringComparer.Ordinal)
.ThenByDescending(static bucket => bucket.NoisyCount)
.ThenBy(static bucket => bucket.ArtifactCount)
.ThenBy(static bucket => bucket.ObservationCount)
.Select(static bucket => new BundleBucketDocument(
CveId: bucket.CveId,
NoisyCount: bucket.NoisyCount,
ArtifactCount: bucket.ArtifactCount))
.ToList();
var now = _timeProvider.GetUtcNow();
var payload = JsonSerializer.SerializeToUtf8Bytes(new
var deterministicIdSeed = JsonSerializer.SerializeToUtf8Bytes(new BundleIdSeedDocument(
SiteId: _options.SiteId,
AggregatedAt: aggregation.AggregatedAt,
TotalFacts: aggregation.TotalFacts,
SuppressedBuckets: aggregation.SuppressedBuckets,
EpsilonSpent: aggregation.EpsilonSpent,
Buckets: canonicalBuckets,
ConsentDigest: consent.DsseDigest,
CreatedAt: now), SerializerOptions);
var bundleId = CreateDeterministicGuid(deterministicIdSeed);
var payload = JsonSerializer.SerializeToUtf8Bytes(new BundlePayloadDocument(
Id: bundleId,
SiteId: _options.SiteId,
PredicateType: _options.BundlePredicateType,
AggregatedAt: aggregation.AggregatedAt,
TotalFacts: aggregation.TotalFacts,
SuppressedBuckets: aggregation.SuppressedBuckets,
EpsilonSpent: aggregation.EpsilonSpent,
Buckets: canonicalBuckets,
ConsentDigest: consent.DsseDigest,
ConsentSignerKeyId: consent.SignerKeyId,
CreatedAt: now), SerializerOptions);
return BuildSignedBundleAsync(bundleId, aggregation, consent, payload, now, ct);
}
private async Task<FederatedBundle> BuildSignedBundleAsync(
Guid bundleId,
AggregationResult aggregation,
ConsentProof consent,
byte[] payload,
DateTimeOffset createdAt,
CancellationToken ct)
{
FederationDsseEnvelopeSignResult signResult;
try
{
id = bundleId,
siteId = _options.SiteId,
predicateType = _options.BundlePredicateType,
aggregatedAt = aggregation.AggregatedAt,
totalFacts = aggregation.TotalFacts,
suppressedBuckets = aggregation.SuppressedBuckets,
epsilonSpent = aggregation.EpsilonSpent,
buckets = aggregation.Buckets.Where(b => !b.Suppressed).Select(b => new
{
cveId = b.CveId,
noisyCount = b.NoisyCount,
artifactCount = b.ArtifactCount
}),
consentDigest = consent.DsseDigest,
createdAt = now
});
signResult = await _envelopeSigner
.SignAsync(_options.BundlePredicateType, payload, ct)
.ConfigureAwait(false);
}
catch (FederationSignatureException)
{
throw;
}
catch (Exception ex)
{
throw new FederationSignatureException(
"federation.dsse.sign_failed",
"Federated bundle could not be DSSE-signed.",
ex);
}
var digest = ComputeDigest(payload);
var envelope = payload; // Placeholder: real DSSE envelope wraps with signature
var bundle = new FederatedBundle(
return new FederatedBundle(
Id: bundleId,
SourceSiteId: _options.SiteId,
Aggregation: aggregation,
ConsentDsseDigest: consent.DsseDigest,
BundleDsseDigest: digest,
Envelope: envelope,
CreatedAt: now);
return Task.FromResult(bundle);
BundleDsseDigest: signResult.EnvelopeDigest,
Envelope: signResult.Envelope,
CreatedAt: createdAt,
SignerKeyId: signResult.SignerKeyId);
}
public Task<bool> VerifyAsync(FederatedBundle bundle, CancellationToken ct = default)
public async Task<bool> VerifyAsync(FederatedBundle bundle, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// Verify the bundle digest matches the envelope content
var recomputedDigest = ComputeDigest(bundle.Envelope);
var isValid = string.Equals(recomputedDigest, bundle.BundleDsseDigest, StringComparison.Ordinal);
var recomputedDigest = HmacFederationDsseEnvelopeService.ComputeDigest(bundle.Envelope);
if (!string.Equals(recomputedDigest, bundle.BundleDsseDigest, StringComparison.Ordinal))
{
return false;
}
return Task.FromResult(isValid);
var verifyResult = await _envelopeVerifier
.VerifyAsync(bundle.Envelope, _options.BundlePredicateType, ct)
.ConfigureAwait(false);
if (!verifyResult.IsValid || verifyResult.Payload is null)
{
return false;
}
if (!string.IsNullOrWhiteSpace(bundle.SignerKeyId) &&
!string.Equals(bundle.SignerKeyId, verifyResult.SignerKeyId, StringComparison.Ordinal))
{
return false;
}
BundlePayloadDocument? payload;
try
{
payload = JsonSerializer.Deserialize<BundlePayloadDocument>(verifyResult.Payload, SerializerOptions);
}
catch (JsonException)
{
return false;
}
if (payload is null)
{
return false;
}
if (payload.Id != bundle.Id ||
!string.Equals(payload.SiteId, bundle.SourceSiteId, StringComparison.Ordinal) ||
payload.CreatedAt != bundle.CreatedAt ||
!string.Equals(payload.ConsentDigest, bundle.ConsentDsseDigest, StringComparison.Ordinal))
{
return false;
}
return true;
}
private static string ComputeDigest(byte[] payload)
private static Guid CreateDeterministicGuid(ReadOnlySpan<byte> seed)
{
var hash = SHA256.HashData(payload);
return $"sha256:{Convert.ToHexStringLower(hash)}";
var hash = SHA256.HashData(seed);
Span<byte> guidBytes = stackalloc byte[16];
hash.AsSpan(0, 16).CopyTo(guidBytes);
// RFC 4122 variant + version 4 layout on deterministic bytes.
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x40);
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80);
return new Guid(guidBytes);
}
private sealed record BundleBucketDocument(
[property: JsonPropertyName("cveId")] string CveId,
[property: JsonPropertyName("noisyCount")] double NoisyCount,
[property: JsonPropertyName("artifactCount")] int ArtifactCount);
private sealed record BundleIdSeedDocument(
[property: JsonPropertyName("siteId")] string SiteId,
[property: JsonPropertyName("aggregatedAt")] DateTimeOffset AggregatedAt,
[property: JsonPropertyName("totalFacts")] int TotalFacts,
[property: JsonPropertyName("suppressedBuckets")] int SuppressedBuckets,
[property: JsonPropertyName("epsilonSpent")] double EpsilonSpent,
[property: JsonPropertyName("buckets")] IReadOnlyList<BundleBucketDocument> Buckets,
[property: JsonPropertyName("consentDigest")] string ConsentDigest,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
private sealed record BundlePayloadDocument(
[property: JsonPropertyName("id")] Guid Id,
[property: JsonPropertyName("siteId")] string SiteId,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("aggregatedAt")] DateTimeOffset AggregatedAt,
[property: JsonPropertyName("totalFacts")] int TotalFacts,
[property: JsonPropertyName("suppressedBuckets")] int SuppressedBuckets,
[property: JsonPropertyName("epsilonSpent")] double EpsilonSpent,
[property: JsonPropertyName("buckets")] IReadOnlyList<BundleBucketDocument> Buckets,
[property: JsonPropertyName("consentDigest")] string ConsentDigest,
[property: JsonPropertyName("consentSignerKeyId")] string? ConsentSignerKeyId,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
}

View File

@@ -22,4 +22,5 @@ public sealed record FederatedBundle(
string ConsentDsseDigest,
string BundleDsseDigest,
byte[] Envelope,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt,
string? SignerKeyId = null);

View File

@@ -1,20 +1,46 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using StellaOps.Telemetry.Federation.Security;
namespace StellaOps.Telemetry.Federation.Consent;
public sealed class ConsentManager : IConsentManager
{
private readonly ConcurrentDictionary<string, ConsentEntry> _consents = new();
private readonly FederatedTelemetryOptions _options;
private readonly IFederationDsseEnvelopeSigner _envelopeSigner;
private readonly IFederationDsseEnvelopeVerifier _envelopeVerifier;
private readonly TimeProvider _timeProvider;
public ConsentManager(TimeProvider? timeProvider = null)
public ConsentManager(
IOptions<FederatedTelemetryOptions> options,
IFederationDsseEnvelopeSigner envelopeSigner,
IFederationDsseEnvelopeVerifier envelopeVerifier,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
_envelopeSigner = envelopeSigner ?? throw new ArgumentNullException(nameof(envelopeSigner));
_envelopeVerifier = envelopeVerifier ?? throw new ArgumentNullException(nameof(envelopeVerifier));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public ConsentManager(IOptions<FederatedTelemetryOptions> options, TimeProvider? timeProvider = null)
: this(
options,
new HmacFederationDsseEnvelopeService(options),
new HmacFederationDsseEnvelopeService(options),
timeProvider)
{
}
public ConsentManager(TimeProvider? timeProvider = null)
: this(Options.Create(new FederatedTelemetryOptions()), timeProvider)
{
}
public Task<ConsentState> GetConsentStateAsync(string tenantId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
@@ -26,7 +52,8 @@ public sealed class ConsentManager : IConsentManager
GrantedBy: null,
GrantedAt: null,
ExpiresAt: null,
DsseDigest: null));
DsseDigest: null,
SignerKeyId: null));
}
var now = _timeProvider.GetUtcNow();
@@ -38,7 +65,8 @@ public sealed class ConsentManager : IConsentManager
GrantedBy: null,
GrantedAt: null,
ExpiresAt: null,
DsseDigest: null));
DsseDigest: null,
SignerKeyId: null));
}
return Task.FromResult(new ConsentState(
@@ -46,10 +74,11 @@ public sealed class ConsentManager : IConsentManager
GrantedBy: entry.GrantedBy,
GrantedAt: entry.GrantedAt,
ExpiresAt: entry.ExpiresAt,
DsseDigest: entry.DsseDigest));
DsseDigest: entry.DsseDigest,
SignerKeyId: entry.SignerKeyId));
}
public Task<ConsentProof> GrantConsentAsync(
public async Task<ConsentProof> GrantConsentAsync(
string tenantId,
string grantedBy,
TimeSpan? ttl = null,
@@ -60,28 +89,101 @@ public sealed class ConsentManager : IConsentManager
var now = _timeProvider.GetUtcNow();
var expiresAt = ttl.HasValue ? now + ttl.Value : (DateTimeOffset?)null;
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
tenantId,
grantedBy,
grantedAt = now,
expiresAt,
type = "stella.ops/federatedConsent@v1"
});
var digest = ComputeDigest(payload);
var envelope = payload; // Placeholder: real DSSE envelope wraps with signature
var entry = new ConsentEntry(tenantId, grantedBy, now, expiresAt, digest);
_consents[tenantId] = entry;
return Task.FromResult(new ConsentProof(
var payload = JsonSerializer.SerializeToUtf8Bytes(new ConsentPayloadDocument(
TenantId: tenantId,
GrantedBy: grantedBy,
GrantedAt: now,
ExpiresAt: expiresAt,
DsseDigest: digest,
Envelope: envelope));
Type: _options.ConsentPredicateType));
FederationDsseEnvelopeSignResult signResult;
try
{
signResult = await _envelopeSigner
.SignAsync(_options.ConsentPredicateType, payload, ct)
.ConfigureAwait(false);
}
catch (FederationSignatureException)
{
throw;
}
catch (Exception ex)
{
throw new FederationSignatureException(
"federation.dsse.sign_failed",
"Consent proof could not be DSSE-signed.",
ex);
}
var entry = new ConsentEntry(tenantId, grantedBy, now, expiresAt, signResult.EnvelopeDigest, signResult.SignerKeyId);
_consents[tenantId] = entry;
return new ConsentProof(
TenantId: tenantId,
GrantedBy: grantedBy,
GrantedAt: now,
ExpiresAt: expiresAt,
DsseDigest: signResult.EnvelopeDigest,
Envelope: signResult.Envelope,
SignerKeyId: signResult.SignerKeyId);
}
public async Task<bool> VerifyProofAsync(ConsentProof proof, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(proof);
var envelopeDigest = HmacFederationDsseEnvelopeService.ComputeDigest(proof.Envelope);
if (!string.Equals(envelopeDigest, proof.DsseDigest, StringComparison.Ordinal))
{
return false;
}
var verifyResult = await _envelopeVerifier
.VerifyAsync(proof.Envelope, _options.ConsentPredicateType, ct)
.ConfigureAwait(false);
if (!verifyResult.IsValid || verifyResult.Payload is null)
{
return false;
}
ConsentPayloadDocument? payload;
try
{
payload = JsonSerializer.Deserialize<ConsentPayloadDocument>(verifyResult.Payload);
}
catch (JsonException)
{
return false;
}
if (payload is null)
{
return false;
}
if (!string.Equals(payload.TenantId, proof.TenantId, StringComparison.Ordinal) ||
!string.Equals(payload.GrantedBy, proof.GrantedBy, StringComparison.Ordinal) ||
payload.GrantedAt != proof.GrantedAt ||
payload.ExpiresAt != proof.ExpiresAt ||
!string.Equals(payload.Type, _options.ConsentPredicateType, StringComparison.Ordinal))
{
return false;
}
if (!string.IsNullOrWhiteSpace(proof.SignerKeyId) &&
!string.Equals(proof.SignerKeyId, verifyResult.SignerKeyId, StringComparison.Ordinal))
{
return false;
}
var now = _timeProvider.GetUtcNow();
if (payload.ExpiresAt.HasValue && now >= payload.ExpiresAt.Value)
{
return false;
}
return true;
}
public Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default)
@@ -91,16 +193,18 @@ public sealed class ConsentManager : IConsentManager
return Task.CompletedTask;
}
private static string ComputeDigest(byte[] payload)
{
var hash = SHA256.HashData(payload);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private sealed record ConsentEntry(
string TenantId,
string GrantedBy,
DateTimeOffset GrantedAt,
DateTimeOffset? ExpiresAt,
string DsseDigest);
string DsseDigest,
string SignerKeyId);
private sealed record ConsentPayloadDocument(
[property: JsonPropertyName("tenantId")] string TenantId,
[property: JsonPropertyName("grantedBy")] string GrantedBy,
[property: JsonPropertyName("grantedAt")] DateTimeOffset GrantedAt,
[property: JsonPropertyName("expiresAt")] DateTimeOffset? ExpiresAt,
[property: JsonPropertyName("type")] string Type);
}

View File

@@ -4,6 +4,7 @@ public interface IConsentManager
{
Task<ConsentState> GetConsentStateAsync(string tenantId, CancellationToken ct = default);
Task<ConsentProof> GrantConsentAsync(string tenantId, string grantedBy, TimeSpan? ttl = null, CancellationToken ct = default);
Task<bool> VerifyProofAsync(ConsentProof proof, CancellationToken ct = default);
Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default);
}
@@ -12,7 +13,8 @@ public sealed record ConsentState(
string? GrantedBy,
DateTimeOffset? GrantedAt,
DateTimeOffset? ExpiresAt,
string? DsseDigest);
string? DsseDigest,
string? SignerKeyId = null);
public sealed record ConsentProof(
string TenantId,
@@ -20,4 +22,5 @@ public sealed record ConsentProof(
DateTimeOffset GrantedAt,
DateTimeOffset? ExpiresAt,
string DsseDigest,
byte[] Envelope);
byte[] Envelope,
string? SignerKeyId = null);

View File

@@ -3,6 +3,7 @@ namespace StellaOps.Telemetry.Federation;
public sealed class FederatedTelemetryOptions
{
public const string SectionName = "FederatedTelemetry";
public const string UnsignedFallbackKeyId = "offline-unsigned-fallback";
/// <summary>
/// Minimum number of distinct artifacts per CVE bucket to avoid suppression.
@@ -43,4 +44,28 @@ public sealed class FederatedTelemetryOptions
/// Identifier for this site in the federation mesh.
/// </summary>
public string SiteId { get; set; } = "default";
/// <summary>
/// Key identifier used when signing federation DSSE envelopes.
/// </summary>
public string DsseSignerKeyId { get; set; } = "federation-default";
/// <summary>
/// Local secret used by the default federation DSSE signer (HMAC-SHA256).
/// </summary>
public string DsseSignerSecret { get; set; } = "stellaops-federation-default-secret";
/// <summary>
/// Trusted signer secrets keyed by DSSE key ID for offline verification.
/// </summary>
public Dictionary<string, string> DsseTrustedKeys { get; } = new(StringComparer.Ordinal)
{
["federation-default"] = "stellaops-federation-default-secret"
};
/// <summary>
/// When true, missing key material produces unsigned envelopes tagged with
/// <see cref="UnsignedFallbackKeyId"/> instead of throwing.
/// </summary>
public bool AllowUnsignedDsseFallback { get; set; }
}

View File

@@ -5,6 +5,7 @@ using StellaOps.Telemetry.Federation.Bundles;
using StellaOps.Telemetry.Federation.Consent;
using StellaOps.Telemetry.Federation.Intelligence;
using StellaOps.Telemetry.Federation.Privacy;
using StellaOps.Telemetry.Federation.Security;
using StellaOps.Telemetry.Federation.Sync;
namespace StellaOps.Telemetry.Federation;
@@ -22,6 +23,9 @@ public static class FederationServiceCollectionExtensions
services.TryAddSingleton<IPrivacyBudgetTracker, PrivacyBudgetTracker>();
services.TryAddSingleton<ITelemetryAggregator, TelemetryAggregator>();
services.TryAddSingleton<HmacFederationDsseEnvelopeService>();
services.TryAddSingleton<IFederationDsseEnvelopeSigner>(sp => sp.GetRequiredService<HmacFederationDsseEnvelopeService>());
services.TryAddSingleton<IFederationDsseEnvelopeVerifier>(sp => sp.GetRequiredService<HmacFederationDsseEnvelopeService>());
services.TryAddSingleton<IConsentManager, ConsentManager>();
services.TryAddSingleton<IFederatedTelemetryBundleBuilder, FederatedTelemetryBundleBuilder>();
services.TryAddSingleton<IExploitIntelligenceMerger, ExploitIntelligenceMerger>();

View File

@@ -0,0 +1,42 @@
namespace StellaOps.Telemetry.Federation.Security;
public interface IFederationDsseEnvelopeSigner
{
Task<FederationDsseEnvelopeSignResult> SignAsync(
string payloadType,
ReadOnlyMemory<byte> canonicalPayload,
CancellationToken ct = default);
}
public interface IFederationDsseEnvelopeVerifier
{
Task<FederationDsseEnvelopeVerifyResult> VerifyAsync(
ReadOnlyMemory<byte> envelope,
string expectedPayloadType,
CancellationToken ct = default);
}
public sealed record FederationDsseEnvelopeSignResult(
byte[] Envelope,
string EnvelopeDigest,
string SignerKeyId,
bool UsedFallback);
public sealed record FederationDsseEnvelopeVerifyResult(
bool IsValid,
string? SignerKeyId,
byte[]? Payload,
string? PayloadDigest,
string? ErrorCode,
string? ErrorMessage);
public sealed class FederationSignatureException : InvalidOperationException
{
public FederationSignatureException(string errorCode, string message, Exception? innerException = null)
: base(message, innerException)
{
ErrorCode = errorCode;
}
public string ErrorCode { get; }
}

View File

@@ -0,0 +1,268 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace StellaOps.Telemetry.Federation.Security;
public sealed class HmacFederationDsseEnvelopeService :
IFederationDsseEnvelopeSigner,
IFederationDsseEnvelopeVerifier
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = null,
WriteIndented = false
};
private readonly FederatedTelemetryOptions _options;
public HmacFederationDsseEnvelopeService(IOptions<FederatedTelemetryOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value;
}
public Task<FederationDsseEnvelopeSignResult> SignAsync(
string payloadType,
ReadOnlyMemory<byte> canonicalPayload,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new FederationSignatureException(
"federation.dsse.invalid_payload_type",
"DSSE payloadType is required for federation signing.");
}
var normalizedType = payloadType.Trim();
var payloadBytes = canonicalPayload.ToArray();
var payloadBase64 = Convert.ToBase64String(payloadBytes);
if (!TryGetSigner(out var signerKeyId, out var signerSecret))
{
if (_options.AllowUnsignedDsseFallback)
{
var unsignedEnvelope = new DsseEnvelopeDocument(
PayloadType: normalizedType,
Payload: payloadBase64,
Signatures: Array.Empty<DsseSignatureDocument>());
var unsignedBytes = JsonSerializer.SerializeToUtf8Bytes(unsignedEnvelope, SerializerOptions);
return Task.FromResult(new FederationDsseEnvelopeSignResult(
Envelope: unsignedBytes,
EnvelopeDigest: ComputeDigest(unsignedBytes),
SignerKeyId: FederatedTelemetryOptions.UnsignedFallbackKeyId,
UsedFallback: true));
}
throw new FederationSignatureException(
"federation.dsse.signer_unavailable",
"Federation DSSE signer key material is not configured.");
}
var signatureBytes = ComputeSignature(normalizedType, payloadBytes, signerSecret);
var envelope = new DsseEnvelopeDocument(
PayloadType: normalizedType,
Payload: payloadBase64,
Signatures:
[
new DsseSignatureDocument(
KeyId: signerKeyId,
Signature: Convert.ToBase64String(signatureBytes))
]);
var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, SerializerOptions);
return Task.FromResult(new FederationDsseEnvelopeSignResult(
Envelope: envelopeBytes,
EnvelopeDigest: ComputeDigest(envelopeBytes),
SignerKeyId: signerKeyId,
UsedFallback: false));
}
public Task<FederationDsseEnvelopeVerifyResult> VerifyAsync(
ReadOnlyMemory<byte> envelope,
string expectedPayloadType,
CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(expectedPayloadType))
{
return Task.FromResult(Fail(
"federation.dsse.invalid_payload_type",
"Expected payloadType is required for federation verification."));
}
DsseEnvelopeDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize<DsseEnvelopeDocument>(envelope.Span, SerializerOptions);
}
catch (JsonException ex)
{
return Task.FromResult(Fail("federation.dsse.malformed_envelope", ex.Message));
}
if (parsed is null)
{
return Task.FromResult(Fail(
"federation.dsse.malformed_envelope",
"Federation DSSE envelope JSON was empty."));
}
var normalizedExpectedType = expectedPayloadType.Trim();
if (!string.Equals(parsed.PayloadType, normalizedExpectedType, StringComparison.Ordinal))
{
return Task.FromResult(Fail(
"federation.dsse.payload_type_mismatch",
$"Expected payloadType '{normalizedExpectedType}' but received '{parsed.PayloadType}'."));
}
byte[] payloadBytes;
try
{
payloadBytes = Convert.FromBase64String(parsed.Payload);
}
catch (FormatException ex)
{
return Task.FromResult(Fail("federation.dsse.payload_not_base64", ex.Message));
}
if (parsed.Signatures.Count == 0)
{
return Task.FromResult(Fail(
"federation.dsse.signature_missing",
"Federation DSSE envelope has no signatures."));
}
var trustedKeys = ResolveTrustedSecrets();
foreach (var signature in parsed.Signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
continue;
}
if (!trustedKeys.TryGetValue(signature.KeyId, out var trustedSecret))
{
continue;
}
byte[] providedSignature;
try
{
providedSignature = Convert.FromBase64String(signature.Signature);
}
catch (FormatException)
{
continue;
}
var expectedSignature = ComputeSignature(parsed.PayloadType, payloadBytes, trustedSecret);
if (CryptographicOperations.FixedTimeEquals(providedSignature, expectedSignature))
{
return Task.FromResult(new FederationDsseEnvelopeVerifyResult(
IsValid: true,
SignerKeyId: signature.KeyId,
Payload: payloadBytes,
PayloadDigest: ComputeDigest(payloadBytes),
ErrorCode: null,
ErrorMessage: null));
}
}
return Task.FromResult(Fail(
"federation.dsse.signature_invalid_or_untrusted",
"Federation DSSE envelope signature could not be verified with trusted keys."));
}
public static string ComputeDigest(ReadOnlySpan<byte> bytes)
{
var hash = SHA256.HashData(bytes);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private bool TryGetSigner(out string signerKeyId, out byte[] signerSecret)
{
signerKeyId = _options.DsseSignerKeyId?.Trim() ?? string.Empty;
signerSecret = Array.Empty<byte>();
if (string.IsNullOrWhiteSpace(signerKeyId))
{
return false;
}
var configuredSecret = _options.DsseSignerSecret?.Trim();
if (string.IsNullOrEmpty(configuredSecret))
{
return false;
}
signerSecret = Encoding.UTF8.GetBytes(configuredSecret);
return true;
}
private Dictionary<string, byte[]> ResolveTrustedSecrets()
{
var trusted = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (var entry in _options.DsseTrustedKeys.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
{
continue;
}
trusted[entry.Key.Trim()] = Encoding.UTF8.GetBytes(entry.Value.Trim());
}
if (TryGetSigner(out var signerKeyId, out var signerSecret))
{
trusted.TryAdd(signerKeyId, signerSecret);
}
return trusted;
}
private static byte[] ComputeSignature(string payloadType, byte[] payload, byte[] secret)
{
var pae = CreatePreAuthenticationEncoding(payloadType, payload);
using var hmac = new HMACSHA256(secret);
return hmac.ComputeHash(pae);
}
private static byte[] CreatePreAuthenticationEncoding(string payloadType, byte[] payload)
{
var payloadTypeBytes = Encoding.UTF8.GetBytes(payloadType);
var prefix = Encoding.UTF8.GetBytes($"DSSEv1 {payloadTypeBytes.Length} {payloadType} {payload.Length} ");
var pae = new byte[prefix.Length + payload.Length];
Buffer.BlockCopy(prefix, 0, pae, 0, prefix.Length);
Buffer.BlockCopy(payload, 0, pae, prefix.Length, payload.Length);
return pae;
}
private static FederationDsseEnvelopeVerifyResult Fail(string errorCode, string errorMessage)
{
return new FederationDsseEnvelopeVerifyResult(
IsValid: false,
SignerKeyId: null,
Payload: null,
PayloadDigest: null,
ErrorCode: errorCode,
ErrorMessage: errorMessage);
}
private sealed record DsseEnvelopeDocument(
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignatureDocument> Signatures);
private sealed record DsseSignatureDocument(
[property: JsonPropertyName("keyid")] string KeyId,
[property: JsonPropertyName("sig")] string Signature);
}