Namespace FishMMO.Client.Security
Classes
- ClientCertificatePinning
Centralised TLS certificate pinning for outbound
UnityWebRequesttraffic (launcher version probe, patch download, login-server discovery, renewal endpoints, etc.).Pins are SHA-256 hashes of the
SubjectPublicKeyInfo(SPKI) DER encoding, base64-encoded — the same format used by HPKP / RFC 7469. SPKI pinning is preferred over full-certificate pinning because it survives certificate renewals as long as the underlying key pair is reused, and is preferred over CA pinning because it cannot be bypassed by any CA in the trust store.Unity's
CertificateHandler.ValidateCertificate(byte[])only exposes the leaf certificate, so this implementation deliberately ignores chain validity and instead requires that the leaf's SPKI match a known-good pin. Always configure at least two pins (active + backup) so an emergency key rotation does not require a client patch.IL2CPP / AOT platforms (WebGL, iOS, consoles):BouncyCastle relies on runtime reflection for algorithm lookup and type activation. Without linker instructions, the managed-code linker will strip the types it needs. EnsureAssets/link.xmlincludes:<assembly fullname="Org.BouncyCastle" preserve="all"/>.
- ClientSecurityBootstrap
Initialises ClientCertificatePinning before any scene loads or
UnityWebRequestis dispatched. Pin sources, in order:CertificatePins.generated.cs— IL-embedded pins written by FishMMO > Security > Fetch Certificate Pins. The committed source contains sentinel placeholders; the editor tool overwrites them.API pin update manifest (optional, async) — fetched from
GET {APIHost}config/pins, verified against an Ed25519 public key embedded alongside the pins. Only ADDS pins; never removes. Failures are silent — the compile-time pin set remains active.
There is NO StreamingAssets fallback. Pins are always IL-embedded.
- Ed25519ManifestVerifier
Verifies an Ed25519 signature carried inside a JSON document, against a public key embedded in the client at build time.
- GeneratedClientSecret
IL-embedded client gate secret. The real value is substituted at build time by CI from the FISHMMO_CLIENT_GATE_SECRET env var.
- GeneratedPinSet
IL-embedded certificate pin set. The real values are substituted at build time by CI. The committed sentinel values are intentionally invalid so pinning cannot accidentally ship with empty values.
- NullPinUpdateSidecar
No-op default sidecar used until a signed-manifest implementation is wired in. Always returns "no update available". Never downgrades the static pin set.
- PinUpdateManifest
Result of a successful pin update fetch. Fields are intentionally minimal to constrain what a compromised sidecar can express.
- TwoFactorRecoveryCrypto
Password-based authenticated encryption for the local 2FA recovery-code payload.
Why the account password and not a machine-bound key. The obvious alternative is a key sealed to the machine (DPAPI, keychain, a file next to the payload). That protects the codes from another local process, but it dies with the installation — and a reinstall, a new machine or a wiped profile is precisely the situation in which a player reaches for recovery codes. A machine-bound key would therefore trade a confidentiality problem for an availability one, and locking a player out of their own account is the worse failure of the two. The account password is in hand at exactly the two moments that matter — when the codes are written at 2FA setup, and when the player legitimately asks to read them back — and it is the one secret that survives a reinstall.
Primitives. Argon2id and AES-256-GCM, both from the BouncyCastle build this project already ships (
Assets/Dependencies/BouncyCastle.Cryptography.dll) and already depends on for all of its other crypto. No new dependency. BouncyCastle rather thanSystem.Security.Cryptography.AesGcmfor the same reasonCryptoHelper.EncryptAESuses it: it behaves identically on every Unity player target, where the BCL AEAD types are not uniformly available.Why Argon2id rather than the PBKDF2 the server uses for recovery-code hashing.
CryptoHelper.TwoFactor.HashRecoveryCodeis PBKDF2-HMAC-SHA256 at 600k iterations because it runs server-side, once per verification, against a high-entropy random code. This runs client-side, twice per account lifetime, against a human-chosen password, and the attacker is someone who already has the file. Memory-hardness is the property that matters there, and the parameters are recorded in the envelope so they can be raised later without stranding files written by an older client.
- TwoFactorRecoveryStore
The local, on-disk copy of the 2FA setup payload (otpauth URI + recovery codes) written during account creation so the player still has the codes if the client dies before they finish writing them down.
The payload used to be a plaintext
.txt. Randomising the filename and tightening the permissions — the previous pass — removed the account-name leak and the guessable path, but the contents were still readable by anything that could open the file: another local user on a machine where the chmod did not take, a backup agent, a sync client, a stolen disk. This class encrypts the payload under the account password; see TwoFactorRecoveryCrypto for why the password and not a machine-bound key.Every method here is deliberately non-destructive on failure. The one thing this storage must never do is destroy a payload the player has not yet copied down.
Interfaces
- IPinUpdateSidecar
Scaffold interface for out-of-band TLS pin updates.
This is a forward-planning scaffold only. No production implementation is currently wired into the client. The NullPinUpdateSidecar default is always returned, meaning the static pin set from ClientSecurityBootstrap is never overridden at runtime.
Hard-coded compile-time pins (see ClientSecurityBootstrap) require shipping a new client build to rotate keys. That is unacceptable when a CA-incident or HSM rotation forces an emergency pin swap on a faster cadence than the client release pipeline.
The intended production implementation:
- Fetches a signed manifest (Ed25519 / RSA-PSS) from a well-known URL,
- Verifies the signature against a compile-time embedded public key (a separate trust anchor from the TLS PKI it is updating!),
- Returns the new pin set with an effective-from / expires-at window so the bootstrap can reject stale manifests.
The bootstrap MUST treat sidecar failures as advisory only and never downgrade pinning below what was statically configured. A compromised sidecar host must not be able to remove pins — only narrow them.
Enums
- TwoFactorRecoveryReadResult
The outcome of an attempt to open a recovery-code envelope.