235 lines
9.7 KiB
C#
235 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.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);
|
|
}
|