consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Security;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
@@ -7,7 +12,7 @@ public sealed class ConsentManagerTests
|
||||
[Fact]
|
||||
public async Task Default_consent_state_is_not_granted()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
var manager = CreateManager();
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
|
||||
@@ -16,30 +21,115 @@ public sealed class ConsentManagerTests
|
||||
Assert.Null(state.GrantedAt);
|
||||
Assert.Null(state.ExpiresAt);
|
||||
Assert.Null(state.DsseDigest);
|
||||
Assert.Null(state.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_consent_sets_granted_state()
|
||||
public async Task Grant_consent_sets_granted_state_and_signer_metadata()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
var manager = CreateManager();
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
Assert.Equal("tenant-1", proof.TenantId);
|
||||
Assert.Equal("admin@example.com", proof.GrantedBy);
|
||||
Assert.NotNull(proof.DsseDigest);
|
||||
Assert.StartsWith("sha256:", proof.DsseDigest);
|
||||
Assert.StartsWith("sha256:", proof.DsseDigest, StringComparison.Ordinal);
|
||||
Assert.Equal("consent-key", proof.SignerKeyId);
|
||||
Assert.NotEmpty(proof.Envelope);
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(state.Granted);
|
||||
Assert.Equal("admin@example.com", state.GrantedBy);
|
||||
Assert.Equal("consent-key", state.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyProofAsync_succeeds_for_valid_proof()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
var valid = await manager.VerifyProofAsync(proof);
|
||||
|
||||
Assert.True(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyProofAsync_fails_for_payload_tampering()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
var tamperedEnvelope = TamperEnvelopePayload(proof.Envelope);
|
||||
|
||||
var valid = await manager.VerifyProofAsync(proof with { Envelope = tamperedEnvelope });
|
||||
|
||||
Assert.False(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyProofAsync_fails_for_signature_tampering()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
var tamperedEnvelope = TamperEnvelopeSignature(proof.Envelope);
|
||||
|
||||
var valid = await manager.VerifyProofAsync(proof with { Envelope = tamperedEnvelope });
|
||||
|
||||
Assert.False(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyProofAsync_fails_for_wrong_key_verifier()
|
||||
{
|
||||
var signerOptions = CreateOptions("consent-key", "sign-secret", ("consent-key", "sign-secret"));
|
||||
var verifierOptions = CreateOptions("consent-key", "verify-secret", ("consent-key", "wrong-secret"));
|
||||
|
||||
var manager = new ConsentManager(
|
||||
signerOptions,
|
||||
new HmacFederationDsseEnvelopeService(signerOptions),
|
||||
new HmacFederationDsseEnvelopeService(verifierOptions));
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
var valid = await manager.VerifyProofAsync(proof);
|
||||
|
||||
Assert.False(valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyProofAsync_fails_when_signature_valid_but_consent_expired()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 12, 0, 0, TimeSpan.Zero));
|
||||
var manager = CreateManager(timeProvider: fakeTime);
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com", ttl: TimeSpan.FromMinutes(30));
|
||||
Assert.True(await manager.VerifyProofAsync(proof));
|
||||
|
||||
fakeTime.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
var afterExpiry = await manager.VerifyProofAsync(proof);
|
||||
Assert.False(afterExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_with_fixed_clock_and_key_material_produces_deterministic_digest()
|
||||
{
|
||||
var fixedNow = new DateTimeOffset(2026, 03, 04, 15, 0, 0, TimeSpan.Zero);
|
||||
var fakeTime = new FakeTimeProvider(fixedNow);
|
||||
var manager = CreateManager(timeProvider: fakeTime);
|
||||
|
||||
var proof1 = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
var proof2 = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
Assert.Equal(proof1.DsseDigest, proof2.DsseDigest);
|
||||
Assert.Equal(proof1.Envelope, proof2.Envelope);
|
||||
Assert.Equal(proof1.SignerKeyId, proof2.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_consent_clears_state()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
var manager = CreateManager();
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
await manager.RevokeConsentAsync("tenant-1", "admin@example.com");
|
||||
@@ -48,42 +138,10 @@ public sealed class ConsentManagerTests
|
||||
Assert.False(state.Granted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TTL_expiry_revokes_consent()
|
||||
{
|
||||
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var manager = new ConsentManager(fakeTime);
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin@example.com", ttl: TimeSpan.FromHours(1));
|
||||
|
||||
var stateBefore = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(stateBefore.Granted);
|
||||
|
||||
// Advance time past TTL
|
||||
fakeTime.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
var stateAfter = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.False(stateAfter.Granted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_without_TTL_has_no_expiry()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
|
||||
var proof = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
|
||||
Assert.Null(proof.ExpiresAt);
|
||||
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.True(state.Granted);
|
||||
Assert.Null(state.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_tenants_independent()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
var manager = CreateManager();
|
||||
|
||||
await manager.GrantConsentAsync("tenant-1", "admin1@example.com");
|
||||
await manager.GrantConsentAsync("tenant-2", "admin2@example.com");
|
||||
@@ -100,9 +158,11 @@ public sealed class ConsentManagerTests
|
||||
[Fact]
|
||||
public async Task Grant_overwrites_previous_consent()
|
||||
{
|
||||
var manager = new ConsentManager();
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 9, 0, 0, TimeSpan.Zero));
|
||||
var manager = CreateManager(timeProvider: fakeTime);
|
||||
|
||||
var proof1 = await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(5));
|
||||
var proof2 = await manager.GrantConsentAsync("tenant-1", "newadmin@example.com");
|
||||
|
||||
Assert.NotEqual(proof1.DsseDigest, proof2.DsseDigest);
|
||||
@@ -110,6 +170,59 @@ public sealed class ConsentManagerTests
|
||||
var state = await manager.GetConsentStateAsync("tenant-1");
|
||||
Assert.Equal("newadmin@example.com", state.GrantedBy);
|
||||
}
|
||||
|
||||
private static ConsentManager CreateManager(
|
||||
IOptions<FederatedTelemetryOptions>? options = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IFederationDsseEnvelopeSigner? signer = null,
|
||||
IFederationDsseEnvelopeVerifier? verifier = null)
|
||||
{
|
||||
options ??= CreateOptions("consent-key", "consent-secret", ("consent-key", "consent-secret"));
|
||||
signer ??= new HmacFederationDsseEnvelopeService(options);
|
||||
verifier ??= new HmacFederationDsseEnvelopeService(options);
|
||||
return new ConsentManager(options, signer, verifier, timeProvider);
|
||||
}
|
||||
|
||||
private static IOptions<FederatedTelemetryOptions> CreateOptions(
|
||||
string signerKeyId,
|
||||
string signerSecret,
|
||||
params (string KeyId, string Secret)[] trustedSecrets)
|
||||
{
|
||||
var options = new FederatedTelemetryOptions
|
||||
{
|
||||
ConsentPredicateType = "stella.ops/federatedConsent@v1",
|
||||
DsseSignerKeyId = signerKeyId,
|
||||
DsseSignerSecret = signerSecret
|
||||
};
|
||||
|
||||
options.DsseTrustedKeys.Clear();
|
||||
foreach (var trusted in trustedSecrets)
|
||||
{
|
||||
options.DsseTrustedKeys[trusted.KeyId] = trusted.Secret;
|
||||
}
|
||||
|
||||
return Options.Create(options);
|
||||
}
|
||||
|
||||
private static byte[] TamperEnvelopePayload(byte[] envelope)
|
||||
{
|
||||
var document = JsonNode.Parse(envelope)!.AsObject();
|
||||
var payload = Convert.FromBase64String(document["payload"]!.GetValue<string>());
|
||||
payload[0] ^= 0x01;
|
||||
document["payload"] = Convert.ToBase64String(payload);
|
||||
return Encoding.UTF8.GetBytes(document.ToJsonString());
|
||||
}
|
||||
|
||||
private static byte[] TamperEnvelopeSignature(byte[] envelope)
|
||||
{
|
||||
var document = JsonNode.Parse(envelope)!.AsObject();
|
||||
var signatureNode = document["signatures"]!.AsArray()[0]!.AsObject();
|
||||
var signature = signatureNode["sig"]!.GetValue<string>();
|
||||
signatureNode["sig"] = signature[^1] == 'A'
|
||||
? $"{signature[..^1]}B"
|
||||
: $"{signature[..^1]}A";
|
||||
return Encoding.UTF8.GetBytes(document.ToJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,59 +1,26 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Telemetry.Federation;
|
||||
using StellaOps.Telemetry.Federation.Aggregation;
|
||||
using StellaOps.Telemetry.Federation.Bundles;
|
||||
using StellaOps.Telemetry.Federation.Consent;
|
||||
using StellaOps.Telemetry.Federation.Security;
|
||||
|
||||
namespace StellaOps.Telemetry.Federation.Tests;
|
||||
|
||||
public sealed class FederatedTelemetryBundleBuilderTests
|
||||
{
|
||||
private static FederatedTelemetryBundleBuilder CreateBuilder(string siteId = "test-site")
|
||||
{
|
||||
var options = Options.Create(new FederatedTelemetryOptions
|
||||
{
|
||||
SiteId = siteId,
|
||||
BundlePredicateType = "stella.ops/federatedTelemetry@v1"
|
||||
});
|
||||
|
||||
return new FederatedTelemetryBundleBuilder(options);
|
||||
}
|
||||
|
||||
private static AggregationResult CreateAggregation()
|
||||
{
|
||||
return new AggregationResult(
|
||||
Buckets:
|
||||
[
|
||||
new AggregationBucket("CVE-2024-0001", 10, 5, 10.3, false),
|
||||
new AggregationBucket("CVE-2024-0002", 3, 2, 0, true),
|
||||
],
|
||||
TotalFacts: 13,
|
||||
SuppressedBuckets: 1,
|
||||
EpsilonSpent: 0.5,
|
||||
AggregatedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static ConsentProof CreateConsentProof()
|
||||
{
|
||||
return new ConsentProof(
|
||||
TenantId: "tenant-1",
|
||||
GrantedBy: "admin@example.com",
|
||||
GrantedAt: DateTimeOffset.UtcNow,
|
||||
ExpiresAt: null,
|
||||
DsseDigest: "sha256:abc123",
|
||||
Envelope: new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_creates_bundle_with_correct_site_id()
|
||||
{
|
||||
var builder = CreateBuilder("my-site");
|
||||
var aggregation = CreateAggregation();
|
||||
var consent = CreateConsentProof();
|
||||
var builder = CreateBuilder(siteId: "my-site");
|
||||
|
||||
var bundle = await builder.BuildAsync(aggregation, consent);
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
Assert.Equal("my-site", bundle.SourceSiteId);
|
||||
Assert.NotEqual(Guid.Empty, bundle.Id);
|
||||
Assert.Equal("bundle-key", bundle.SignerKeyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,13 +35,13 @@ public sealed class FederatedTelemetryBundleBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_produces_valid_dsse_digest()
|
||||
public async Task Build_produces_signed_envelope_digest()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
Assert.StartsWith("sha256:", bundle.BundleDsseDigest);
|
||||
Assert.StartsWith("sha256:", bundle.BundleDsseDigest, StringComparison.Ordinal);
|
||||
Assert.NotEmpty(bundle.Envelope);
|
||||
}
|
||||
|
||||
@@ -82,33 +49,132 @@ public sealed class FederatedTelemetryBundleBuilderTests
|
||||
public async Task Verify_succeeds_for_unmodified_bundle()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
var isValid = await builder.VerifyAsync(bundle);
|
||||
|
||||
Assert.True(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_for_tampered_bundle()
|
||||
public async Task Verify_fails_for_payload_tampering_even_when_digest_is_recomputed()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
// Tamper with the envelope
|
||||
var tampered = bundle with
|
||||
var tamperedEnvelope = TamperEnvelopePayload(bundle.Envelope);
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
Envelope = new byte[] { 0xFF, 0xFE, 0xFD }
|
||||
Envelope = tamperedEnvelope,
|
||||
BundleDsseDigest = HmacFederationDsseEnvelopeService.ComputeDigest(tamperedEnvelope)
|
||||
};
|
||||
|
||||
var isValid = await builder.VerifyAsync(tampered);
|
||||
var isValid = await builder.VerifyAsync(tamperedBundle);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_includes_aggregation_data()
|
||||
public async Task Verify_fails_for_signature_tampering_even_when_digest_is_recomputed()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
|
||||
var tamperedEnvelope = TamperEnvelopeSignature(bundle.Envelope);
|
||||
var tamperedBundle = bundle with
|
||||
{
|
||||
Envelope = tamperedEnvelope,
|
||||
BundleDsseDigest = HmacFederationDsseEnvelopeService.ComputeDigest(tamperedEnvelope)
|
||||
};
|
||||
|
||||
var isValid = await builder.VerifyAsync(tamperedBundle);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_for_wrong_key_verification()
|
||||
{
|
||||
var signerOptions = CreateOptions("bundle-key", "bundle-secret", ("bundle-key", "bundle-secret"));
|
||||
var verifierOptions = CreateOptions("bundle-key", "bundle-secret", ("bundle-key", "different-secret"));
|
||||
|
||||
var builder = new FederatedTelemetryBundleBuilder(
|
||||
signerOptions,
|
||||
new HmacFederationDsseEnvelopeService(signerOptions),
|
||||
new HmacFederationDsseEnvelopeService(verifierOptions));
|
||||
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
var isValid = await builder.VerifyAsync(bundle);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_fails_when_consent_digest_linkage_is_mismatched()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
var mismatch = bundle with { ConsentDsseDigest = "sha256:not-the-original-consent" };
|
||||
|
||||
var isValid = await builder.VerifyAsync(mismatch);
|
||||
|
||||
Assert.False(isValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_replay_with_fixed_clock_and_key_is_digest_deterministic()
|
||||
{
|
||||
var fixedTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 16, 0, 0, TimeSpan.Zero));
|
||||
var builder = CreateBuilder(timeProvider: fixedTime);
|
||||
var aggregation = CreateAggregation();
|
||||
var consent = CreateConsentProof();
|
||||
|
||||
var bundle1 = await builder.BuildAsync(aggregation, consent);
|
||||
var bundle2 = await builder.BuildAsync(aggregation, consent);
|
||||
|
||||
Assert.Equal(bundle1.Id, bundle2.Id);
|
||||
Assert.Equal(bundle1.BundleDsseDigest, bundle2.BundleDsseDigest);
|
||||
Assert.Equal(bundle1.Envelope, bundle2.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_canonicalizes_bucket_order_for_identical_logical_inputs()
|
||||
{
|
||||
var fixedTime = new FakeTimeProvider(new DateTimeOffset(2026, 03, 04, 16, 30, 0, TimeSpan.Zero));
|
||||
var builder = CreateBuilder(timeProvider: fixedTime);
|
||||
var consent = CreateConsentProof();
|
||||
|
||||
var aggregationA = new AggregationResult(
|
||||
Buckets:
|
||||
[
|
||||
new AggregationBucket("CVE-2024-7777", 9, 4, 9.1, false),
|
||||
new AggregationBucket("CVE-2024-0001", 10, 5, 10.3, false),
|
||||
new AggregationBucket("CVE-2024-0002", 3, 2, 0, true),
|
||||
],
|
||||
TotalFacts: 19,
|
||||
SuppressedBuckets: 1,
|
||||
EpsilonSpent: 0.7,
|
||||
AggregatedAt: new DateTimeOffset(2026, 03, 04, 15, 59, 0, TimeSpan.Zero));
|
||||
|
||||
var aggregationB = aggregationA with
|
||||
{
|
||||
Buckets =
|
||||
[
|
||||
new AggregationBucket("CVE-2024-0001", 10, 5, 10.3, false),
|
||||
new AggregationBucket("CVE-2024-0002", 3, 2, 0, true),
|
||||
new AggregationBucket("CVE-2024-7777", 9, 4, 9.1, false),
|
||||
]
|
||||
};
|
||||
|
||||
var bundleA = await builder.BuildAsync(aggregationA, consent);
|
||||
var bundleB = await builder.BuildAsync(aggregationB, consent);
|
||||
|
||||
Assert.Equal(bundleA.BundleDsseDigest, bundleB.BundleDsseDigest);
|
||||
Assert.Equal(bundleA.Envelope, bundleB.Envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_includes_aggregation_data_reference()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var aggregation = CreateAggregation();
|
||||
@@ -119,14 +185,89 @@ public sealed class FederatedTelemetryBundleBuilderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_sets_creation_timestamp()
|
||||
public async Task Build_sets_creation_timestamp_from_time_provider()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
var now = new DateTimeOffset(2026, 03, 04, 18, 0, 0, TimeSpan.Zero);
|
||||
var builder = CreateBuilder(timeProvider: new FakeTimeProvider(now));
|
||||
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var bundle = await builder.BuildAsync(CreateAggregation(), CreateConsentProof());
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
Assert.InRange(bundle.CreatedAt, before, after);
|
||||
Assert.Equal(now, bundle.CreatedAt);
|
||||
}
|
||||
|
||||
private static FederatedTelemetryBundleBuilder CreateBuilder(string siteId = "test-site", TimeProvider? timeProvider = null)
|
||||
{
|
||||
var options = CreateOptions("bundle-key", "bundle-secret", ("bundle-key", "bundle-secret"), siteId);
|
||||
return new FederatedTelemetryBundleBuilder(
|
||||
options,
|
||||
new HmacFederationDsseEnvelopeService(options),
|
||||
new HmacFederationDsseEnvelopeService(options),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private static IOptions<FederatedTelemetryOptions> CreateOptions(
|
||||
string signerKeyId,
|
||||
string signerSecret,
|
||||
(string KeyId, string Secret) trustedKey,
|
||||
string siteId = "test-site")
|
||||
{
|
||||
var options = new FederatedTelemetryOptions
|
||||
{
|
||||
SiteId = siteId,
|
||||
BundlePredicateType = "stella.ops/federatedTelemetry@v1",
|
||||
DsseSignerKeyId = signerKeyId,
|
||||
DsseSignerSecret = signerSecret
|
||||
};
|
||||
|
||||
options.DsseTrustedKeys.Clear();
|
||||
options.DsseTrustedKeys[trustedKey.KeyId] = trustedKey.Secret;
|
||||
return Options.Create(options);
|
||||
}
|
||||
|
||||
private static AggregationResult CreateAggregation()
|
||||
{
|
||||
return new AggregationResult(
|
||||
Buckets:
|
||||
[
|
||||
new AggregationBucket("CVE-2024-0001", 10, 5, 10.3, false),
|
||||
new AggregationBucket("CVE-2024-0002", 3, 2, 0, true),
|
||||
new AggregationBucket("CVE-2024-7777", 9, 4, 9.1, false),
|
||||
],
|
||||
TotalFacts: 22,
|
||||
SuppressedBuckets: 1,
|
||||
EpsilonSpent: 0.8,
|
||||
AggregatedAt: new DateTimeOffset(2026, 03, 04, 14, 30, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
private static ConsentProof CreateConsentProof()
|
||||
{
|
||||
return new ConsentProof(
|
||||
TenantId: "tenant-1",
|
||||
GrantedBy: "admin@example.com",
|
||||
GrantedAt: new DateTimeOffset(2026, 03, 04, 13, 0, 0, TimeSpan.Zero),
|
||||
ExpiresAt: new DateTimeOffset(2026, 03, 05, 13, 0, 0, TimeSpan.Zero),
|
||||
DsseDigest: "sha256:consent-proof",
|
||||
Envelope: new byte[] { 1, 2, 3 },
|
||||
SignerKeyId: "consent-key");
|
||||
}
|
||||
|
||||
private static byte[] TamperEnvelopePayload(byte[] envelope)
|
||||
{
|
||||
var document = JsonNode.Parse(envelope)!.AsObject();
|
||||
var payload = Convert.FromBase64String(document["payload"]!.GetValue<string>());
|
||||
payload[0] ^= 0x01;
|
||||
document["payload"] = Convert.ToBase64String(payload);
|
||||
return Encoding.UTF8.GetBytes(document.ToJsonString());
|
||||
}
|
||||
|
||||
private static byte[] TamperEnvelopeSignature(byte[] envelope)
|
||||
{
|
||||
var document = JsonNode.Parse(envelope)!.AsObject();
|
||||
var signatureNode = document["signatures"]!.AsArray()[0]!.AsObject();
|
||||
var signature = signatureNode["sig"]!.GetValue<string>();
|
||||
signatureNode["sig"] = signature[^1] == 'A'
|
||||
? $"{signature[..^1]}B"
|
||||
: $"{signature[..^1]}A";
|
||||
return Encoding.UTF8.GetBytes(document.ToJsonString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,12 @@ public sealed class FederationSyncAndIntelligenceTests
|
||||
Envelope: [0x10, 0x11, 0x12]));
|
||||
}
|
||||
|
||||
public Task<bool> VerifyProofAsync(ConsentProof proof, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task RevokeConsentAsync(string tenantId, string revokedBy, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ public sealed record FederatedBundle(
|
||||
string ConsentDsseDigest,
|
||||
string BundleDsseDigest,
|
||||
byte[] Envelope,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt,
|
||||
string? SignerKeyId = null);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user