feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s

Closes #5
This commit is contained in:
KyuubiYoru
2026-07-16 05:13:35 +02:00
parent 69c8b2d2bc
commit 47382ddadc
21 changed files with 2294 additions and 6 deletions
+6
View File
@@ -81,6 +81,8 @@ The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md). threat model are indexed in [the architecture documentation](docs/architecture/README.md).
The frozen v1 wire surface is documented in the The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
## Development ## Development
@@ -97,5 +99,9 @@ dotnet test Rendezvous.slnx --configuration Release --no-build
Run the bootstrap server with Run the bootstrap server with
`dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds `dotnet run --project src/FinalFactory.Rendezvous.Server`. It serves HTTP health endpoints and binds
the configured UDP mediator port; both stop through normal host cancellation. the configured UDP mediator port; both stop through normal host cancellation.
The launch profile uses an ephemeral development-only signing key. Production
startup fails closed until externally supplied game policies and `env:` signing
key references resolve to valid key material; no reusable game secret is stored
in this repository or the public Client package.
The project dependency rules and supported runtime choices are documented in The project dependency rules and supported runtime choices are documented in
[project and dependency boundaries](docs/architecture/project-boundaries.md). [project and dependency boundaries](docs/architecture/project-boundaries.md).
+1
View File
@@ -9,6 +9,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [Threat model](../security/threat-model.md) - [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md) - [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md) - [Versioned HTTP and UDP contracts](../contracts/README.md)
- [Game provisioning and signing-key lifecycle](../security/provisioning.md)
These decisions intentionally leave gameplay authority, player identity, These decisions intentionally leave gameplay authority, player identity,
simulation, persistence, social features, skill matchmaking, and gameplay simulation, persistence, social features, skill matchmaking, and gameplay
+84
View File
@@ -0,0 +1,84 @@
# Game provisioning and signing-key lifecycle
Tracking: #5
Rendezvous treats game and environment scope as provisioned policy, not caller
input. Production starts only when it can build an enabled policy registry and
load at least one currently active signing key from an external secret provider.
Unknown and disabled scopes fail closed.
## Policy boundary
Each `GamePolicy` fixes the allowed:
- game/environment pair and regions;
- exact gameplay protocol versions;
- publisher trust and listing visibility modes;
- metadata keys, required keys, per-value limits, total bytes, and key count;
- listing, anonymous-host, and active-attempt quotas; and
- dedicated fallback feature policy.
Publisher authorization first authenticates a typed principal, then derives the
authoritative game/environment from that principal. Request fields are compared
for mismatch detection but never replace the authenticated scope. Dedicated
workloads, short-lived player-host grants, anonymous unlisted publishers, and
operators are distinct principal types. Operator credentials cannot be used as
publisher credentials, and anonymous publishers cannot escalate to public
visibility.
## Signed credentials
Signed principal credentials use the compact form
`rv1.<key-id>.<base64url-payload>.<base64url-HMAC-SHA256>`. The signed payload
contains version, issuer, audience, subject, principal kind, bounded scope,
issued/not-before/expiry times, and a random nonce. It contains no signing key,
reusable publisher secret, player identity, or gameplay state.
Validation is deliberately ordered and bounded:
1. enforce the v1 opaque-credential length and four-segment grammar;
2. resolve a known, non-revoked key in its verification window;
3. compare the HMAC in fixed time;
4. parse canonical bounded JSON;
5. require exact version, issuer, and audience;
6. enforce clock skew, expiry, key lifetime, principal kind, and scope shape.
Failures return typed internal reasons without echoing the credential. Logs and
metrics must record only allowlisted tenant/principal/result dimensions; token,
key, secret-reference value, and raw key material are excluded.
## Rotation and revocation
A key is bound either to operator credentials only or to allowed publisher
credential kinds for exactly one game/environment. The verifier checks this
authority after the signature, so even a compromised game grant issuer cannot
mint a valid cross-game or operator credential.
A key also has three times: `NotBefore`, `SignUntil`, and `VerifyUntil`. Issuance
picks the newest authorized non-revoked key inside its signing window. Older credentials continue
to verify until the old key's verification window ends, providing an explicit
overlap. After `VerifyUntil` they fail as retired. Configuration revocation and
runtime revocation both reject immediately. A configured revoked key retains
only its public key ID/lifecycle metadata and does not require retired secret
material to remain available.
Key IDs are non-secret base64url identifiers. Secret references are resolved
through `ISecretProvider`; production supports `env:<VARIABLE>` references and
the interface is replaceable by a deployment-specific vault/KMS adapter. The
committed development profile uses an in-memory random key identified by a
`development:ephemeral/...` reference. It never writes key material to disk and
all credentials become invalid when the process exits.
## Production configuration
`Rendezvous:Provisioning` supplies issuer, audience, clock skew, signing-key
descriptors, and game policies. A production key reference such as
`env:RENDEZVOUS_SIGNING_KEY_2026_01` expects that environment variable to hold at
least 32 random bytes encoded as base64. Missing, malformed, short, inactive, or
duplicate keys stop startup with a key-ID-only diagnostic. No game-wide secret
belongs in `appsettings`, source control, examples, the Client package, URLs,
responses, logs, metrics, exceptions, or diagnostic dumps.
Readiness becomes true only after provisioning and UDP startup both succeed.
OpenAPI generation uses a pinned build-only host and does not start listeners or
bypass provisioning in a deployed server process.
+31 -6
View File
@@ -1,15 +1,15 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Transport; using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.OpenApi; using Microsoft.OpenApi;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
bool isOpenApiGeneration = Environment.GetCommandLineArgs().Any(static argument => bool isOpenApiGeneration = string.Equals(
string.Equals( System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name,
Path.GetFileName(argument), "GetDocument.Insider",
"dotnet-getdocument.dll", StringComparison.Ordinal);
StringComparison.OrdinalIgnoreCase));
builder.Services.AddOpenApi("v1", static options => builder.Services.AddOpenApi("v1", static options =>
options.AddSchemaTransformer(static (schema, context, cancellationToken) => options.AddSchemaTransformer(static (schema, context, cancellationToken) =>
@@ -34,6 +34,30 @@ builder.Services.AddOpenApi("v1", static options =>
})); }));
builder.Services.ConfigureHttpJsonOptions(static options => builder.Services.ConfigureHttpJsonOptions(static options =>
ContractJson.Configure(options.SerializerOptions)); ContractJson.Configure(options.SerializerOptions));
if (isOpenApiGeneration)
{
builder.Services.AddSingleton(new ProvisioningReadiness(false));
}
else
{
ProvisioningOptions provisioningOptions = builder.Configuration
.GetSection(ProvisioningOptions.SectionName)
.Get<ProvisioningOptions>() ?? new ProvisioningOptions();
ISecretProvider secretProvider = builder.Environment.IsDevelopment()
? new EphemeralDevelopmentSecretProvider()
: new EnvironmentSecretProvider();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
provisioningOptions,
secretProvider,
DateTimeOffset.UtcNow);
builder.Services.AddSingleton(provisioning);
builder.Services.AddSingleton(provisioning.Policies);
builder.Services.AddSingleton(provisioning.Credentials);
builder.Services.AddSingleton(provisioning.PublisherAuthorization);
builder.Services.AddSingleton(new ProvisioningReadiness(true));
}
builder.Services builder.Services
.AddOptions<UdpMediatorOptions>() .AddOptions<UdpMediatorOptions>()
.BindConfiguration(UdpMediatorOptions.SectionName) .BindConfiguration(UdpMediatorOptions.SectionName)
@@ -61,7 +85,8 @@ app.MapGet(
.WithTags("Health"); .WithTags("Health");
app.MapGet( app.MapGet(
"/health/ready", "/health/ready",
static (UdpMediatorService mediator) => mediator.LocalEndpoint is null static (UdpMediatorService mediator, ProvisioningReadiness provisioning) =>
mediator.LocalEndpoint is null || !provisioning.IsReady
? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable)
: Results.Ok(new HealthResponse { Status = "ready" })) : Results.Ok(new HealthResponse { Status = "ready" }))
.Produces<HealthResponse>() .Produces<HealthResponse>()
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"development": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://127.0.0.1:5096",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,80 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicy
{
private readonly HashSet<uint> _protocolVersions;
private readonly HashSet<RegionId> _regions;
private readonly HashSet<ListingVisibility> _visibilityModes;
private readonly HashSet<PublisherTrustMode> _publisherTrustModes;
private readonly Dictionary<string, int> _metadataValueMaxBytes;
private readonly HashSet<string> _requiredMetadataKeys;
public GamePolicy(GamePolicyOptions options)
{
GameId = new GameId(options.GameId);
EnvironmentId = new EnvironmentId(options.EnvironmentId);
Enabled = options.Enabled;
_protocolVersions = new HashSet<uint>(options.ProtocolVersions);
_regions = options.Regions.Select(static region => new RegionId(region)).ToHashSet();
_visibilityModes = new HashSet<ListingVisibility>(options.VisibilityModes);
_publisherTrustModes = new HashSet<PublisherTrustMode>(options.PublisherTrustModes);
_metadataValueMaxBytes = new Dictionary<string, int>(
options.MetadataValueMaxBytes,
StringComparer.Ordinal);
_requiredMetadataKeys = new HashSet<string>(
options.RequiredMetadataKeys,
StringComparer.Ordinal);
MetadataMaxBytes = options.MetadataMaxBytes;
MetadataMaxKeys = options.MetadataMaxKeys;
MaxListingsPerPrincipal = options.MaxListingsPerPrincipal;
MaxAnonymousListingsPerAddress = options.MaxAnonymousListingsPerAddress;
MaxActiveJoinAttempts = options.MaxActiveJoinAttempts;
FallbackPolicy = options.FallbackPolicy;
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public bool Enabled { get; }
public int MetadataMaxBytes { get; }
public int MetadataMaxKeys { get; }
public int MaxListingsPerPrincipal { get; }
public int MaxAnonymousListingsPerAddress { get; }
public int MaxActiveJoinAttempts { get; }
public FallbackPolicyMode FallbackPolicy { get; }
public bool AllowsProtocol(uint protocolVersion) =>
_protocolVersions.Contains(protocolVersion);
public bool AllowsRegion(RegionId regionId) => _regions.Contains(regionId);
public bool AllowsVisibility(ListingVisibility visibility) =>
_visibilityModes.Contains(visibility);
public bool AllowsPublisherTrust(PublisherTrustMode trustMode) =>
_publisherTrustModes.Contains(trustMode);
public bool AllowsMetadata(IReadOnlyDictionary<string, string>? metadata)
{
if (!ContractValidation.IsMetadataValid(metadata)
|| metadata!.Count > MetadataMaxKeys
|| !_requiredMetadataKeys.IsSubsetOf(metadata.Keys))
{
return false;
}
foreach (KeyValuePair<string, string> item in metadata)
{
if (!_metadataValueMaxBytes.TryGetValue(item.Key, out int maximumBytes)
|| !ContractValidation.IsUtf8LengthWithin(item.Value, maximumBytes))
{
return false;
}
}
return JsonSerializer.SerializeToUtf8Bytes(metadata, ContractJson.Options).Length
<= MetadataMaxBytes;
}
}
@@ -0,0 +1,118 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class GamePolicyRegistry
{
private readonly Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> _policies;
private GamePolicyRegistry(
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies) =>
_policies = policies;
public bool HasEnabledPolicies => _policies.Values.Any(static policy => policy.Enabled);
public IEnumerable<GamePolicy> EnabledPolicies =>
_policies.Values.Where(static policy => policy.Enabled);
public static GamePolicyRegistry Create(IEnumerable<GamePolicyOptions> options)
{
GamePolicyOptions[] configuredPolicies = options.ToArray();
if (configuredPolicies.Length > ProvisioningLimits.MaxGamePolicies)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxGamePolicies} game policies may be configured.");
}
Dictionary<(GameId Game, EnvironmentId Environment), GamePolicy> policies = [];
foreach (GamePolicyOptions policyOptions in configuredPolicies)
{
Validate(policyOptions);
GamePolicy policy = new(policyOptions);
if (!policies.TryAdd((policy.GameId, policy.EnvironmentId), policy))
{
throw new ProvisioningConfigurationException(
$"Duplicate game/environment policy: {policy.GameId}/{policy.EnvironmentId}.");
}
}
return new GamePolicyRegistry(policies);
}
public bool TryGet(
GameId gameId,
EnvironmentId environmentId,
out GamePolicy? policy)
{
if (_policies.TryGetValue((gameId, environmentId), out GamePolicy? candidate)
&& candidate.Enabled)
{
policy = candidate;
return true;
}
policy = null;
return false;
}
private static void Validate(GamePolicyOptions options)
{
if (!GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
"Game policies require valid game and environment IDs.");
}
if (options.ProtocolVersions.Count is 0 or > ProvisioningLimits.MaxProtocolVersionsPerPolicy
|| options.ProtocolVersions.Contains(0)
|| options.ProtocolVersions.Count != options.ProtocolVersions.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique non-zero protocol versions.");
}
if (options.Regions.Count is 0 or > ProvisioningLimits.MaxRegionsPerPolicy
|| options.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| options.Regions.Count != options.Regions.Distinct(StringComparer.Ordinal).Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires unique valid regions.");
}
if (options.VisibilityModes.Count == 0
|| options.VisibilityModes.Any(static mode => !Enum.IsDefined(mode))
|| options.VisibilityModes.Count != options.VisibilityModes.Distinct().Count()
|| options.PublisherTrustModes.Count == 0
|| options.PublisherTrustModes.Any(static mode => !Enum.IsDefined(mode))
|| options.PublisherTrustModes.Count != options.PublisherTrustModes.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} requires valid visibility and trust modes.");
}
if (options.MetadataMaxBytes is < 2 or > ContractLimits.MetadataMaxBytes
|| options.MetadataMaxKeys is < 0 or > ContractLimits.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Count > options.MetadataMaxKeys
|| options.MetadataValueMaxBytes.Any(static item =>
string.IsNullOrWhiteSpace(item.Key)
|| !ContractValidation.IsUtf8LengthWithin(item.Key, ContractLimits.MetadataKeyMaxBytes)
|| item.Value is < 0 or > ContractLimits.MetadataValueMaxBytes)
|| options.RequiredMetadataKeys.Any(key =>
!options.MetadataValueMaxBytes.ContainsKey(key)))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has an invalid metadata schema.");
}
if (options.MaxListingsPerPrincipal is < 1 or > ProvisioningLimits.MaxListingsPerPrincipal
|| options.MaxAnonymousListingsPerAddress < 0
|| options.MaxAnonymousListingsPerAddress > options.MaxListingsPerPrincipal
|| options.MaxActiveJoinAttempts is < 1
or > ProvisioningLimits.MaxActiveJoinAttemptsPerPolicy
|| !Enum.IsDefined(options.FallbackPolicy))
{
throw new ProvisioningConfigurationException(
$"Policy {options.GameId}/{options.EnvironmentId} has invalid quotas or fallback policy.");
}
}
}
@@ -0,0 +1,451 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PrincipalCredentialService
{
private const string TokenPrefix = "rv1";
private readonly string _issuer;
private readonly string _audience;
private readonly TimeSpan _clockSkew;
private readonly SigningKeyRing _keyRing;
public PrincipalCredentialService(
string issuer,
string audience,
TimeSpan clockSkew,
SigningKeyRing keyRing)
{
if (!IsSafeAuthority(issuer) || !IsSafeAuthority(audience))
{
throw new ProvisioningConfigurationException(
"Credential issuer and audience are required.");
}
if (clockSkew < TimeSpan.Zero || clockSkew > TimeSpan.FromSeconds(30))
{
throw new ProvisioningConfigurationException(
"Credential clock skew must be between zero and 30 seconds.");
}
_issuer = issuer;
_audience = audience;
_clockSkew = clockSkew;
_keyRing = keyRing;
}
public string Issue(AuthenticatedPrincipal principal, DateTimeOffset now)
{
if (!IsSafeSubject(principal.Subject))
{
throw new ArgumentException(
"Principal subjects must be 1128 visible ASCII characters.",
nameof(principal));
}
if (principal.ExpiresAt <= now)
{
throw new ArgumentException("Cannot issue an already-expired principal.", nameof(principal));
}
CredentialPayload payload = CreatePayload(principal, now);
if (CreatePrincipal(payload, principal.ExpiresAt) is null)
{
throw new ArgumentException(
"The principal contains an invalid kind or scope.",
nameof(principal));
}
if (!_keyRing.TryGetSigningKey(
now,
payload.Kind,
payload.GameId,
payload.EnvironmentId,
out SigningKey? signingKey)
|| signingKey is null)
{
throw new InvalidOperationException("No active signing key is available.");
}
if (principal.ExpiresAt > signingKey.VerifyUntil)
{
throw new InvalidOperationException(
"The active key verification window is shorter than the credential lifetime.");
}
string encodedPayload = Base64Url.Encode(
JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options));
string signedContent = $"{TokenPrefix}.{signingKey.KeyId}.{encodedPayload}";
string signature = Base64Url.Encode(signingKey.Sign(signedContent));
string token = $"{signedContent}.{signature}";
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
throw new InvalidOperationException("The signed credential exceeds the v1 size limit.");
}
return token;
}
public CredentialValidationResult Validate(string? token, DateTimeOffset now)
{
if (!ContractValidation.IsOpaqueHttpCredentialValid(token))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string[] segments = token!.Split('.');
if (segments.Length != 4
|| !string.Equals(segments[0], TokenPrefix, StringComparison.Ordinal)
|| segments[1].Length == 0)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
VerificationKeyLookup lookup = _keyRing.FindVerificationKey(
segments[1],
now,
out SigningKey? signingKey);
if (lookup != VerificationKeyLookup.Available || signingKey is null)
{
return CredentialValidationResult.Invalid(lookup switch
{
VerificationKeyLookup.Revoked => CredentialValidationError.KeyRevoked,
VerificationKeyLookup.NotYetValid => CredentialValidationError.KeyNotYetValid,
VerificationKeyLookup.Retired => CredentialValidationError.KeyRetired,
_ => CredentialValidationError.UnknownKey,
});
}
if (!Base64Url.TryDecode(segments[3], out byte[]? suppliedSignature))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
string signedContent = $"{segments[0]}.{segments[1]}.{segments[2]}";
byte[] expectedSignature = signingKey.Sign(signedContent);
bool signatureMatches = suppliedSignature.Length == expectedSignature.Length
&& CryptographicOperations.FixedTimeEquals(suppliedSignature, expectedSignature);
CryptographicOperations.ZeroMemory(suppliedSignature);
CryptographicOperations.ZeroMemory(expectedSignature);
if (!signatureMatches)
{
return CredentialValidationResult.Invalid(CredentialValidationError.SignatureInvalid);
}
if (!Base64Url.TryDecode(segments[2], out byte[]? encodedPayload))
{
return CredentialValidationResult.Invalid(CredentialValidationError.Malformed);
}
CredentialPayload? payload;
try
{
payload = JsonSerializer.Deserialize<CredentialPayload>(
encodedPayload,
ContractJson.Options);
}
catch (JsonException)
{
payload = null;
}
finally
{
CryptographicOperations.ZeroMemory(encodedPayload);
}
if (payload is null || payload.Version != ContractLimits.ContractVersion)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (!string.Equals(payload.Issuer, _issuer, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.IssuerMismatch);
}
if (!string.Equals(payload.Audience, _audience, StringComparison.Ordinal))
{
return CredentialValidationResult.Invalid(CredentialValidationError.AudienceMismatch);
}
if (!signingKey.Authorizes(payload.Kind, payload.GameId, payload.EnvironmentId))
{
return CredentialValidationResult.Invalid(CredentialValidationError.KeyScopeMismatch);
}
DateTimeOffset issuedAt;
DateTimeOffset notBefore;
DateTimeOffset expiresAt;
try
{
issuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAtUnixSeconds);
notBefore = DateTimeOffset.FromUnixTimeSeconds(payload.NotBeforeUnixSeconds);
expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAtUnixSeconds);
}
catch (ArgumentOutOfRangeException)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
if (issuedAt > now + _clockSkew || notBefore > now + _clockSkew)
{
return CredentialValidationResult.Invalid(CredentialValidationError.NotYetValid);
}
if (expiresAt <= now - _clockSkew || expiresAt <= notBefore)
{
return CredentialValidationResult.Invalid(CredentialValidationError.Expired);
}
if (issuedAt > notBefore
|| issuedAt < signingKey.NotBefore - _clockSkew
|| expiresAt > signingKey.VerifyUntil)
{
return CredentialValidationResult.Invalid(CredentialValidationError.PayloadInvalid);
}
AuthenticatedPrincipal? principal = CreatePrincipal(payload, expiresAt);
return principal is null
? CredentialValidationResult.Invalid(CredentialValidationError.ScopeInvalid)
: CredentialValidationResult.Valid(principal);
}
public override string ToString() => "[PrincipalCredentialService: key material and credentials redacted]";
private CredentialPayload CreatePayload(AuthenticatedPrincipal principal, DateTimeOffset now)
{
CredentialPayload payload = new()
{
Version = ContractLimits.ContractVersion,
Issuer = _issuer,
Audience = _audience,
Subject = principal.Subject,
IssuedAtUnixSeconds = now.ToUnixTimeSeconds(),
NotBeforeUnixSeconds = now.ToUnixTimeSeconds(),
ExpiresAtUnixSeconds = principal.ExpiresAt.ToUnixTimeSeconds(),
Nonce = Guid.NewGuid().ToString("N"),
};
switch (principal)
{
case DedicatedPublisherPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.DedicatedPublisher);
break;
case PlayerHostGrantPrincipal publisher:
SetPublisherPayload(payload, publisher, PrincipalCredentialKind.PlayerHostGrant);
break;
case OperatorPrincipal operatorPrincipal:
payload.Kind = PrincipalCredentialKind.Operator;
payload.Permissions = operatorPrincipal.Permissions.Order().ToList();
break;
default:
throw new ArgumentException(
"Anonymous principals cannot receive reusable signed credentials.",
nameof(principal));
}
return payload;
}
private static void SetPublisherPayload(
CredentialPayload payload,
IPublisherPrincipal publisher,
PrincipalCredentialKind kind)
{
payload.Kind = kind;
payload.GameId = publisher.GameId.ToString();
payload.EnvironmentId = publisher.EnvironmentId.ToString();
payload.Regions = publisher.AllowedRegions
.Select(static region => region.ToString())
.Order(StringComparer.Ordinal)
.ToList();
}
private static AuthenticatedPrincipal? CreatePrincipal(
CredentialPayload payload,
DateTimeOffset expiresAt)
{
if (!IsSafeSubject(payload.Subject)
|| !Guid.TryParseExact(payload.Nonce, "N", out Guid nonce)
|| nonce == Guid.Empty)
{
return null;
}
if (payload.Kind == PrincipalCredentialKind.Operator)
{
if (payload.GameId is not null
|| payload.EnvironmentId is not null
|| payload.Regions.Count != 0
|| payload.Permissions.Count == 0
|| payload.Permissions.Any(static permission => !Enum.IsDefined(permission))
|| payload.Permissions.Count != payload.Permissions.Distinct().Count())
{
return null;
}
return new OperatorPrincipal(
payload.Subject,
expiresAt,
new HashSet<OperatorPermission>(payload.Permissions));
}
if (!GameId.TryParse(payload.GameId, out GameId gameId)
|| !EnvironmentId.TryParse(payload.EnvironmentId, out EnvironmentId environmentId)
|| payload.Regions.Count == 0
|| payload.Regions.Any(static region => !RegionId.TryParse(region, out _))
|| payload.Regions.Count != payload.Regions.Distinct(StringComparer.Ordinal).Count()
|| payload.Permissions.Count != 0)
{
return null;
}
HashSet<RegionId> regions = payload.Regions.Select(static region => new RegionId(region)).ToHashSet();
return payload.Kind switch
{
PrincipalCredentialKind.DedicatedPublisher => new DedicatedPublisherPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
PrincipalCredentialKind.PlayerHostGrant => new PlayerHostGrantPrincipal(
payload.Subject,
expiresAt,
gameId,
environmentId,
regions),
_ => null,
};
}
private static bool IsSafeSubject(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
private static bool IsSafeAuthority(string? value) =>
value is not null
&& value.Length is > 0 and <= 128
&& value.All(static character => character is >= '!' and <= '~');
}
internal sealed class CredentialPayload
{
[JsonRequired]
public int Version { get; set; }
[JsonRequired]
public string Issuer { get; set; } = string.Empty;
[JsonRequired]
public string Audience { get; set; } = string.Empty;
[JsonRequired]
public string Subject { get; set; } = string.Empty;
[JsonRequired]
public PrincipalCredentialKind Kind { get; set; }
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public List<string> Regions { get; set; } = [];
public List<OperatorPermission> Permissions { get; set; } = [];
[JsonRequired]
public long IssuedAtUnixSeconds { get; set; }
[JsonRequired]
public long NotBeforeUnixSeconds { get; set; }
[JsonRequired]
public long ExpiresAtUnixSeconds { get; set; }
[JsonRequired]
public string Nonce { get; set; } = string.Empty;
}
internal readonly record struct CredentialValidationResult(
bool IsValid,
CredentialValidationError Error,
AuthenticatedPrincipal? Principal)
{
public static CredentialValidationResult Valid(AuthenticatedPrincipal principal) =>
new(true, CredentialValidationError.None, principal);
public static CredentialValidationResult Invalid(CredentialValidationError error) =>
new(false, error, null);
public override string ToString() => $"[CredentialValidation: {Error}, credential redacted]";
}
internal enum CredentialValidationError
{
None = 0,
Malformed = 1,
UnknownKey = 2,
KeyRevoked = 3,
KeyNotYetValid = 4,
KeyRetired = 5,
SignatureInvalid = 6,
PayloadInvalid = 7,
IssuerMismatch = 8,
AudienceMismatch = 9,
KeyScopeMismatch = 10,
NotYetValid = 11,
Expired = 12,
ScopeInvalid = 13,
}
internal static class Base64Url
{
public static string Encode(ReadOnlySpan<byte> bytes) => Convert
.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
public static bool TryDecode(string value, out byte[] bytes)
{
bytes = [];
if (string.IsNullOrEmpty(value)
|| value.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
return false;
}
string padded = value.Replace('-', '+').Replace('_', '/');
int remainder = padded.Length % 4;
if (remainder == 1)
{
return false;
}
padded += remainder switch
{
0 => string.Empty,
2 => "==",
3 => "=",
_ => string.Empty,
};
try
{
bytes = Convert.FromBase64String(padded);
return true;
}
catch (FormatException)
{
return false;
}
}
}
@@ -0,0 +1,107 @@
using System.Collections.Frozen;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal abstract record AuthenticatedPrincipal(
string Subject,
DateTimeOffset ExpiresAt);
internal interface IPublisherPrincipal
{
string Subject { get; }
DateTimeOffset ExpiresAt { get; }
GameId GameId { get; }
EnvironmentId EnvironmentId { get; }
PublisherTrustMode TrustMode { get; }
IReadOnlySet<RegionId> AllowedRegions { get; }
}
internal sealed record DedicatedPublisherPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public DedicatedPublisherPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.ManagedDedicated;
}
internal sealed record PlayerHostGrantPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public PlayerHostGrantPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.PlayerGrant;
}
internal sealed record AnonymousUnlistedPrincipal : AuthenticatedPrincipal, IPublisherPrincipal
{
public AnonymousUnlistedPrincipal(
string subject,
DateTimeOffset expiresAt,
GameId gameId,
EnvironmentId environmentId,
IEnumerable<RegionId> allowedRegions)
: base(subject, expiresAt)
{
GameId = gameId;
EnvironmentId = environmentId;
AllowedRegions = allowedRegions.ToFrozenSet();
}
public GameId GameId { get; }
public EnvironmentId EnvironmentId { get; }
public IReadOnlySet<RegionId> AllowedRegions { get; }
public PublisherTrustMode TrustMode => PublisherTrustMode.AnonymousUnlisted;
}
internal sealed record OperatorPrincipal : AuthenticatedPrincipal
{
public OperatorPrincipal(
string subject,
DateTimeOffset expiresAt,
IEnumerable<OperatorPermission> permissions)
: base(subject, expiresAt) => Permissions = permissions.ToFrozenSet();
public IReadOnlySet<OperatorPermission> Permissions { get; }
}
internal enum OperatorPermission
{
ReadPolicy = 1,
ManagePolicy = 2,
RevokePublisher = 3,
RotateKeys = 4,
}
internal enum PrincipalCredentialKind
{
DedicatedPublisher = 1,
PlayerHostGrant = 2,
Operator = 3,
}
@@ -0,0 +1,71 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningOptions
{
public const string SectionName = "Rendezvous:Provisioning";
public string Issuer { get; set; } = string.Empty;
public string Audience { get; set; } = string.Empty;
public int ClockSkewSeconds { get; set; } = 30;
public List<SigningKeyOptions> SigningKeys { get; set; } = [];
public List<GamePolicyOptions> Games { get; set; } = [];
}
internal sealed class SigningKeyOptions
{
public string KeyId { get; set; } = string.Empty;
public string SecretReference { get; set; } = string.Empty;
public List<PrincipalCredentialKind> CredentialKinds { get; set; } = [];
public string? GameId { get; set; }
public string? EnvironmentId { get; set; }
public DateTimeOffset NotBefore { get; set; }
public DateTimeOffset SignUntil { get; set; }
public DateTimeOffset VerifyUntil { get; set; }
public bool Revoked { get; set; }
}
internal sealed class GamePolicyOptions
{
public string GameId { get; set; } = string.Empty;
public string EnvironmentId { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public List<uint> ProtocolVersions { get; set; } = [];
public List<string> Regions { get; set; } = [];
public List<ListingVisibility> VisibilityModes { get; set; } = [];
public List<PublisherTrustMode> PublisherTrustModes { get; set; } = [];
public Dictionary<string, int> MetadataValueMaxBytes { get; set; } =
new(StringComparer.Ordinal);
public List<string> RequiredMetadataKeys { get; set; } = [];
public int MetadataMaxBytes { get; set; } = ContractLimits.MetadataMaxBytes;
public int MetadataMaxKeys { get; set; } = ContractLimits.MetadataMaxKeys;
public int MaxListingsPerPrincipal { get; set; } = 100;
public int MaxAnonymousListingsPerAddress { get; set; } = 2;
public int MaxActiveJoinAttempts { get; set; } = 1_000;
public FallbackPolicyMode FallbackPolicy { get; set; }
}
internal enum FallbackPolicyMode
{
Disabled = 0,
DedicatedEndpointAllowed = 1,
}
internal static class ProvisioningLimits
{
public const int MaxGamePolicies = 1_024;
public const int MaxSigningKeys = 128;
public const int MaxProtocolVersionsPerPolicy = 64;
public const int MaxRegionsPerPolicy = 32;
public const int MaxListingsPerPrincipal = 25_000;
public const int MaxActiveJoinAttemptsPerPolicy = 10_000;
}
internal sealed class ProvisioningConfigurationException : Exception
{
public ProvisioningConfigurationException(string message)
: base(message)
{
}
}
@@ -0,0 +1,120 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class ProvisioningRuntime : IDisposable
{
private readonly IDisposable? _secretProviderLifetime;
private ProvisioningRuntime(
GamePolicyRegistry policies,
SigningKeyRing signingKeys,
PrincipalCredentialService credentials,
PublisherAuthorizationService publisherAuthorization,
IDisposable? secretProviderLifetime)
{
Policies = policies;
SigningKeys = signingKeys;
Credentials = credentials;
PublisherAuthorization = publisherAuthorization;
_secretProviderLifetime = secretProviderLifetime;
}
public GamePolicyRegistry Policies { get; }
public SigningKeyRing SigningKeys { get; }
public PrincipalCredentialService Credentials { get; }
public PublisherAuthorizationService PublisherAuthorization { get; }
public static ProvisioningRuntime Create(
ProvisioningOptions options,
ISecretProvider secretProvider,
DateTimeOffset now)
{
try
{
SigningKeyRing signingKeys = SigningKeyRing.Create(options.SigningKeys, secretProvider);
try
{
if (!signingKeys.HasKeys
|| !signingKeys.HasActiveSigningKey(now))
{
throw new ProvisioningConfigurationException(
"At least one active signing key with available production key material is required.");
}
GamePolicyRegistry policies = GamePolicyRegistry.Create(options.Games);
if (!policies.HasEnabledPolicies)
{
throw new ProvisioningConfigurationException(
"At least one enabled game/environment policy is required.");
}
foreach (GamePolicy policy in policies.EnabledPolicies)
{
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.ManagedDedicated,
PrincipalCredentialKind.DedicatedPublisher,
now);
RequirePublisherKey(
signingKeys,
policy,
PublisherTrustMode.PlayerGrant,
PrincipalCredentialKind.PlayerHostGrant,
now);
}
PrincipalCredentialService credentials = new(
options.Issuer,
options.Audience,
TimeSpan.FromSeconds(options.ClockSkewSeconds),
signingKeys);
PublisherAuthorizationService authorization = new(policies);
return new ProvisioningRuntime(
policies,
signingKeys,
credentials,
authorization,
secretProvider as IDisposable);
}
catch
{
signingKeys.Dispose();
throw;
}
}
catch
{
(secretProvider as IDisposable)?.Dispose();
throw;
}
}
public void Dispose()
{
SigningKeys.Dispose();
_secretProviderLifetime?.Dispose();
}
private static void RequirePublisherKey(
SigningKeyRing signingKeys,
GamePolicy policy,
PublisherTrustMode trustMode,
PrincipalCredentialKind credentialKind,
DateTimeOffset now)
{
if (policy.AllowsPublisherTrust(trustMode)
&& !signingKeys.HasActiveSigningKey(
now,
credentialKind,
policy.GameId.ToString(),
policy.EnvironmentId.ToString()))
{
throw new ProvisioningConfigurationException(
$"Policy {policy.GameId}/{policy.EnvironmentId} has no active {credentialKind} key.");
}
}
}
internal sealed record ProvisioningReadiness(bool IsReady);
@@ -0,0 +1,119 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class PublisherAuthorizationService(GamePolicyRegistry policies)
{
public PublisherAuthorizationResult Authorize(
AuthenticatedPrincipal principal,
GameId requestedGameId,
EnvironmentId requestedEnvironmentId,
RegionId requestedRegionId,
uint requestedProtocolVersion,
ListingVisibility requestedVisibility,
IReadOnlyDictionary<string, string> requestedMetadata,
DateTimeOffset now)
{
if (principal.ExpiresAt <= now)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PrincipalExpired);
}
if (principal is not IPublisherPrincipal publisher)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.NotPublisher);
}
if (publisher.GameId != requestedGameId
|| publisher.EnvironmentId != requestedEnvironmentId)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ScopeMismatch);
}
if (!policies.TryGet(publisher.GameId, publisher.EnvironmentId, out GamePolicy? policy)
|| policy is null)
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.PolicyNotFound);
}
if (!policy.AllowsPublisherTrust(publisher.TrustMode))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.TrustModeNotAllowed);
}
if (!publisher.AllowedRegions.Contains(requestedRegionId)
|| !policy.AllowsRegion(requestedRegionId))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.RegionNotAllowed);
}
if (!policy.AllowsProtocol(requestedProtocolVersion))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.ProtocolNotAllowed);
}
if (publisher.TrustMode == PublisherTrustMode.AnonymousUnlisted
&& requestedVisibility != ListingVisibility.Unlisted)
{
return PublisherAuthorizationResult.Denied(
PublisherAuthorizationError.AnonymousMustBeUnlisted);
}
if (!policy.AllowsVisibility(requestedVisibility))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.VisibilityNotAllowed);
}
if (!policy.AllowsMetadata(requestedMetadata))
{
return PublisherAuthorizationResult.Denied(PublisherAuthorizationError.MetadataNotAllowed);
}
return PublisherAuthorizationResult.Allowed(new AuthorizedPublisherContext(
publisher.Subject,
publisher.GameId,
publisher.EnvironmentId,
requestedRegionId,
requestedProtocolVersion,
requestedVisibility,
publisher.TrustMode,
policy));
}
}
internal sealed record AuthorizedPublisherContext(
string Subject,
GameId GameId,
EnvironmentId EnvironmentId,
RegionId RegionId,
uint ProtocolVersion,
ListingVisibility Visibility,
PublisherTrustMode TrustMode,
GamePolicy Policy);
internal readonly record struct PublisherAuthorizationResult(
bool IsAllowed,
PublisherAuthorizationError Error,
AuthorizedPublisherContext? Context)
{
public static PublisherAuthorizationResult Allowed(AuthorizedPublisherContext context) =>
new(true, PublisherAuthorizationError.None, context);
public static PublisherAuthorizationResult Denied(PublisherAuthorizationError error) =>
new(false, error, null);
}
internal enum PublisherAuthorizationError
{
None = 0,
NotPublisher = 1,
ScopeMismatch = 2,
PolicyNotFound = 3,
TrustModeNotAllowed = 4,
RegionNotAllowed = 5,
ProtocolNotAllowed = 6,
AnonymousMustBeUnlisted = 7,
VisibilityNotAllowed = 8,
MetadataNotAllowed = 9,
PrincipalExpired = 10,
}
@@ -0,0 +1,144 @@
using System.Security.Cryptography;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal interface ISecretProvider
{
bool TryGetSecret(string reference, out SecretMaterial? secret);
}
internal sealed class SecretMaterial : IDisposable
{
private byte[]? _bytes;
public SecretMaterial(ReadOnlySpan<byte> bytes)
{
if (bytes.Length == 0)
{
throw new ArgumentException("Secret material cannot be empty.", nameof(bytes));
}
_bytes = bytes.ToArray();
}
public int Length => _bytes?.Length ?? 0;
public byte[] CopyBytes() => _bytes?.ToArray()
?? throw new ObjectDisposedException(nameof(SecretMaterial));
public void Dispose()
{
if (_bytes is not null)
{
CryptographicOperations.ZeroMemory(_bytes);
_bytes = null;
}
}
public override string ToString() => "[REDACTED SECRET]";
}
internal sealed class EnvironmentSecretProvider : ISecretProvider
{
private const string Prefix = "env:";
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
string? encoded = Environment.GetEnvironmentVariable(reference[Prefix.Length..]);
if (string.IsNullOrEmpty(encoded))
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
secret = new SecretMaterial(bytes);
CryptographicOperations.ZeroMemory(bytes);
return true;
}
catch (FormatException)
{
return false;
}
}
}
internal sealed class EphemeralDevelopmentSecretProvider : ISecretProvider, IDisposable
{
private const string Prefix = "development:ephemeral/";
private readonly Dictionary<string, byte[]> _secrets = new(StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
secret = null;
if (!reference.StartsWith(Prefix, StringComparison.Ordinal)
|| reference.Length == Prefix.Length)
{
return false;
}
if (!_secrets.TryGetValue(reference, out byte[]? bytes))
{
bytes = RandomNumberGenerator.GetBytes(32);
_secrets.Add(reference, bytes);
}
secret = new SecretMaterial(bytes);
return true;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[EphemeralDevelopmentSecretProvider]";
}
internal sealed class DictionarySecretProvider : ISecretProvider, IDisposable
{
private readonly Dictionary<string, byte[]> _secrets;
public DictionarySecretProvider(IReadOnlyDictionary<string, byte[]> secrets) =>
_secrets = secrets.ToDictionary(
static item => item.Key,
static item => item.Value.ToArray(),
StringComparer.Ordinal);
public bool TryGetSecret(string reference, out SecretMaterial? secret)
{
if (_secrets.TryGetValue(reference, out byte[]? bytes))
{
secret = new SecretMaterial(bytes);
return true;
}
secret = null;
return false;
}
public void Dispose()
{
foreach (byte[] bytes in _secrets.Values)
{
CryptographicOperations.ZeroMemory(bytes);
}
_secrets.Clear();
}
public override string ToString() => "[DictionarySecretProvider: REDACTED]";
}
@@ -0,0 +1,275 @@
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class SigningKeyRing : IDisposable
{
private readonly Dictionary<string, SigningKey> _keys;
private readonly ConcurrentDictionary<string, byte> _runtimeRevocations =
new(StringComparer.Ordinal);
private SigningKeyRing(Dictionary<string, SigningKey> keys) => _keys = keys;
public bool HasKeys => _keys.Count > 0;
public static SigningKeyRing Create(
IEnumerable<SigningKeyOptions> options,
ISecretProvider secretProvider)
{
SigningKeyOptions[] configuredKeys = options.ToArray();
if (configuredKeys.Length > ProvisioningLimits.MaxSigningKeys)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxSigningKeys} signing keys may be configured.");
}
Dictionary<string, SigningKey> keys = new(StringComparer.Ordinal);
try
{
foreach (SigningKeyOptions keyOptions in configuredKeys)
{
Validate(keyOptions);
if (keys.ContainsKey(keyOptions.KeyId))
{
throw new ProvisioningConfigurationException(
$"Duplicate signing key ID '{keyOptions.KeyId}'.");
}
if (keyOptions.Revoked)
{
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, null));
continue;
}
if (!secretProvider.TryGetSecret(
keyOptions.SecretReference,
out SecretMaterial? material)
|| material is null)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' has no available key material.");
}
using (material)
{
if (material.Length < 32)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' must contain at least 32 bytes.");
}
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, material.CopyBytes()));
}
}
return new SigningKeyRing(keys);
}
catch
{
foreach (SigningKey key in keys.Values)
{
key.Dispose();
}
throw;
}
}
public bool HasActiveSigningKey(DateTimeOffset now) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil);
public bool HasActiveSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId));
public bool TryGetSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId,
out SigningKey? signingKey)
{
signingKey = _keys.Values
.Where(key => !IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId))
.OrderByDescending(static key => key.NotBefore)
.ThenByDescending(static key => key.KeyId, StringComparer.Ordinal)
.FirstOrDefault();
return signingKey is not null;
}
public VerificationKeyLookup FindVerificationKey(
string keyId,
DateTimeOffset now,
out SigningKey? signingKey)
{
signingKey = null;
if (!_keys.TryGetValue(keyId, out SigningKey? candidate))
{
return VerificationKeyLookup.Unknown;
}
if (IsRevoked(candidate))
{
return VerificationKeyLookup.Revoked;
}
if (now < candidate.NotBefore)
{
return VerificationKeyLookup.NotYetValid;
}
if (now >= candidate.VerifyUntil)
{
return VerificationKeyLookup.Retired;
}
signingKey = candidate;
return VerificationKeyLookup.Available;
}
public bool Revoke(string keyId) =>
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
public void Dispose()
{
foreach (SigningKey key in _keys.Values)
{
key.Dispose();
}
_keys.Clear();
_runtimeRevocations.Clear();
}
public override string ToString() => $"[SigningKeyRing: {_keys.Count} keys, material redacted]";
private bool IsRevoked(SigningKey key) =>
key.ConfiguredRevoked || _runtimeRevocations.ContainsKey(key.KeyId);
private static void Validate(SigningKeyOptions options)
{
if (string.IsNullOrEmpty(options.KeyId)
|| options.KeyId.Length > 64
|| options.KeyId.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
throw new ProvisioningConfigurationException(
"Signing key IDs must be 164 base64url characters.");
}
if (string.IsNullOrWhiteSpace(options.SecretReference)
|| options.NotBefore >= options.SignUntil
|| options.SignUntil > options.VerifyUntil)
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' has an invalid secret reference or lifetime.");
}
if (options.CredentialKinds.Count == 0
|| options.CredentialKinds.Any(static kind => !Enum.IsDefined(kind))
|| options.CredentialKinds.Count != options.CredentialKinds.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' requires unique valid credential kinds.");
}
bool operatorKey = options.CredentialKinds.Contains(PrincipalCredentialKind.Operator);
bool hasPublisherKind = options.CredentialKinds.Any(static kind =>
kind is PrincipalCredentialKind.DedicatedPublisher
or PrincipalCredentialKind.PlayerHostGrant);
if (operatorKey
? options.CredentialKinds.Count != 1
|| options.GameId is not null
|| options.EnvironmentId is not null
: !hasPublisherKind
|| !GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' must be operator-only or bound to one game/environment.");
}
}
}
internal sealed class SigningKey : IDisposable
{
private byte[]? _material;
public SigningKey(SigningKeyOptions options, byte[]? material)
{
KeyId = options.KeyId;
NotBefore = options.NotBefore;
SignUntil = options.SignUntil;
VerifyUntil = options.VerifyUntil;
ConfiguredRevoked = options.Revoked;
CredentialKinds = options.CredentialKinds.ToFrozenSet();
GameId = options.GameId;
EnvironmentId = options.EnvironmentId;
_material = material;
}
public string KeyId { get; }
public DateTimeOffset NotBefore { get; }
public DateTimeOffset SignUntil { get; }
public DateTimeOffset VerifyUntil { get; }
public bool ConfiguredRevoked { get; }
public IReadOnlySet<PrincipalCredentialKind> CredentialKinds { get; }
public string? GameId { get; }
public string? EnvironmentId { get; }
public bool Authorizes(
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) =>
CredentialKinds.Contains(kind)
&& (kind == PrincipalCredentialKind.Operator
? gameId is null && environmentId is null
: string.Equals(GameId, gameId, StringComparison.Ordinal)
&& string.Equals(EnvironmentId, environmentId, StringComparison.Ordinal));
public byte[] Sign(string input)
{
ObjectDisposedException.ThrowIf(_material is null, this);
return HMACSHA256.HashData(_material, Encoding.ASCII.GetBytes(input));
}
public void Dispose()
{
if (_material is not null)
{
CryptographicOperations.ZeroMemory(_material);
_material = null;
}
}
public override string ToString() => $"[SigningKey {KeyId}: material redacted]";
}
internal enum VerificationKeyLookup
{
Available = 0,
Unknown = 1,
Revoked = 2,
NotYetValid = 3,
Retired = 4,
}
@@ -0,0 +1,43 @@
{
"Rendezvous": {
"Provisioning": {
"Issuer": "final-factory-rendezvous-development",
"Audience": "final-factory-rendezvous",
"ClockSkewSeconds": 30,
"SigningKeys": [
{
"KeyId": "development-ephemeral-1",
"SecretReference": "development:ephemeral/rendezvous-signing",
"CredentialKinds": ["DedicatedPublisher", "PlayerHostGrant"],
"GameId": "space-game",
"EnvironmentId": "development",
"NotBefore": "2025-01-01T00:00:00Z",
"SignUntil": "2035-01-01T00:00:00Z",
"VerifyUntil": "2035-01-02T00:00:00Z"
}
],
"Games": [
{
"GameId": "space-game",
"EnvironmentId": "development",
"Enabled": true,
"ProtocolVersions": [1],
"Regions": ["local"],
"VisibilityModes": ["Public", "Unlisted"],
"PublisherTrustModes": ["ManagedDedicated", "PlayerGrant", "AnonymousUnlisted"],
"MetadataValueMaxBytes": {
"map": 64,
"mode": 32
},
"RequiredMetadataKeys": [],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 2,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 1,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "Disabled"
}
]
}
}
}
@@ -0,0 +1,83 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class GamePolicyTests
{
[Fact]
public void RegistryFailsClosedForUnknownAndDisabledScopes()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy("unscouted", "staging", enabled: false),
]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out _));
Assert.False(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("staging"),
out _));
Assert.False(registry.TryGet(
new GameId("unscouted"),
new EnvironmentId("staging"),
out _));
}
[Fact]
public void PerGamePolicyConstrainsProtocolMetadataQuotasAndFeatures()
{
GamePolicyRegistry registry = GamePolicyRegistry.Create(
[ProvisioningTestData.CreatePolicy()]);
Assert.True(registry.TryGet(
new GameId("space-game"),
new EnvironmentId("production"),
out GamePolicy? policy));
Assert.NotNull(policy);
Assert.True(policy.AllowsProtocol(7));
Assert.False(policy.AllowsProtocol(8));
Assert.True(policy.AllowsRegion(new RegionId("eu-central")));
Assert.False(policy.AllowsRegion(new RegionId("us-east")));
Assert.True(policy.AllowsVisibility(ListingVisibility.Public));
Assert.True(policy.AllowsPublisherTrust(PublisherTrustMode.PlayerGrant));
Assert.Equal(FallbackPolicyMode.DedicatedEndpointAllowed, policy.FallbackPolicy);
Assert.Equal(10, policy.MaxListingsPerPrincipal);
Assert.Equal(1, policy.MaxAnonymousListingsPerAddress);
Assert.Equal(100, policy.MaxActiveJoinAttempts);
Assert.True(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["map"] = "europa",
}));
Assert.False(policy.AllowsMetadata(new Dictionary<string, string>
{
["mode"] = "co-op",
["unknown"] = "value",
}));
}
[Fact]
public void InvalidOrDuplicatePolicyConfigurationFailsAtStartup()
{
GamePolicyOptions invalid = ProvisioningTestData.CreatePolicy();
invalid.ProtocolVersions = [];
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create([invalid]));
Assert.Throws<ProvisioningConfigurationException>(() =>
GamePolicyRegistry.Create(
[
ProvisioningTestData.CreatePolicy(),
ProvisioningTestData.CreatePolicy(),
]));
}
}
@@ -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);
}
@@ -0,0 +1,97 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class ProvisioningSecurityTests
{
[Fact]
public void StartupFailsClearlyWhenKeyMaterialIsAbsent()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions(
ProvisioningTestData.CreateKey(secretReference: "missing-production-secret"));
using DictionarySecretProvider empty = ProvisioningTestData.CreateSecrets();
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, empty, ProvisioningTestData.Now));
Assert.Contains("key-1", exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("missing-production-secret", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void StartupRequiresAnActivePublisherKeyForEveryEnabledPolicy()
{
ProvisioningOptions options = ProvisioningTestData.CreateOptions();
options.Games.Add(ProvisioningTestData.CreatePolicy("unscouted", "production"));
using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1");
ProvisioningConfigurationException exception = Assert.Throws<ProvisioningConfigurationException>(
() => ProvisioningRuntime.Create(options, secrets, ProvisioningTestData.Now));
Assert.Contains("unscouted/production", exception.Message, StringComparison.Ordinal);
Assert.Contains("key", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SecretMaterialCredentialsAndKeysAreRedactedFromDiagnostics()
{
byte[] knownSecret = Enumerable.Range(1, 32).Select(static value => (byte)value).ToArray();
string encodedSecret = Convert.ToBase64String(knownSecret);
using SecretMaterial material = new(knownSecret);
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
{
["secret-1"] = knownSecret,
});
using SigningKeyRing keys = SigningKeyRing.Create(
[ProvisioningTestData.CreateKey()],
secrets);
PrincipalCredentialService service = new(
"final-factory-rendezvous",
"rendezvous-service",
TimeSpan.FromSeconds(30),
keys);
string token = service.Issue(
ProvisioningTestData.CreateDedicatedPublisher(),
ProvisioningTestData.Now);
CredentialValidationResult result = service.Validate(token, ProvisioningTestData.Now);
string diagnostics = string.Join(
'|',
material,
secrets,
keys,
service,
result);
Assert.DoesNotContain(encodedSecret, diagnostics, StringComparison.Ordinal);
Assert.DoesNotContain(token, diagnostics, StringComparison.Ordinal);
Assert.Contains("redacted", diagnostics, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PublicClientAndContractSurfacesContainNoProvisioningSecrets()
{
Type[] publicTypes = typeof(GameId).Assembly.GetExportedTypes()
.Concat(Assembly.Load("FinalFactory.Rendezvous.Client").GetExportedTypes())
.ToArray();
string[] forbiddenTerms =
[
"GameSecret",
"SigningKey",
"KeyMaterial",
"PublisherCredential",
"SecretProvider",
];
foreach (Type type in publicTypes)
{
IEnumerable<string> names = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
.Select(static member => member.Name)
.Append(type.Name);
Assert.DoesNotContain(names, name => forbiddenTerms.Any(term =>
name.Contains(term, StringComparison.OrdinalIgnoreCase)));
}
}
}
@@ -0,0 +1,98 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
internal static class ProvisioningTestData
{
public static readonly DateTimeOffset Now = new(2026, 7, 16, 12, 0, 0, TimeSpan.Zero);
public static GamePolicyOptions CreatePolicy(
string gameId = "space-game",
string environmentId = "production",
bool enabled = true) => new()
{
GameId = gameId,
EnvironmentId = environmentId,
Enabled = enabled,
ProtocolVersions = [7],
Regions = ["eu-central"],
VisibilityModes = [ListingVisibility.Public, ListingVisibility.Unlisted],
PublisherTrustModes =
[
PublisherTrustMode.ManagedDedicated,
PublisherTrustMode.PlayerGrant,
PublisherTrustMode.AnonymousUnlisted,
],
MetadataValueMaxBytes = new Dictionary<string, int>(StringComparer.Ordinal)
{
["map"] = 32,
["mode"] = 16,
},
RequiredMetadataKeys = ["mode"],
MetadataMaxBytes = 256,
MetadataMaxKeys = 2,
MaxListingsPerPrincipal = 10,
MaxAnonymousListingsPerAddress = 1,
MaxActiveJoinAttempts = 100,
FallbackPolicy = FallbackPolicyMode.DedicatedEndpointAllowed,
};
public static SigningKeyOptions CreateKey(
string keyId = "key-1",
string secretReference = "secret-1",
DateTimeOffset? notBefore = null,
DateTimeOffset? signUntil = null,
DateTimeOffset? verifyUntil = null,
bool revoked = false,
IEnumerable<PrincipalCredentialKind>? credentialKinds = null,
string? gameId = "space-game",
string? environmentId = "production") => new()
{
KeyId = keyId,
SecretReference = secretReference,
CredentialKinds = credentialKinds?.ToList()
?? [
PrincipalCredentialKind.DedicatedPublisher,
PrincipalCredentialKind.PlayerHostGrant,
],
GameId = gameId,
EnvironmentId = environmentId,
NotBefore = notBefore ?? Now.AddHours(-1),
SignUntil = signUntil ?? Now.AddHours(1),
VerifyUntil = verifyUntil ?? Now.AddHours(2),
Revoked = revoked,
};
public static DictionarySecretProvider CreateSecrets(params string[] references)
{
Dictionary<string, byte[]> secrets = new(StringComparer.Ordinal);
for (int index = 0; index < references.Length; index++)
{
secrets.Add(
references[index],
Enumerable.Range(1 + index, 32).Select(static value => (byte)value).ToArray());
}
return new DictionarySecretProvider(secrets);
}
public static DedicatedPublisherPrincipal CreateDedicatedPublisher(
DateTimeOffset? expiresAt = null,
string gameId = "space-game",
string environmentId = "production") => new(
"workload-42",
expiresAt ?? Now.AddMinutes(10),
new GameId(gameId),
new EnvironmentId(environmentId),
new HashSet<RegionId> { new("eu-central") });
public static ProvisioningOptions CreateOptions(SigningKeyOptions? key = null) => new()
{
Issuer = "final-factory-rendezvous",
Audience = "rendezvous-service",
ClockSkewSeconds = 30,
SigningKeys = [key ?? CreateKey()],
Games = [CreatePolicy()],
};
}
@@ -0,0 +1,124 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Provisioning;
public sealed class PublisherAuthorizationTests
{
private static readonly IReadOnlyDictionary<string, string> ValidMetadata =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
};
[Fact]
public void AuthoritativeScopeComesFromThePublisherPrincipal()
{
PublisherAuthorizationService service = CreateService();
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher();
PublisherAuthorizationResult result = service.Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.True(result.IsAllowed);
Assert.NotNull(result.Context);
Assert.Equal(principal.GameId, result.Context.GameId);
Assert.Equal(principal.EnvironmentId, result.Context.EnvironmentId);
}
[Theory]
[InlineData("unscouted", "production")]
[InlineData("space-game", "staging")]
public void CrossGameAndEnvironmentScopeEscalationIsDenied(
string requestedGame,
string requestedEnvironment)
{
PublisherAuthorizationResult result = CreateService().Authorize(
ProvisioningTestData.CreateDedicatedPublisher(),
new GameId(requestedGame),
new EnvironmentId(requestedEnvironment),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.False(result.IsAllowed);
Assert.Equal(PublisherAuthorizationError.ScopeMismatch, result.Error);
Assert.Null(result.Context);
}
[Fact]
public void OperatorCannotBeUsedAsAGamePublisher()
{
OperatorPrincipal principal = new(
"operator-1",
ProvisioningTestData.Now.AddMinutes(10),
new HashSet<OperatorPermission> { OperatorPermission.ReadPolicy });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
new GameId("space-game"),
new EnvironmentId("production"),
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.NotPublisher, result.Error);
}
[Fact]
public void AnonymousPublisherCanNeverEscalateToPublicVisibility()
{
AnonymousUnlistedPrincipal principal = new(
"anonymous-source-1",
ProvisioningTestData.Now.AddMinutes(2),
new GameId("space-game"),
new EnvironmentId("production"),
new HashSet<RegionId> { new("eu-central") });
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.AnonymousMustBeUnlisted, result.Error);
}
[Fact]
public void ExpiredPrincipalIsRecheckedAtAuthorizationTime()
{
DedicatedPublisherPrincipal principal = ProvisioningTestData.CreateDedicatedPublisher(
ProvisioningTestData.Now.AddSeconds(-1));
PublisherAuthorizationResult result = CreateService().Authorize(
principal,
principal.GameId,
principal.EnvironmentId,
new RegionId("eu-central"),
7,
ListingVisibility.Public,
ValidMetadata,
ProvisioningTestData.Now);
Assert.Equal(PublisherAuthorizationError.PrincipalExpired, result.Error);
}
private static PublisherAuthorizationService CreateService() => new(
GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]));
}