Class CryptoHelper
- Namespace
- FishMMO.Auth.Implementation
- Assembly
- FishMMO-AuthShared.dll
Static class providing cryptographic helper methods for X25519 ECDH key agreement, AES-256-GCM authenticated encryption, HKDF-SHA256 key derivation, HMAC-SHA256 signing, and authentication token management. BouncyCastle is used for cross-platform support on all Unity targets.
public static class CryptoHelper
- Inheritance
-
CryptoHelper
- Inherited Members
Fields
AadLength
Length in bytes of an AAD buffer produced by BuildAad(byte, ushort, uint).
public const int AadLength = 7
Field Value
AesGcmTagLengthBytes
Length in bytes of AES-GCM authentication tag.
public const int AesGcmTagLengthBytes = 16
Field Value
GcmNonceLength
Required length for a GCM nonce in bytes.
public const int GcmNonceLength = 12
Field Value
HandshakeDomainSeparator
Domain separation prefix hashed into the handshake transcript. Prevents cross-protocol transcript reuse and future downgrade attacks.
public static readonly byte[] HandshakeDomainSeparator
Field Value
- byte[]
HmacKeyLength
HMAC-SHA256 key length in bytes.
public const int HmacKeyLength = 32
Field Value
HmacSha512KeyLength
HMAC-SHA512 optimal key length in bytes. Used for keying HMAC-SHA512 operations (e.g., fake SRP salt derivation).
public const int HmacSha512KeyLength = 64
Field Value
HmacTagLength
HMAC-SHA256 output tag length in bytes. Semantically distinct from HmacKeyLength even though both are 32 for SHA-256.
public const int HmacTagLength = 32
Field Value
MaxAesCiphertextSize
Maximum allowed AES ciphertext size in bytes to prevent oversized allocations. This limit covers the complete GCM output (ciphertext + 16-byte authentication tag).
public const int MaxAesCiphertextSize = 65536
Field Value
MaxGcmNonceCounter
Maximum GCM nonce counter value (7 bytes = 2^56 − 1). This is the theoretical nonce-space limit for the 7-byte counter field in the 12-byte GCM nonce layout. In practice, NextNonce() caps the counter at MaxValue (2^32 − 1) because the sequence numbers exchanged in wire messages are 32-bit. ShouldRekey uses uint.MaxValue as the practical maximum accordingly.
public const ulong MaxGcmNonceCounter = 72057594037927935
Field Value
MaxSrpPayloadBytes
Maximum allowed size in bytes for any single encrypted SRP payload field. Prevents oversized payloads from consuming AES decryption CPU on workers. Enforcement is at the protocol layer (server broadcast handlers) rather than inside crypto helpers, because the limit is transport-specific.
public const int MaxSrpPayloadBytes = 1024
Field Value
MaxSupportedProtocolVersion
Maximum protocol version this build supports.
public const ushort MaxSupportedProtocolVersion = 1
Field Value
MaxTokenLifetimeMinutes
Maximum allowed token lifetime in minutes for BuildAuthToken(string, long, long, DateTime, byte[], AccessLevel, string?). Caps the blast radius of a token compromise by preventing tokens with excessively long validity windows.
public const int MaxTokenLifetimeMinutes = 60
Field Value
MinSignedTokenLength
Minimum valid signed token length: 1 (version) + 1 (tokenType) + 2 (nameLen) + 1 (name) + 1 (accessLevel) + 8 (serverId) + 8 (signingKeyId) + 8 (ticks) + 16 (nonce) + 32 (HMAC).
public const int MinSignedTokenLength = 79
Field Value
MinSupportedProtocolVersion
Minimum protocol version this build supports.
public const ushort MinSupportedProtocolVersion = 1
Field Value
ProtocolVersion
Protocol version used for AAD binding. Increment when protocol-level changes occur.
public const ushort ProtocolVersion = 1
Field Value
SessionPrefixLength
Required length for a GCM session prefix in bytes.
public const int SessionPrefixLength = 4
Field Value
StrictUtf8
Strict UTF-8 decoder that rejects malformed byte sequences instead of silently replacing them with U+FFFD. Used for security-sensitive token parsing and SRP decryption.
public static readonly UTF8Encoding StrictUtf8
Field Value
TokenFormatVersion
Token format version embedded in auth tokens. Decoupled from ProtocolVersion so that protocol negotiation changes do not silently invalidate outstanding tokens. Increment only when the token wire format changes.
public const byte TokenFormatVersion = 4
Field Value
TokenTypeAuth
Token type discriminator for authentication tokens. Prevents cross-purpose token reuse across different subsystems.
public const byte TokenTypeAuth = 1
Field Value
X25519PublicKeyLength
Length in bytes of an X25519 public key.
public const int X25519PublicKeyLength = 32
Field Value
Methods
BuildAad(byte, ushort, uint)
Builds AAD from protocol metadata to be bound into AES-GCM authentication. Layout: [1-byte messageType][2-byte version big-endian][4-byte sequence big-endian].
public static byte[] BuildAad(byte messageType, ushort version, uint sequence)
Parameters
Returns
- byte[]
BuildAuthToken(string, long, long, DateTime, byte[], AccessLevel, string?)
Builds a signed authentication token for use in World/Scene server token-based authentication. Layout (v4): [1B version][1B tokenType][2B nameLen BE][name UTF-8][1B accessLevel][8B serverId BE][8B signingKeyId BE][8B ticks BE][1B ipLen][realIp UTF-8][16B nonce][32B HMAC]. The HMAC covers the entire payload (everything except the trailing 32-byte HMAC). Token format version and token type are included in the HMAC, preventing token reuse across format versions or different token subsystems.
public static byte[] BuildAuthToken(string accountName, long loginServerId, long signingKeyId, DateTime expiresUtc, byte[] hmacKey, AccessLevel accessLevel, string? realIp = null)
Parameters
accountNamestringAccount name to embed in the token.
loginServerIdlongDatabase ID of the issuing LoginServer.
signingKeyIdlongDatabase ID of the HMAC signing key used to sign this token.
expiresUtcDateTimeUTC expiration time for the token.
hmacKeybyte[]32-byte HMAC signing key.
accessLevelAccessLevelAccount access level to embed in the token.
realIpstring
Returns
- byte[]
Signed token as a byte array (payload + HMAC).
Remarks
Replay mitigation: Tokens are bearer tokens with a random nonce but no server-side single-use enforcement. Replay is bounded by the expiration window. Nonce uniqueness is not tracked because the same token may be legitimately presented to multiple World/Scene servers within its lifetime (e.g., server transfers).
For additional protection the issuing LoginServer stores HashTokenHex(byte[]) on issuance, and World/Scene servers check revocation via the database before accepting a token. Explicit logout or key rotation revokes all outstanding tokens for an account.
BuildGcmNonce(byte[], ulong, bool)
Builds a 12-byte GCM nonce from a session prefix, explicit message sequence number,
and direction flag. Callers MUST provide an explicit, monotonic sequence number
(for example obtained via Interlocked.Increment) to avoid implicit ordering.
Layout: [4-byte prefix][1-byte direction][7-byte counter big-endian].
The direction byte prevents collision between client→server and server→client nonces
for the same sequence number.
public static byte[] BuildGcmNonce(byte[] sessionPrefix, ulong counter, bool serverToClient)
Parameters
sessionPrefixbyte[]4-byte random prefix unique to the session.
counterulongExplicit message sequence number (ulong). Must not exceed MaxGcmNonceCounter.
serverToClientbooltruefor server→client messages;falsefor client→server messages.
Returns
- byte[]
A 12-byte nonce suitable for AES-GCM.
Remarks
Prefix uniqueness: Each session’s prefix is derived from a unique master secret via DeriveSessionKeys(byte[], byte[], int), giving statistical uniqueness within a 4-byte (2³²) space. Combined with the direction byte and monotonic counter, the full 12-byte nonce is unique per (session, direction, message) tuple. Callers must ensure that each session uses a fresh master secret so that prefixes do not repeat across sessions.
The 7-byte counter supports up to 2^56 − 1 messages per direction — centuries at 1 M packets/sec. Callers should rekey the session well before exhaustion (see DeriveSessionKeys(byte[], byte[], int) remarks).
Exceptions
- CryptographicException
Thrown if
counterexceeds MaxGcmNonceCounter.
DecryptAES(byte[], byte[], byte[], byte[])
AES-GCM decrypt with Additional Authenticated Data (AAD).
public static byte[] DecryptAES(byte[] symmetricKey, byte[] iv, byte[] input, byte[] aad)
Parameters
Returns
- byte[]
Decrypted plaintext. Callers must zeroize the returned array via ZeroMemory(byte[]) when no longer needed, as it contains the original secret data.
Remarks
Callers MUST treat a thrown CryptographicException as fatal to the connection — do not continue the session after a GCM authentication tag mismatch.
DeriveSessionKeys(byte[], byte[], int)
Derives directional AES keys and per-direction session prefixes from a single master secret.
The handshakeTranscriptHash MUST be bound into the derivation to prevent handshake tampering.
public static CryptoHelper.SessionKeys DeriveSessionKeys(byte[] masterSecret, byte[] handshakeTranscriptHash, int aesKeyLength = 32)
Parameters
Returns
Remarks
Rekey strategy: Callers should trigger a rekey (regenerate master secret and re-derive session keys) under any of the following conditions:
- The GCM nonce counter approaches MaxGcmNonceCounter.
- A configurable time interval elapses (recommended: every 10–30 minutes).
- A server transfer or reconnection occurs.
Rekeying provides forward secrecy and bounds the blast radius of any single key compromise.
Reconnect safety: Session prefixes are derived from the master secret. A fresh master secret MUST be negotiated on every new connection or reconnect to guarantee prefix uniqueness. Reusing a master secret across connections would produce identical prefixes and catastrophically break GCM nonce uniqueness.
Key material lifetime: This method zeroes masterSecret
in its finally block before returning — even if HKDF throws mid-derivation.
This is intentional: from a security perspective, a partially-derived state must not
leave the master secret accessible. Callers must not reference masterSecret
after this call. On HKDF failure, callers should tear down the connection and
re-handshake to negotiate a fresh master secret.
Destroy(byte[])
Convenience wrapper around ZeroMemory(byte[]) for code clarity. Zeroes all bytes in the given key material. Call on disconnect, logout, or rekey to destroy sensitive key material. Null arrays are safely ignored.
public static void Destroy(byte[] key)
Parameters
keybyte[]The key material to zeroize, or null.
EncryptAES(byte[], byte[], byte[], byte[])
AES-GCM encrypt with Additional Authenticated Data (AAD).
public static byte[] EncryptAES(byte[] symmetricKey, byte[] iv, byte[] input, byte[] aad)
Parameters
symmetricKeybyte[]AES-256 encryption key.
ivbyte[]12-byte GCM nonce (see BuildGcmNonce(byte[], ulong, bool)).
inputbyte[]Plaintext to encrypt. Callers should zero this array after the call if it contains sensitive data (e.g., SRP proofs, token payloads).
aadbyte[]Additional authenticated data bound into the GCM tag.
Returns
- byte[]
Ciphertext including the GCM authentication tag. The return value is not secret and does not require zeroization by callers.
Remarks
The intermediate output buffer is heap-allocated and zeroed in a finally block.
ArrayPool would reduce GC pressure, but System.Buffers has an ambient assembly
conflict in this project (duplicate with netstandard2.1). Revisit if that is resolved.
FixedTimeEquals(byte[], byte[])
Compares two byte spans in constant time to prevent timing side-channel attacks. Delegates to FixedTimeEquals(byte[], byte[]) which is guaranteed not to short-circuit on the first differing byte.
public static bool FixedTimeEquals(byte[] left, byte[] right)
Parameters
Returns
- bool
trueif both arrays are non-null, equal length, and contain the same bytes; otherwisefalse.
GenerateKey(int)
Generates a cryptographically secure random key of the specified length in bytes.
public static byte[] GenerateKey(int length)
Parameters
lengthintLength of the key in bytes.
Returns
- byte[]
Randomly generated key as a byte array.
HashTokenHex(byte[])
Computes the SHA-256 hash of a byte array and returns it as a lowercase hex string. Used to derive a token fingerprint for database revocation checks.
public static string HashTokenHex(byte[] token)
Parameters
tokenbyte[]The signed token to hash.
Returns
- string
Lowercase hex SHA-256 hash string.
Remarks
GC note: The returned string persists on the managed heap until collected. This is acceptable because the hash is not secret — it is a one-way fingerprint stored in the database for revocation lookups. The raw token (input) should be zeroed by callers after this call.
IsValidX25519PublicKey(byte[])
Validates a peer-supplied X25519 public key against the
well-known small-order point blacklist (RFC 7748 §6.1). Returns false
for any input that would collapse the ECDH shared secret to a predictable
value regardless of the local private key.
public static bool IsValidX25519PublicKey(byte[] publicKey)
Parameters
publicKeybyte[]32-byte X25519 public key (peer-supplied).
Returns
- bool
trueif the key is not on the small-order blacklist.
Remarks
The blacklist below covers the seven distinct small-order x-coordinates plus their high-bit-set variants (X25519 implementations mask off the top bit, so both forms must be rejected explicitly).
Comparison is byte-wise but constant-time per entry to avoid leaking which (if any) blacklist entry matched. This is informational — a malicious peer already knows their own public key — but reduces the risk of side-channel regressions in the surrounding handshake code.
NegotiateProtocolVersion(ushort, ushort)
Negotiates the highest common protocol version between local and peer version ranges.
Called by the server after receiving the client's version range in ClientHandshake.
The agreed version is bound into HKDF labels and AAD for all subsequent messages.
public static ushort NegotiateProtocolVersion(ushort peerMin, ushort peerMax)
Parameters
Returns
- ushort
The highest mutually supported version.
Exceptions
- CryptographicException
Thrown if no common version exists.
SignHmacSha256(byte[], byte[])
Computes an HMAC-SHA256 over the given data using the specified key.
public static byte[] SignHmacSha256(byte[] key, byte[] data)
Parameters
keybyte[]HMAC key (must be exactly HmacKeyLength bytes).
databyte[]Data to authenticate.
Returns
- byte[]
32-byte HMAC-SHA256 tag.
TryParseAndVerifyAuthToken(byte[], byte[], out string?, out long, out long, out AccessLevel, out DateTime, out string?)
Parses and verifies an authentication token's HMAC signature. Does NOT check expiration or revocation — callers must validate those separately.
public static bool TryParseAndVerifyAuthToken(byte[] signedToken, byte[] hmacKey, out string? accountName, out long loginServerId, out long signingKeyId, out AccessLevel accessLevel, out DateTime expiresUtc, out string? realIp)
Parameters
signedTokenbyte[]Signed token bytes (payload + HMAC).
hmacKeybyte[]32-byte HMAC verification key.
accountNamestringParsed account name (null on failure).
loginServerIdlongParsed LoginServer database ID (0 on failure).
signingKeyIdlongParsed signing-key database ID (0 on failure).
accessLevelAccessLevelParsed access level (AccessLevel.Player on failure).
expiresUtcDateTimeParsed UTC expiration (DateTime.MinValue on failure).
realIpstring
Returns
- bool
trueif the HMAC is valid and the token is well-formed; otherwisefalse.
ValidateSequenceRange(uint, uint)
Validates that a base sequence number is large enough to derive fieldCount
sub-sequences via seq - (fieldCount - 1) through seq without underflow.
Use before any seq - N arithmetic in protocol handlers to prevent uint wrap-around.
public static bool ValidateSequenceRange(uint seq, uint fieldCount)
Parameters
sequintThe base (highest) sequence number from the message.
fieldCountuintTotal number of fields encoded (e.g., 5 for CreateAccount, 2 for SrpVerify).
Returns
- bool
trueifseq >= fieldCount - 1;falseif subtraction would underflow.
VerifyHmacSha256(byte[], byte[], byte[])
Verifies an HMAC-SHA256 signature in constant time.
public static bool VerifyHmacSha256(byte[] key, byte[] data, byte[] signature)
Parameters
keybyte[]HMAC key (must be 32 bytes).
databyte[]Data that was authenticated.
signaturebyte[]Expected 32-byte HMAC-SHA256 tag.
Returns
- bool
trueif the signature is valid; otherwisefalse.
WriteAad(byte[], byte, ushort, uint)
Writes AAD directly into a caller-supplied buffer, avoiding a heap allocation. Use on hot paths (e.g., per-field encryption in a multi-field message) where thousands of 7-byte arrays per second would pressure the GC.
public static void WriteAad(byte[] destination, byte messageType, ushort version, uint sequence)