Files
Rendezvous/src/FinalFactory.Rendezvous.Server/Provisioning/SigningKeyRing.cs
T
KyuubiYoru 47382ddadc
quality-gate / quality (push) Successful in 50s
feat: add tenant provisioning and key lifecycle (#5)
Closes #5
2026-07-16 05:13:35 +02:00

276 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Provisioning;
internal sealed class SigningKeyRing : IDisposable
{
private readonly Dictionary<string, SigningKey> _keys;
private readonly ConcurrentDictionary<string, byte> _runtimeRevocations =
new(StringComparer.Ordinal);
private SigningKeyRing(Dictionary<string, SigningKey> keys) => _keys = keys;
public bool HasKeys => _keys.Count > 0;
public static SigningKeyRing Create(
IEnumerable<SigningKeyOptions> options,
ISecretProvider secretProvider)
{
SigningKeyOptions[] configuredKeys = options.ToArray();
if (configuredKeys.Length > ProvisioningLimits.MaxSigningKeys)
{
throw new ProvisioningConfigurationException(
$"At most {ProvisioningLimits.MaxSigningKeys} signing keys may be configured.");
}
Dictionary<string, SigningKey> keys = new(StringComparer.Ordinal);
try
{
foreach (SigningKeyOptions keyOptions in configuredKeys)
{
Validate(keyOptions);
if (keys.ContainsKey(keyOptions.KeyId))
{
throw new ProvisioningConfigurationException(
$"Duplicate signing key ID '{keyOptions.KeyId}'.");
}
if (keyOptions.Revoked)
{
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, null));
continue;
}
if (!secretProvider.TryGetSecret(
keyOptions.SecretReference,
out SecretMaterial? material)
|| material is null)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' has no available key material.");
}
using (material)
{
if (material.Length < 32)
{
throw new ProvisioningConfigurationException(
$"Signing key '{keyOptions.KeyId}' must contain at least 32 bytes.");
}
keys.Add(keyOptions.KeyId, new SigningKey(keyOptions, material.CopyBytes()));
}
}
return new SigningKeyRing(keys);
}
catch
{
foreach (SigningKey key in keys.Values)
{
key.Dispose();
}
throw;
}
}
public bool HasActiveSigningKey(DateTimeOffset now) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil);
public bool HasActiveSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) => _keys.Values.Any(key =>
!IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId));
public bool TryGetSigningKey(
DateTimeOffset now,
PrincipalCredentialKind kind,
string? gameId,
string? environmentId,
out SigningKey? signingKey)
{
signingKey = _keys.Values
.Where(key => !IsRevoked(key)
&& key.NotBefore <= now
&& now < key.SignUntil
&& key.Authorizes(kind, gameId, environmentId))
.OrderByDescending(static key => key.NotBefore)
.ThenByDescending(static key => key.KeyId, StringComparer.Ordinal)
.FirstOrDefault();
return signingKey is not null;
}
public VerificationKeyLookup FindVerificationKey(
string keyId,
DateTimeOffset now,
out SigningKey? signingKey)
{
signingKey = null;
if (!_keys.TryGetValue(keyId, out SigningKey? candidate))
{
return VerificationKeyLookup.Unknown;
}
if (IsRevoked(candidate))
{
return VerificationKeyLookup.Revoked;
}
if (now < candidate.NotBefore)
{
return VerificationKeyLookup.NotYetValid;
}
if (now >= candidate.VerifyUntil)
{
return VerificationKeyLookup.Retired;
}
signingKey = candidate;
return VerificationKeyLookup.Available;
}
public bool Revoke(string keyId) =>
_keys.ContainsKey(keyId) && _runtimeRevocations.TryAdd(keyId, 0);
public void Dispose()
{
foreach (SigningKey key in _keys.Values)
{
key.Dispose();
}
_keys.Clear();
_runtimeRevocations.Clear();
}
public override string ToString() => $"[SigningKeyRing: {_keys.Count} keys, material redacted]";
private bool IsRevoked(SigningKey key) =>
key.ConfiguredRevoked || _runtimeRevocations.ContainsKey(key.KeyId);
private static void Validate(SigningKeyOptions options)
{
if (string.IsNullOrEmpty(options.KeyId)
|| options.KeyId.Length > 64
|| options.KeyId.Any(static character =>
character is not (>= 'A' and <= 'Z')
and not (>= 'a' and <= 'z')
and not (>= '0' and <= '9')
and not '-'
and not '_'))
{
throw new ProvisioningConfigurationException(
"Signing key IDs must be 164 base64url characters.");
}
if (string.IsNullOrWhiteSpace(options.SecretReference)
|| options.NotBefore >= options.SignUntil
|| options.SignUntil > options.VerifyUntil)
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' has an invalid secret reference or lifetime.");
}
if (options.CredentialKinds.Count == 0
|| options.CredentialKinds.Any(static kind => !Enum.IsDefined(kind))
|| options.CredentialKinds.Count != options.CredentialKinds.Distinct().Count())
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' requires unique valid credential kinds.");
}
bool operatorKey = options.CredentialKinds.Contains(PrincipalCredentialKind.Operator);
bool hasPublisherKind = options.CredentialKinds.Any(static kind =>
kind is PrincipalCredentialKind.DedicatedPublisher
or PrincipalCredentialKind.PlayerHostGrant);
if (operatorKey
? options.CredentialKinds.Count != 1
|| options.GameId is not null
|| options.EnvironmentId is not null
: !hasPublisherKind
|| !GameId.TryParse(options.GameId, out _)
|| !EnvironmentId.TryParse(options.EnvironmentId, out _))
{
throw new ProvisioningConfigurationException(
$"Signing key '{options.KeyId}' must be operator-only or bound to one game/environment.");
}
}
}
internal sealed class SigningKey : IDisposable
{
private byte[]? _material;
public SigningKey(SigningKeyOptions options, byte[]? material)
{
KeyId = options.KeyId;
NotBefore = options.NotBefore;
SignUntil = options.SignUntil;
VerifyUntil = options.VerifyUntil;
ConfiguredRevoked = options.Revoked;
CredentialKinds = options.CredentialKinds.ToFrozenSet();
GameId = options.GameId;
EnvironmentId = options.EnvironmentId;
_material = material;
}
public string KeyId { get; }
public DateTimeOffset NotBefore { get; }
public DateTimeOffset SignUntil { get; }
public DateTimeOffset VerifyUntil { get; }
public bool ConfiguredRevoked { get; }
public IReadOnlySet<PrincipalCredentialKind> CredentialKinds { get; }
public string? GameId { get; }
public string? EnvironmentId { get; }
public bool Authorizes(
PrincipalCredentialKind kind,
string? gameId,
string? environmentId) =>
CredentialKinds.Contains(kind)
&& (kind == PrincipalCredentialKind.Operator
? gameId is null && environmentId is null
: string.Equals(GameId, gameId, StringComparison.Ordinal)
&& string.Equals(EnvironmentId, environmentId, StringComparison.Ordinal));
public byte[] Sign(string input)
{
ObjectDisposedException.ThrowIf(_material is null, this);
return HMACSHA256.HashData(_material, Encoding.ASCII.GetBytes(input));
}
public void Dispose()
{
if (_material is not null)
{
CryptographicOperations.ZeroMemory(_material);
_material = null;
}
}
public override string ToString() => $"[SigningKey {KeyId}: material redacted]";
}
internal enum VerificationKeyLookup
{
Available = 0,
Unknown = 1,
Revoked = 2,
NotYetValid = 3,
Retired = 4,
}