Files
Rendezvous/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs
T
KyuubiYoru be732de7c9
quality-gate / quality (push) Failing after 1m1s
feat(server): add observability and operator controls (#16)
2026-07-16 13:22:16 +02:00

236 lines
9.7 KiB
C#

using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PrincipalCredentialTests
{
[Fact]
public void Base64UrlDecoderRejectsNonCanonicalTrailingBits()
{
Assert.True(Base64Url.TryDecode("AA", out byte[] canonical));
Assert.Equal(new byte[] { 0 }, canonical);
Assert.False(Base64Url.TryDecode("AB", out byte[] nonCanonical));
Assert.Empty(nonCanonical);
}
[Fact]
public void DedicatedAndPlayerGrantCredentialsRoundtripToDistinctPrincipals()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
DedicatedPublisherPrincipal dedicated = ProvisioningTestData.CreateDedicatedPublisher();
PlayerHostGrantPrincipal playerGrant = new(
"host-grant-7",
ProvisioningTestData.Now.AddMinutes(5),
dedicated.GameId,
dedicated.EnvironmentId,
dedicated.AllowedRegions);
CredentialValidationResult dedicatedResult = service.Validate(
service.Issue(dedicated, ProvisioningTestData.Now),
ProvisioningTestData.Now);
CredentialValidationResult grantResult = service.Validate(
service.Issue(playerGrant, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<DedicatedPublisherPrincipal>(dedicatedResult.Principal);
Assert.IsType<PlayerHostGrantPrincipal>(grantResult.Principal);
}
[Fact]
public void WrongIssuerAndAudienceAreRejectedAfterSignatureValidation()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService issuer = CreateService(keys);
string token = issuer.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
PrincipalCredentialService wrongIssuer = new(
"other-issuer",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
PrincipalCredentialService wrongAudience = new(
"final-factory-rendezvous",
"other-audience",
TimeSpan.FromSeconds(30),
keys);
Assert.Equal(
CredentialValidationError.IssuerMismatch,
wrongIssuer.Validate(token, ProvisioningTestData.Now).Error);
Assert.Equal(
CredentialValidationError.AudienceMismatch,
wrongAudience.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void ExpiryTamperingAndRevocationAreRejected()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = CreateService(keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(1)),
ProvisioningTestData.Now);
char replacement = token[^1] == 'A' ? 'B' : 'A';
string tampered = token[..^1] + replacement;
Assert.Equal(
CredentialValidationError.Expired,
service.Validate(token, ProvisioningTestData.Now.AddSeconds(91)).Error);
Assert.Equal(
CredentialValidationError.SignatureInvalid,
service.Validate(tampered, ProvisioningTestData.Now).Error);
Assert.True(keys.Revoke("key-1"));
Assert.True(keys.Revoke("key-1"));
Assert.Equal(
CredentialValidationError.KeyRevoked,
service.Validate(token, ProvisioningTestData.Now).Error);
}
[Fact]
public void KeyRotationHonorsOverlapAndRejectsRetiredKeys()
{
SigningKeyOptions oldKey = ProvisioningTestData.CreateKey(
"old-key",
"old-secret",
ProvisioningTestData.Now.AddHours(-1),
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddMinutes(60));
SigningKeyOptions newKey = ProvisioningTestData.CreateKey(
"new-key",
"new-secret",
ProvisioningTestData.Now.AddMinutes(10),
ProvisioningTestData.Now.AddHours(2),
ProvisioningTestData.Now.AddHours(3));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets(
"old-secret",
"new-secret");
using SigningKeyRing keys = SigningKeyRing.Create([oldKey, newKey], secrets);
PrincipalCredentialService service = CreateService(keys);
string oldToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(50)),
ProvisioningTestData.Now);
Assert.True(service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
Assert.Equal(
CredentialValidationError.KeyRetired,
service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(61)).Error);
string newToken = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddMinutes(90)),
ProvisioningTestData.Now.AddMinutes(20));
Assert.True(service.Validate(newToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid);
}
[Fact]
public void OperatorCredentialNeverBecomesAPublisherPrincipal()
{
SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey(
credentialKinds: [PrincipalCredentialKind.Operator],
gameId: null,
environmentId: null);
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[operatorKey],
secrets);
PrincipalCredentialService service = CreateService(keys);
OperatorPrincipal operatorPrincipal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(5),
new HashSet<OperatorPermission> { OperatorPermission.RotateKeys });
CredentialValidationResult result = service.Validate(
service.Issue(operatorPrincipal, ProvisioningTestData.Now),
ProvisioningTestData.Now);
Assert.IsType<OperatorPrincipal>(result.Principal);
Assert.IsNotAssignableFrom<IPublisherPrincipal>(result.Principal);
}
[Fact]
public void ConfiguredRevocationDoesNotRequireRetiredSecretMaterial()
{
SigningKeyOptions revoked = ProvisioningTestData.CreateKey(
"revoked-key",
"removed-secret",
revoked: true);
SigningKeyOptions active = ProvisioningTestData.CreateKey(
"active-key",
"active-secret");
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("active-secret");
using SigningKeyRing keys = SigningKeyRing.Create([revoked, active], secrets);
Assert.Equal(
VerificationKeyLookup.Revoked,
keys.FindVerificationKey("revoked-key", ProvisioningTestData.Now, out _));
Assert.True(keys.TryGetSigningKey(
ProvisioningTestData.Now,
PrincipalCredentialKind.DedicatedPublisher,
"space-game",
"production",
out SigningKey? signingKey));
Assert.Equal("active-key", signingKey?.KeyId);
}
[Fact]
public void SigningKeyAuthorityRejectsCrossGameClaimsEvenWithAValidSignature()
{
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
Assert.Equal(
VerificationKeyLookup.Available,
keys.FindVerificationKey("key-1", ProvisioningTestData.Now, out SigningKey? signingKey));
Assert.NotNull(signingKey);
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
Subject = "malicious-grant-issuer",
Kind = PrincipalCredentialKind.PlayerHostGrant,
GameId = "unscouted",
EnvironmentId = "production",
Regions = ["eu-central"],
IssuedAtUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = ProvisioningTestData.Now.AddMinutes(5).ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
string encodedPayload = Base64Url.Encode(
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"rv1.key-1.{encodedPayload}";
string token = $"{signedContent}.{Base64Url.Encode(signingKey.Sign(signedContent))}";
CredentialValidationResult result = CreateService(keys).Validate(
token,
ProvisioningTestData.Now);
Assert.Equal(CredentialValidationError.KeyScopeMismatch, result.Error);
Assert.Null(result.Principal);
}
private static PrincipalCredentialService CreateService(SigningKeyRing keys) => new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
}