Table of Contents

Class CryptoHelper.TwoFactor

Namespace
FishMMO.Auth.Implementation
Assembly
FishMMO-AuthShared.dll

TOTP two-factor authentication helpers. Uses OtpNet for TOTP generation/verification, AES-256-GCM for encrypting TOTP secrets at rest, and PBKDF2-SHA256 for hashing recovery codes.

public static class CryptoHelper.TwoFactor
Inheritance
CryptoHelper.TwoFactor
Inherited Members

Fields

DefaultRecoveryCodeCount

Default number of recovery codes generated per account.

public const int DefaultRecoveryCodeCount = 8

Field Value

int

TotpSecretLength

TOTP secret length in bytes (160 bits per RFC 4226 §4).

public const int TotpSecretLength = 20

Field Value

int

Methods

BuildOtpauthUri(byte[], string, string)

Builds an otpauth:// URI for use with authenticator apps (Google Authenticator, Authy, etc.).

public static string BuildOtpauthUri(byte[] secret, string accountName, string issuer = "FishMMO")

Parameters

secret byte[]

Plaintext TOTP secret.

accountName string

Account name to display in the authenticator app.

issuer string

Issuer name (application name).

Returns

string

otpauth://totp/ URI string.

DecryptTotpSecret(byte[], string, string)

Decrypts a v2 TOTP at-rest envelope produced by EncryptTotpSecret(byte[], string, byte[], int). Older single-key envelopes are NOT supported; rotation must be performed by out-of-band re-wrap tooling that decrypts under the old scheme and re-encrypts via this method.

public static byte[] DecryptTotpSecret(byte[] masterKek, string username, string storedValue)

Parameters

masterKek byte[]

32-byte persistent server master KEK.

username string

Account name the envelope belongs to. Must match the value supplied at encrypt-time (case-insensitive); a mismatch causes the GCM tag to fail.

storedValue string

Base64-encoded envelope from EncryptTotpSecret(byte[], string, byte[], int).

Returns

byte[]

Plaintext TOTP secret. Caller must zeroize when done.

EncryptTotpSecret(byte[], string, byte[], int)

Encrypts a TOTP secret for at-rest storage using AES-256-GCM under a per-user data-encryption key derived from the server master KEK via HKDF-SHA256.

public static string EncryptTotpSecret(byte[] masterKek, string username, byte[] plaintextSecret, int kekVersion = 1)

Parameters

masterKek byte[]

32-byte persistent server master KEK (NOT the data key).

username string

Account name the secret belongs to. Normalised to lowercase invariant for binding.

plaintextSecret byte[]

Plaintext TOTP secret (typically 20 bytes).

kekVersion int

Master-KEK version identifier (>= 1). Allows offline re-wrap on key rotation.

Returns

string

Base64-encoded envelope suitable for direct storage.

Remarks

Envelope layout (binary, then base64-encoded):

[1B  version  = TotpEnvelopeVersion]
[4B  kekVersion (big-endian uint32)]
[16B salt    (random, HKDF salt)]
[12B nonce   (random, GCM IV)]
[N B ciphertext + 16B GCM tag]

Per-user data key = HKDF-SHA256(IKM=masterKek, salt=salt, info="totp|"+lower(username)+"|kv="+kekVersion, L=32). AAD = UTF-8 of "fishmmo-totp-secret-v2|"+lower(username)+"|kv="+kekVersion. Both the salt and the username binding make every envelope unique per account and prevent ciphertext transplant across users or KEK versions.

GenerateRecoveryCodes(int)

Generates a set of single-use recovery codes in XXXX-XXXX-XXXX-XXXX format. Each code carries 64 bits of entropy (8 random bytes, 16 hex chars).

public static string[] GenerateRecoveryCodes(int count = 8)

Parameters

count int

Number of codes to generate.

Returns

string[]

Array of plaintext recovery codes.

GenerateTotpSecret()

Generates a cryptographically random TOTP secret.

public static byte[] GenerateTotpSecret()

Returns

byte[]

20-byte secret suitable for TOTP.

HashRecoveryCode(string, string)

Hashes a recovery code with PBKDF2-SHA256 for secure at-rest storage.

public static string HashRecoveryCode(string accountId, string plaintextCode)

Parameters

accountId string

Account identifier (e.g. username). Bound into the PBKDF2 input so a stolen recovery-code hash cannot be reused across accounts even if the cleartext code happens to collide.

plaintextCode string

Plaintext recovery code.

Returns

string

String in format "v2:<iterations>:base64(salt):base64(hash)" for database storage.

VerifyRecoveryCode(string, string, string)

Verifies a submitted recovery code against a stored PBKDF2-SHA256 hash. Accepts both the v2 envelope ("v2:<iters>:salt:hash" — account-bound) and the legacy ("salt:hash" — not account-bound, 100_000 iterations) format.

public static bool VerifyRecoveryCode(string accountId, string submitted, string storedHash)

Parameters

accountId string

Account identifier. Used only when verifying v2 hashes.

submitted string

Plaintext recovery code submitted by the user.

storedHash string

Stored hash in v2 or legacy envelope format.

Returns

bool

true if the code matches; false otherwise.

Remarks

WARNING: Legacy format cross-account reuse. The legacy "salt:hash" format hashes only the plaintext recovery code with a per-code salt, without binding to the account identifier. This means the same recovery code (or two codes that happen to produce the same PBKDF2 output with different salts) could, in a pathological collision scenario, be verified against an unintended account's stored hash. The v2 envelope mitigates this by incorporating the account ID into the PBKDF2 input (via accountId + "|" + normalizedCode), making each hash account-specific.

All newly-generated recovery codes use the v2 envelope. The legacy parser is retained only for backward-compatible verification of hashes written before the v2 migration. Consider rotating any remaining legacy hashes to v2 during a maintenance window to eliminate this class of risk entirely.

VerifyTotpCode(byte[], string, long)

Verifies a TOTP code with a ±1 step verification window and anti-replay protection.

public static (bool Valid, long WindowUsed) VerifyTotpCode(byte[] plaintextSecret, string submittedCode, long lastWindow)

Parameters

plaintextSecret byte[]

Decrypted TOTP secret.

submittedCode string

6-digit TOTP code from the user.

lastWindow long

Last successfully used TOTP time window (for anti-replay). Pass 0 for first use.

Returns

(bool Valid, long WindowUsed)

Tuple: (valid, windowUsed). If valid, windowUsed should be stored for anti-replay.

Remarks

Heap retention: The OtpNet Totp constructor copies plaintextSecret into an internal managed array that cannot be zeroed without reflection. The Totp object becomes GC-eligible once this method returns, and the caller is expected to zero plaintextSecret in its finally block (after any DB persistence calls complete). Pinning and manual zeroing of the OtpNet internal field is not worthwhile given the narrow time window and the already-encrypted-at-rest storage.

TODO: If a future OtpNet release exposes IDisposable or a zeroing API on Totp, adopt it here to eliminate the residual heap copy.

TryZeroizeTotpInternals is best-effort: TryZeroizeTotpInternals(object) uses reflection to find and zero any byte[] fields inside the OtpNet Totp object graph, including the embedded InMemoryKey. This is best-effort: it depends on the internal field layout of OtpNet, which may change between versions without notice. After this method returns, the original TOTP secret remains in the managed heap (inside OtpNet-internal arrays) until the next garbage collection. Callers must zero plaintextSecret independently in a finally block. Do not rely solely on TryZeroizeTotpInternals(object) for security guarantees.