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