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; } } }