feat: add tenant provisioning and key lifecycle (#5)
quality-gate / quality (push) Successful in 50s

Closes #5
This commit is contained in:
KyuubiYoru
2026-07-16 05:13:35 +02:00
parent 69c8b2d2bc
commit 47382ddadc
21 changed files with 2294 additions and 6 deletions
@@ -0,0 +1,451 @@
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 1128 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<CredentialPayload>(
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<OperatorPermission>(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<RegionId> 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<string> Regions { get; set; } = [];
public List<OperatorPermission> 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<byte> 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;
}
}
}