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) { if (!_keys.ContainsKey(keyId)) { return false; } _runtimeRevocations.TryAdd(keyId, 0); return true; } public IReadOnlyList GetStatuses(DateTimeOffset now) => _keys.Values .OrderBy(static key => key.KeyId, StringComparer.Ordinal) .Select(key => new SigningKeyStatus( key.KeyId, IsRevoked(key) ? "revoked" : now < key.NotBefore ? "not-yet-valid" : now < key.SignUntil ? "signing" : now < key.VerifyUntil ? "verify-only" : "retired", key.SignUntil, key.VerifyUntil, key.GameId, key.EnvironmentId, key.CredentialKinds.Select(static kind => kind.ToString()).Order().ToArray())) .ToArray(); 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 record SigningKeyStatus( string KeyId, string Status, DateTimeOffset SignUntil, DateTimeOffset VerifyUntil, string? GameId, string? EnvironmentId, IReadOnlyList CredentialKinds); 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, }