246 lines
8.6 KiB
C#
246 lines
8.6 KiB
C#
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;
|
|
|
|
public sealed class ConsentManagerTests
|
|
{
|
|
[Fact]
|
|
public async Task Default_consent_state_is_not_granted()
|
|
{
|
|
var manager = CreateManager();
|
|
|
|
var state = await manager.GetConsentStateAsync("tenant-1");
|
|
|
|
Assert.False(state.Granted);
|
|
Assert.Null(state.GrantedBy);
|
|
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_and_signer_metadata()
|
|
{
|
|
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, 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 = CreateManager();
|
|
|
|
await manager.GrantConsentAsync("tenant-1", "admin@example.com");
|
|
await manager.RevokeConsentAsync("tenant-1", "admin@example.com");
|
|
|
|
var state = await manager.GetConsentStateAsync("tenant-1");
|
|
Assert.False(state.Granted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Multiple_tenants_independent()
|
|
{
|
|
var manager = CreateManager();
|
|
|
|
await manager.GrantConsentAsync("tenant-1", "admin1@example.com");
|
|
await manager.GrantConsentAsync("tenant-2", "admin2@example.com");
|
|
|
|
await manager.RevokeConsentAsync("tenant-1", "admin1@example.com");
|
|
|
|
var state1 = await manager.GetConsentStateAsync("tenant-1");
|
|
var state2 = await manager.GetConsentStateAsync("tenant-2");
|
|
|
|
Assert.False(state1.Granted);
|
|
Assert.True(state2.Granted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Grant_overwrites_previous_consent()
|
|
{
|
|
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);
|
|
|
|
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>
|
|
/// Simple fake TimeProvider for testing time-dependent behavior.
|
|
/// </summary>
|
|
internal sealed class FakeTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _utcNow;
|
|
|
|
public FakeTimeProvider(DateTimeOffset start)
|
|
{
|
|
_utcNow = start;
|
|
}
|
|
|
|
public override DateTimeOffset GetUtcNow() => _utcNow;
|
|
|
|
public void Advance(TimeSpan duration) => _utcNow += duration;
|
|
|
|
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
|
}
|