diff --git a/README.md b/README.md index 1b81b64..4305075 100644 --- a/README.md +++ b/README.md @@ -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). The frozen v1 wire surface is documented in the [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 @@ -97,5 +99,9 @@ dotnet test Rendezvous.slnx --configuration Release --no-build Run the bootstrap server with `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 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 [project and dependency boundaries](docs/architecture/project-boundaries.md). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0b4ab0f..ad87566 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,6 +9,7 @@ decision requires a superseding ADR and corresponding contract/test updates. - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.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, simulation, persistence, social features, skill matchmaking, and gameplay diff --git a/docs/security/provisioning.md b/docs/security/provisioning.md new file mode 100644 index 0000000..b15f1b0 --- /dev/null +++ b/docs/security/provisioning.md @@ -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...`. 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:` 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. diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index f4c31ca..8dbea94 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -1,15 +1,15 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Http; +using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Transport; using Microsoft.OpenApi; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -bool isOpenApiGeneration = Environment.GetCommandLineArgs().Any(static argument => - string.Equals( - Path.GetFileName(argument), - "dotnet-getdocument.dll", - StringComparison.OrdinalIgnoreCase)); +bool isOpenApiGeneration = string.Equals( + System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name, + "GetDocument.Insider", + StringComparison.Ordinal); builder.Services.AddOpenApi("v1", static options => options.AddSchemaTransformer(static (schema, context, cancellationToken) => @@ -34,6 +34,30 @@ builder.Services.AddOpenApi("v1", static options => })); builder.Services.ConfigureHttpJsonOptions(static options => ContractJson.Configure(options.SerializerOptions)); + +if (isOpenApiGeneration) +{ + builder.Services.AddSingleton(new ProvisioningReadiness(false)); +} +else +{ + ProvisioningOptions provisioningOptions = builder.Configuration + .GetSection(ProvisioningOptions.SectionName) + .Get() ?? 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 .AddOptions() .BindConfiguration(UdpMediatorOptions.SectionName) @@ -61,7 +85,8 @@ app.MapGet( .WithTags("Health"); app.MapGet( "/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.Ok(new HealthResponse { Status = "ready" })) .Produces() diff --git a/src/FinalFactory.Rendezvous.Server/Properties/AssemblyInfo.cs b/src/FinalFactory.Rendezvous.Server/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..024def4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")] diff --git a/src/FinalFactory.Rendezvous.Server/Properties/launchSettings.json b/src/FinalFactory.Rendezvous.Server/Properties/launchSettings.json new file mode 100644 index 0000000..2981f2b --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicy.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicy.cs new file mode 100644 index 0000000..dadfd2d --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicy.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using FinalFactory.Rendezvous.Contracts; + +namespace FinalFactory.Rendezvous.Server.Provisioning; + +internal sealed class GamePolicy +{ + private readonly HashSet _protocolVersions; + private readonly HashSet _regions; + private readonly HashSet _visibilityModes; + private readonly HashSet _publisherTrustModes; + private readonly Dictionary _metadataValueMaxBytes; + private readonly HashSet _requiredMetadataKeys; + + public GamePolicy(GamePolicyOptions options) + { + GameId = new GameId(options.GameId); + EnvironmentId = new EnvironmentId(options.EnvironmentId); + Enabled = options.Enabled; + _protocolVersions = new HashSet(options.ProtocolVersions); + _regions = options.Regions.Select(static region => new RegionId(region)).ToHashSet(); + _visibilityModes = new HashSet(options.VisibilityModes); + _publisherTrustModes = new HashSet(options.PublisherTrustModes); + _metadataValueMaxBytes = new Dictionary( + options.MetadataValueMaxBytes, + StringComparer.Ordinal); + _requiredMetadataKeys = new HashSet( + 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? metadata) + { + if (!ContractValidation.IsMetadataValid(metadata) + || metadata!.Count > MetadataMaxKeys + || !_requiredMetadataKeys.IsSubsetOf(metadata.Keys)) + { + return false; + } + + foreach (KeyValuePair 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; + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicyRegistry.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicyRegistry.cs new file mode 100644 index 0000000..ba6daac --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/GamePolicyRegistry.cs @@ -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 EnabledPolicies => + _policies.Values.Where(static policy => policy.Enabled); + + public static GamePolicyRegistry Create(IEnumerable 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."); + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs new file mode 100644 index 0000000..b553d92 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/PrincipalCredentialService.cs @@ -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( + 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(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 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 Regions { get; set; } = []; + public List 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 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; + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/Principals.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/Principals.cs new file mode 100644 index 0000000..9544b57 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/Principals.cs @@ -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 AllowedRegions { get; } +} + +internal sealed record DedicatedPublisherPrincipal : AuthenticatedPrincipal, IPublisherPrincipal +{ + public DedicatedPublisherPrincipal( + string subject, + DateTimeOffset expiresAt, + GameId gameId, + EnvironmentId environmentId, + IEnumerable allowedRegions) + : base(subject, expiresAt) + { + GameId = gameId; + EnvironmentId = environmentId; + AllowedRegions = allowedRegions.ToFrozenSet(); + } + + public GameId GameId { get; } + public EnvironmentId EnvironmentId { get; } + public IReadOnlySet AllowedRegions { get; } + public PublisherTrustMode TrustMode => PublisherTrustMode.ManagedDedicated; +} + +internal sealed record PlayerHostGrantPrincipal : AuthenticatedPrincipal, IPublisherPrincipal +{ + public PlayerHostGrantPrincipal( + string subject, + DateTimeOffset expiresAt, + GameId gameId, + EnvironmentId environmentId, + IEnumerable allowedRegions) + : base(subject, expiresAt) + { + GameId = gameId; + EnvironmentId = environmentId; + AllowedRegions = allowedRegions.ToFrozenSet(); + } + + public GameId GameId { get; } + public EnvironmentId EnvironmentId { get; } + public IReadOnlySet AllowedRegions { get; } + public PublisherTrustMode TrustMode => PublisherTrustMode.PlayerGrant; +} + +internal sealed record AnonymousUnlistedPrincipal : AuthenticatedPrincipal, IPublisherPrincipal +{ + public AnonymousUnlistedPrincipal( + string subject, + DateTimeOffset expiresAt, + GameId gameId, + EnvironmentId environmentId, + IEnumerable allowedRegions) + : base(subject, expiresAt) + { + GameId = gameId; + EnvironmentId = environmentId; + AllowedRegions = allowedRegions.ToFrozenSet(); + } + + public GameId GameId { get; } + public EnvironmentId EnvironmentId { get; } + public IReadOnlySet AllowedRegions { get; } + public PublisherTrustMode TrustMode => PublisherTrustMode.AnonymousUnlisted; +} + +internal sealed record OperatorPrincipal : AuthenticatedPrincipal +{ + public OperatorPrincipal( + string subject, + DateTimeOffset expiresAt, + IEnumerable permissions) + : base(subject, expiresAt) => Permissions = permissions.ToFrozenSet(); + + public IReadOnlySet Permissions { get; } +} + +internal enum OperatorPermission +{ + ReadPolicy = 1, + ManagePolicy = 2, + RevokePublisher = 3, + RotateKeys = 4, +} + +internal enum PrincipalCredentialKind +{ + DedicatedPublisher = 1, + PlayerHostGrant = 2, + Operator = 3, +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningOptions.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningOptions.cs new file mode 100644 index 0000000..763ea71 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningOptions.cs @@ -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 SigningKeys { get; set; } = []; + public List Games { get; set; } = []; +} + +internal sealed class SigningKeyOptions +{ + public string KeyId { get; set; } = string.Empty; + public string SecretReference { get; set; } = string.Empty; + public List 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 ProtocolVersions { get; set; } = []; + public List Regions { get; set; } = []; + public List VisibilityModes { get; set; } = []; + public List PublisherTrustModes { get; set; } = []; + public Dictionary MetadataValueMaxBytes { get; set; } = + new(StringComparer.Ordinal); + public List 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) + { + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningRuntime.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningRuntime.cs new file mode 100644 index 0000000..9a2e187 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/ProvisioningRuntime.cs @@ -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); diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/PublisherAuthorizationService.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/PublisherAuthorizationService.cs new file mode 100644 index 0000000..166a13f --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/PublisherAuthorizationService.cs @@ -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 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, +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/SecretProviders.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/SecretProviders.cs new file mode 100644 index 0000000..05d096b --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/SecretProviders.cs @@ -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 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 _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 _secrets; + + public DictionarySecretProvider(IReadOnlyDictionary 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]"; +} diff --git a/src/FinalFactory.Rendezvous.Server/Provisioning/SigningKeyRing.cs b/src/FinalFactory.Rendezvous.Server/Provisioning/SigningKeyRing.cs new file mode 100644 index 0000000..f199364 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Provisioning/SigningKeyRing.cs @@ -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 _keys; + private readonly ConcurrentDictionary _runtimeRevocations = + new(StringComparer.Ordinal); + + private SigningKeyRing(Dictionary keys) => _keys = keys; + + public bool HasKeys => _keys.Count > 0; + + public static SigningKeyRing Create( + IEnumerable 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 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 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, +} diff --git a/src/FinalFactory.Rendezvous.Server/appsettings.Development.json b/src/FinalFactory.Rendezvous.Server/appsettings.Development.json new file mode 100644 index 0000000..7c433e5 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/appsettings.Development.json @@ -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" + } + ] + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/GamePolicyTests.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/GamePolicyTests.cs new file mode 100644 index 0000000..c6147e0 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/GamePolicyTests.cs @@ -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 + { + ["mode"] = "co-op", + ["map"] = "europa", + })); + Assert.False(policy.AllowsMetadata(new Dictionary + { + ["map"] = "europa", + })); + Assert.False(policy.AllowsMetadata(new Dictionary + { + ["mode"] = "co-op", + ["unknown"] = "value", + })); + } + + [Fact] + public void InvalidOrDuplicatePolicyConfigurationFailsAtStartup() + { + GamePolicyOptions invalid = ProvisioningTestData.CreatePolicy(); + invalid.ProtocolVersions = []; + Assert.Throws(() => + GamePolicyRegistry.Create([invalid])); + + Assert.Throws(() => + GamePolicyRegistry.Create( + [ + ProvisioningTestData.CreatePolicy(), + ProvisioningTestData.CreatePolicy(), + ])); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs new file mode 100644 index 0000000..a0e4b29 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PrincipalCredentialTests.cs @@ -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(dedicatedResult.Principal); + Assert.IsType(grantResult.Principal); + } + + [Fact] + public void WrongIssuerAndAudienceAreRejectedAfterSignatureValidation() + { + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1"); + using SigningKeyRing keys = SigningKeyRing.Create( + [ProvisioningTestData.CreateKey()], + secrets); + PrincipalCredentialService issuer = CreateService(keys); + string token = issuer.Issue( + ProvisioningTestData.CreateDedicatedPublisher(), + ProvisioningTestData.Now); + + PrincipalCredentialService wrongIssuer = new( + "other-issuer", + "rendezvous-service", + TimeSpan.FromSeconds(30), + keys); + PrincipalCredentialService wrongAudience = new( + "final-factory-rendezvous", + "other-audience", + TimeSpan.FromSeconds(30), + keys); + + Assert.Equal( + CredentialValidationError.IssuerMismatch, + wrongIssuer.Validate(token, ProvisioningTestData.Now).Error); + Assert.Equal( + CredentialValidationError.AudienceMismatch, + wrongAudience.Validate(token, ProvisioningTestData.Now).Error); + } + + [Fact] + public void ExpiryTamperingAndRevocationAreRejected() + { + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1"); + using SigningKeyRing keys = SigningKeyRing.Create( + [ProvisioningTestData.CreateKey()], + secrets); + PrincipalCredentialService service = CreateService(keys); + string token = service.Issue( + ProvisioningTestData.CreateDedicatedPublisher( + ProvisioningTestData.Now.AddMinutes(1)), + ProvisioningTestData.Now); + char replacement = token[^1] == 'A' ? 'B' : 'A'; + string tampered = token[..^1] + replacement; + + Assert.Equal( + CredentialValidationError.Expired, + service.Validate(token, ProvisioningTestData.Now.AddSeconds(91)).Error); + Assert.Equal( + CredentialValidationError.SignatureInvalid, + service.Validate(tampered, ProvisioningTestData.Now).Error); + Assert.True(keys.Revoke("key-1")); + Assert.Equal( + CredentialValidationError.KeyRevoked, + service.Validate(token, ProvisioningTestData.Now).Error); + } + + [Fact] + public void KeyRotationHonorsOverlapAndRejectsRetiredKeys() + { + SigningKeyOptions oldKey = ProvisioningTestData.CreateKey( + "old-key", + "old-secret", + ProvisioningTestData.Now.AddHours(-1), + ProvisioningTestData.Now.AddMinutes(10), + ProvisioningTestData.Now.AddMinutes(60)); + SigningKeyOptions newKey = ProvisioningTestData.CreateKey( + "new-key", + "new-secret", + ProvisioningTestData.Now.AddMinutes(10), + ProvisioningTestData.Now.AddHours(2), + ProvisioningTestData.Now.AddHours(3)); + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets( + "old-secret", + "new-secret"); + using SigningKeyRing keys = SigningKeyRing.Create([oldKey, newKey], secrets); + PrincipalCredentialService service = CreateService(keys); + string oldToken = service.Issue( + ProvisioningTestData.CreateDedicatedPublisher( + ProvisioningTestData.Now.AddMinutes(50)), + ProvisioningTestData.Now); + + Assert.True(service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid); + Assert.Equal( + CredentialValidationError.KeyRetired, + service.Validate(oldToken, ProvisioningTestData.Now.AddMinutes(61)).Error); + + string newToken = service.Issue( + ProvisioningTestData.CreateDedicatedPublisher( + ProvisioningTestData.Now.AddMinutes(90)), + ProvisioningTestData.Now.AddMinutes(20)); + Assert.True(service.Validate(newToken, ProvisioningTestData.Now.AddMinutes(20)).IsValid); + } + + [Fact] + public void OperatorCredentialNeverBecomesAPublisherPrincipal() + { + SigningKeyOptions operatorKey = ProvisioningTestData.CreateKey( + credentialKinds: [PrincipalCredentialKind.Operator], + gameId: null, + environmentId: null); + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1"); + using SigningKeyRing keys = SigningKeyRing.Create( + [operatorKey], + secrets); + PrincipalCredentialService service = CreateService(keys); + OperatorPrincipal operatorPrincipal = new( + "operator-1", + ProvisioningTestData.Now.AddMinutes(5), + new HashSet { OperatorPermission.RotateKeys }); + + CredentialValidationResult result = service.Validate( + service.Issue(operatorPrincipal, ProvisioningTestData.Now), + ProvisioningTestData.Now); + + Assert.IsType(result.Principal); + Assert.IsNotAssignableFrom(result.Principal); + } + + [Fact] + public void ConfiguredRevocationDoesNotRequireRetiredSecretMaterial() + { + SigningKeyOptions revoked = ProvisioningTestData.CreateKey( + "revoked-key", + "removed-secret", + revoked: true); + SigningKeyOptions active = ProvisioningTestData.CreateKey( + "active-key", + "active-secret"); + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("active-secret"); + using SigningKeyRing keys = SigningKeyRing.Create([revoked, active], secrets); + + Assert.Equal( + VerificationKeyLookup.Revoked, + keys.FindVerificationKey("revoked-key", ProvisioningTestData.Now, out _)); + Assert.True(keys.TryGetSigningKey( + ProvisioningTestData.Now, + PrincipalCredentialKind.DedicatedPublisher, + "space-game", + "production", + out SigningKey? signingKey)); + Assert.Equal("active-key", signingKey?.KeyId); + } + + [Fact] + public void SigningKeyAuthorityRejectsCrossGameClaimsEvenWithAValidSignature() + { + using DictionarySecretProvider secrets = ProvisioningTestData.CreateSecrets("secret-1"); + using SigningKeyRing keys = SigningKeyRing.Create( + [ProvisioningTestData.CreateKey()], + secrets); + Assert.Equal( + VerificationKeyLookup.Available, + keys.FindVerificationKey("key-1", ProvisioningTestData.Now, out SigningKey? signingKey)); + Assert.NotNull(signingKey); + + CredentialPayload payload = new() + { + Version = ContractLimits.ContractVersion, + Issuer = "final-factory-rendezvous", + Audience = "rendezvous-service", + Subject = "malicious-grant-issuer", + Kind = PrincipalCredentialKind.PlayerHostGrant, + GameId = "unscouted", + EnvironmentId = "production", + Regions = ["eu-central"], + IssuedAtUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(), + NotBeforeUnixSeconds = ProvisioningTestData.Now.ToUnixTimeSeconds(), + ExpiresAtUnixSeconds = ProvisioningTestData.Now.AddMinutes(5).ToUnixTimeSeconds(), + Nonce = Guid.NewGuid().ToString("N"), + }; + string encodedPayload = Base64Url.Encode( + System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options)); + string signedContent = $"rv1.key-1.{encodedPayload}"; + string token = $"{signedContent}.{Base64Url.Encode(signingKey.Sign(signedContent))}"; + + CredentialValidationResult result = CreateService(keys).Validate( + token, + ProvisioningTestData.Now); + + Assert.Equal(CredentialValidationError.KeyScopeMismatch, result.Error); + Assert.Null(result.Principal); + } + + private static PrincipalCredentialService CreateService(SigningKeyRing keys) => new( + "final-factory-rendezvous", + "rendezvous-service", + TimeSpan.FromSeconds(30), + keys); +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningSecurityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningSecurityTests.cs new file mode 100644 index 0000000..84f394a --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningSecurityTests.cs @@ -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( + () => 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( + () => 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 + { + ["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 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))); + } + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningTestData.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningTestData.cs new file mode 100644 index 0000000..7a75c1c --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/ProvisioningTestData.cs @@ -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(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? 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 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 { 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()], + }; +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Provisioning/PublisherAuthorizationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PublisherAuthorizationTests.cs new file mode 100644 index 0000000..c2a78d1 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/Provisioning/PublisherAuthorizationTests.cs @@ -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 ValidMetadata = + new Dictionary(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.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 { 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()])); +}