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; } } }