Table of Contents

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

int

AesGcmTagLengthBytes

Length in bytes of AES-GCM authentication tag.

public const int AesGcmTagLengthBytes = 16

Field Value

int

GcmNonceLength

Required length for a GCM nonce in bytes.

public const int GcmNonceLength = 12

Field Value

int

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

int

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

int

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

int

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

int

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

ulong

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

int

MaxSupportedProtocolVersion

Maximum protocol version this build supports.

public const ushort MaxSupportedProtocolVersion = 1

Field Value

ushort

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

int

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

int

MinSupportedProtocolVersion

Minimum protocol version this build supports.

public const ushort MinSupportedProtocolVersion = 1

Field Value

ushort

ProtocolVersion

Protocol version used for AAD binding. Increment when protocol-level changes occur.

public const ushort ProtocolVersion = 1

Field Value

ushort

SessionPrefixLength

Required length for a GCM session prefix in bytes.

public const int SessionPrefixLength = 4

Field Value

int

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

UTF8Encoding

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

byte

TokenTypeAuth

Token type discriminator for authentication tokens. Prevents cross-purpose token reuse across different subsystems.

public const byte TokenTypeAuth = 1

Field Value

byte

X25519PublicKeyLength

Length in bytes of an X25519 public key.

public const int X25519PublicKeyLength = 32

Field Value

int

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

messageType byte
version ushort
sequence uint

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

accountName string

Account name to embed in the token.

loginServerId long

Database ID of the issuing LoginServer.

signingKeyId long

Database ID of the HMAC signing key used to sign this token.

expiresUtc DateTime

UTC expiration time for the token.

hmacKey byte[]

32-byte HMAC signing key.

accessLevel AccessLevel

Account access level to embed in the token.

realIp string

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

sessionPrefix byte[]

4-byte random prefix unique to the session.

counter ulong

Explicit message sequence number (ulong). Must not exceed MaxGcmNonceCounter.

serverToClient bool

true for server→client messages; false for 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 counter exceeds 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

symmetricKey byte[]
iv byte[]
input byte[]
aad byte[]

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

masterSecret byte[]
handshakeTranscriptHash byte[]
aesKeyLength int

Returns

CryptoHelper.SessionKeys

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

key byte[]

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

symmetricKey byte[]

AES-256 encryption key.

iv byte[]

12-byte GCM nonce (see BuildGcmNonce(byte[], ulong, bool)).

input byte[]

Plaintext to encrypt. Callers should zero this array after the call if it contains sensitive data (e.g., SRP proofs, token payloads).

aad byte[]

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

left byte[]

First byte array.

right byte[]

Second byte array.

Returns

bool

true if both arrays are non-null, equal length, and contain the same bytes; otherwise false.

GenerateKey(int)

Generates a cryptographically secure random key of the specified length in bytes.

public static byte[] GenerateKey(int length)

Parameters

length int

Length 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

token byte[]

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

publicKey byte[]

32-byte X25519 public key (peer-supplied).

Returns

bool

true if 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

peerMin ushort

Peer's minimum supported version.

peerMax ushort

Peer's maximum supported version.

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

key byte[]

HMAC key (must be exactly HmacKeyLength bytes).

data byte[]

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

signedToken byte[]

Signed token bytes (payload + HMAC).

hmacKey byte[]

32-byte HMAC verification key.

accountName string

Parsed account name (null on failure).

loginServerId long

Parsed LoginServer database ID (0 on failure).

signingKeyId long

Parsed signing-key database ID (0 on failure).

accessLevel AccessLevel

Parsed access level (AccessLevel.Player on failure).

expiresUtc DateTime

Parsed UTC expiration (DateTime.MinValue on failure).

realIp string

Returns

bool

true if the HMAC is valid and the token is well-formed; otherwise false.

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

seq uint

The base (highest) sequence number from the message.

fieldCount uint

Total number of fields encoded (e.g., 5 for CreateAccount, 2 for SrpVerify).

Returns

bool

true if seq >= fieldCount - 1; false if 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

key byte[]

HMAC key (must be 32 bytes).

data byte[]

Data that was authenticated.

signature byte[]

Expected 32-byte HMAC-SHA256 tag.

Returns

bool

true if the signature is valid; otherwise false.

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)

Parameters

destination byte[]

Must be at least AadLength bytes.

messageType byte
version ushort
sequence uint