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(dedicatedResult.Principal); Assert.IsType(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.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.RotateKeys }); CredentialValidationResult result = service.Validate( service.Issue(operatorPrincipal, ProvisioningTestData.Now), ProvisioningTestData.Now); Assert.IsType(result.Principal); Assert.IsNotAssignableFrom(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); }