using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace GameCult.Networking
{
///
/// Provides shared encryption helpers and connection secrets for the networking layer.
///
public static class Secret
{
private static readonly Lazy DefaultOptions =
new(() => ServerSecurityOptions.FromEnvironment());
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
///
/// Configures the default server security options used by overloads that do not take an explicit options instance.
///
public static void ConfigureDefault(ServerSecurityOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
_configuredOptions = options;
}
private static ServerSecurityOptions? _configuredOptions;
///
/// Shared LiteNetLib connection key from the configured default options.
///
public static string ConnectionKey => GetDefaultOptions().ConnectionKey;
///
/// Gets a newly generated nonce suitable for AES-GCM operations.
///
public static byte[] NewNonce
{
get
{
var bytes = new byte[AesGcm.NonceByteSizes.MaxSize];
Rng.GetBytes(bytes);
return bytes;
}
}
///
/// Encrypts a string using AES-GCM.
///
/// The plaintext string to encrypt.
/// The nonce to use for the operation.
/// The encrypted payload, or null when is null or empty.
public static byte[]? EncryptString(string? input, byte[] nonce) =>
EncryptString(input, nonce, GetDefaultOptions());
///
/// Encrypts a string using AES-GCM.
///
public static byte[]? EncryptString(string? input, byte[] nonce, ClientSecurityOptions options)
{
if (string.IsNullOrEmpty(input)) return null;
return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce, options);
}
///
/// Encrypts a string using AES-GCM.
///
public static byte[]? EncryptString(string? input, byte[] nonce, ServerSecurityOptions options)
{
if (string.IsNullOrEmpty(input)) return null;
return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce, options);
}
///
/// Encrypts a byte array using AES-GCM.
///
/// The plaintext bytes to encrypt.
/// The nonce to use for the operation.
/// The authentication tag followed by the ciphertext.
public static byte[] EncryptBytes(byte[] input, byte[] nonce) =>
EncryptBytes(input, nonce, GetDefaultOptions());
///
/// Encrypts a byte array using AES-GCM.
///
public static byte[] EncryptBytes(byte[] input, byte[] nonce, ClientSecurityOptions options)
{
if (input == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid input or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var ciphertext = new byte[input.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
aes.Encrypt(nonce, input, ciphertext, tag);
var result = new byte[tag.Length + ciphertext.Length];
Buffer.BlockCopy(tag, 0, result, 0, tag.Length);
Buffer.BlockCopy(ciphertext, 0, result, tag.Length, ciphertext.Length);
return result;
}
///
/// Encrypts a byte array using AES-GCM.
///
public static byte[] EncryptBytes(byte[] input, byte[] nonce, ServerSecurityOptions options)
{
if (input == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid input or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var ciphertext = new byte[input.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
aes.Encrypt(nonce, input, ciphertext, tag);
var result = new byte[tag.Length + ciphertext.Length];
Buffer.BlockCopy(tag, 0, result, 0, tag.Length);
Buffer.BlockCopy(ciphertext, 0, result, tag.Length, ciphertext.Length);
return result;
}
///
/// Decrypts an encrypted UTF-8 string payload.
///
/// The encrypted bytes produced by the corresponding string or byte encryption overloads.
/// The nonce used during encryption.
/// The decrypted string, or null when the inputs are invalid.
public static string? DecryptString(byte[]? encrypted, byte[]? nonce) =>
DecryptString(encrypted, nonce, GetDefaultOptions());
///
/// Decrypts an encrypted UTF-8 string payload.
///
public static string? DecryptString(byte[]? encrypted, byte[]? nonce, ClientSecurityOptions options)
{
if (encrypted == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize) return null;
var decrypted = DecryptBytes(encrypted, nonce, options);
return Encoding.UTF8.GetString(decrypted);
}
///
/// Decrypts an encrypted UTF-8 string payload.
///
public static string? DecryptString(byte[]? encrypted, byte[]? nonce, ServerSecurityOptions options)
{
if (encrypted == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize) return null;
var decrypted = DecryptBytes(encrypted, nonce, options);
return Encoding.UTF8.GetString(decrypted);
}
///
/// Decrypts an AES-GCM payload into raw bytes.
///
/// The encrypted bytes containing the authentication tag prefix.
/// The nonce used during encryption.
/// The decrypted plaintext bytes.
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce) =>
DecryptBytes(encrypted, nonce, GetDefaultOptions());
///
/// Decrypts an AES-GCM payload into raw bytes.
///
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce, ClientSecurityOptions options)
{
if (encrypted == null || encrypted.Length < AesGcm.TagByteSizes.MaxSize || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid encrypted data or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var decrypted = new byte[encrypted.Length - AesGcm.TagByteSizes.MaxSize];
var tag = encrypted[..AesGcm.TagByteSizes.MaxSize];
var ciphertext = encrypted[AesGcm.TagByteSizes.MaxSize..];
aes.Decrypt(nonce, ciphertext, tag, decrypted);
return decrypted;
}
///
/// Decrypts an AES-GCM payload into raw bytes.
///
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce, ServerSecurityOptions options)
{
if (encrypted == null || encrypted.Length < AesGcm.TagByteSizes.MaxSize || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid encrypted data or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var decrypted = new byte[encrypted.Length - AesGcm.TagByteSizes.MaxSize];
var tag = encrypted[..AesGcm.TagByteSizes.MaxSize];
var ciphertext = encrypted[AesGcm.TagByteSizes.MaxSize..];
aes.Decrypt(nonce, ciphertext, tag, decrypted);
return decrypted;
}
///
/// Creates a signed session token for the supplied user.
///
/// The authenticated user identifier.
/// The UTC expiration time for the token.
/// A tamper-evident session token.
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc) =>
CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, GetDefaultOptions());
///
/// Creates a signed session token for the supplied user.
///
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options) =>
CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, options);
///
/// Creates a signed session token for the supplied user and session version.
///
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, long sessionVersion, ServerSecurityOptions options)
{
var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}|{sessionVersion.ToString(CultureInfo.InvariantCulture)}";
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var signatureBytes = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes);
return $"{ToBase64Url(payloadBytes)}.{ToBase64Url(signatureBytes)}";
}
///
/// Validates and parses a signed session token.
///
/// The token to validate.
/// The extracted user identifier.
/// The extracted UTC expiration time.
/// true when the token is valid and unexpired.
public static bool TryValidateSessionToken(string? token, out Guid userId, out DateTimeOffset expiresAtUtc) =>
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out _);
///
/// Validates and parses a signed session token.
///
public static bool TryValidateSessionToken(
string? token,
out Guid userId,
out DateTimeOffset expiresAtUtc,
out long sessionVersion) =>
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out sessionVersion);
///
/// Validates and parses a signed session token.
///
public static bool TryValidateSessionToken(
string? token,
ServerSecurityOptions options,
out Guid userId,
out DateTimeOffset expiresAtUtc)
{
return TryValidateSessionToken(token, options, out userId, out expiresAtUtc, out _);
}
///
/// Validates and parses a signed session token.
///
public static bool TryValidateSessionToken(
string? token,
ServerSecurityOptions options,
out Guid userId,
out DateTimeOffset expiresAtUtc,
out long sessionVersion)
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
if (string.IsNullOrWhiteSpace(token))
return false;
var parts = token.Split('.');
if (parts.Length != 2)
return false;
try
{
var payloadBytes = FromBase64Url(parts[0]);
var signatureBytes = FromBase64Url(parts[1]);
var expectedSignature = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes);
if (!CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature))
return false;
var payload = Encoding.UTF8.GetString(payloadBytes);
var payloadParts = payload.Split('|');
if ((payloadParts.Length != 2 && payloadParts.Length != 3) ||
!Guid.TryParseExact(payloadParts[0], "N", out userId) ||
!long.TryParse(payloadParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var expiryUnixSeconds))
{
userId = Guid.Empty;
return false;
}
if (payloadParts.Length == 3 &&
!long.TryParse(payloadParts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out sessionVersion))
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
return false;
}
expiresAtUtc = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds);
return expiresAtUtc > DateTimeOffset.UtcNow;
}
catch (FormatException)
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
sessionVersion = 0;
return false;
}
}
private static string ToBase64Url(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] FromBase64Url(string input)
{
var padded = input
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2:
padded += "==";
break;
case 3:
padded += "=";
break;
}
return Convert.FromBase64String(padded);
}
private static byte[] ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(data);
}
private static byte[] ComputeHmacSha256(byte[] key, byte[] data)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(data);
}
private static ServerSecurityOptions GetDefaultOptions()
{
return _configuredOptions ?? DefaultOptions.Value;
}
}
}