452 lines
15 KiB
C#
452 lines
15 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|