313 lines
10 KiB
C#
313 lines
10 KiB
C#
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)
|
||
{
|
||
if (!_keys.ContainsKey(keyId))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
_runtimeRevocations.TryAdd(keyId, 0);
|
||
return true;
|
||
}
|
||
|
||
public IReadOnlyList<SigningKeyStatus> 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<string> 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<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,
|
||
}
|