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
TotpSecretLength
TOTP secret length in bytes (160 bits per RFC 4226 §4).
public const int TotpSecretLength = 20
Field Value
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
secretbyte[]Plaintext TOTP secret.
accountNamestringAccount name to display in the authenticator app.
issuerstringIssuer 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
masterKekbyte[]32-byte persistent server master KEK.
usernamestringAccount name the envelope belongs to. Must match the value supplied at encrypt-time (case-insensitive); a mismatch causes the GCM tag to fail.
storedValuestringBase64-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
masterKekbyte[]32-byte persistent server master KEK (NOT the data key).
usernamestringAccount name the secret belongs to. Normalised to lowercase invariant for binding.
plaintextSecretbyte[]Plaintext TOTP secret (typically 20 bytes).
kekVersionintMaster-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
countintNumber 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
accountIdstringAccount 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.
plaintextCodestringPlaintext 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
accountIdstringAccount identifier. Used only when verifying v2 hashes.
submittedstringPlaintext recovery code submitted by the user.
storedHashstringStored hash in v2 or legacy envelope format.
Returns
- bool
trueif the code matches;falseotherwise.
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
plaintextSecretbyte[]Decrypted TOTP secret.
submittedCodestring6-digit TOTP code from the user.
lastWindowlongLast 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.