feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s
quality-gate / quality (push) Successful in 50s
Closes #5
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Provisioning;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Provisioning;
|
||||
|
||||
public sealed class PrincipalCredentialTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
Reference in New Issue
Block a user