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,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 1–128 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user