Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47382ddadc |
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 1–128 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 1–64 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()]));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user