Table of Contents

Class TwoFactorRecoveryStore

Namespace
FishMMO.Client.Security
Assembly
FishMMO.Client.dll

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.

public static class TwoFactorRecoveryStore
Inheritance
TwoFactorRecoveryStore
Inherited Members

Fields

DirectoryName

Subdirectory of the client's persistent data path holding recovery payloads. Its own directory so the directory permissions can be tightened as well as each file's.

public const string DirectoryName = "2fa-recovery"

Field Value

string

EnvelopeExtension

Extension for an encrypted envelope.

public const string EnvelopeExtension = ".2fa"

Field Value

string

LegacyExtension

Extension of the superseded plaintext payload, still recognised for migration.

public const string LegacyExtension = ".txt"

Field Value

string

Methods

Delete(string)

Deletes a stored recovery file, ignoring failures.

public static void Delete(string path)

Parameters

path string

The file to remove.

EnsureDirectory(string)

Creates the recovery directory if needed and tightens its permissions.

public static string EnsureDirectory(string persistentDataPath)

Parameters

persistentDataPath string

The client's persistent data path.

Returns

string

The full path of the recovery directory.

List(string, bool)

Lists the recovery files currently on disk, newest first.

public static List<string> List(string directory, bool legacyPlaintext = false)

Parameters

directory string

The recovery directory. May not exist.

legacyPlaintext bool

true to list the superseded unencrypted payloads instead of the envelopes.

Returns

List<string>

Full paths; empty if the directory is absent or unreadable.

TryMigrateLegacy(string, string, string, out string)

Re-writes a superseded plaintext payload as an encrypted envelope.

public static bool TryMigrateLegacy(string directory, string legacyPath, string password, out string newPath)

Parameters

directory string

The recovery directory.

legacyPath string

The plaintext file to migrate.

password string

The account password. This must be a password the caller has just had confirmed by the server, not merely one the player typed — see the remarks.

newPath string

Receives the path of the new envelope on success.

Returns

bool

true if the payload is now stored encrypted and the plaintext is gone.

Remarks

Order matters and it is not negotiable. The envelope is written and proven readable first (TrySave(string, string, string, out string) does not publish an unverified file), and only then is the plaintext deleted. Deleting first, or deleting on a partial success, would take a payload the player can definitely read and replace it with one they possibly cannot.

Why a confirmed password. Encrypting under a password that turns out to be wrong does not fail — it succeeds, and produces a file nobody can ever open. That would silently destroy the codes while reporting success. So migration is only ever driven from a point in the flow where the server has already accepted the password.

TryRead(string, string, out string)

Reads one stored recovery file.

public static TwoFactorRecoveryReadResult TryRead(string path, string password, out string payload)

Parameters

path string

The file to read.

password string

The account password.

payload string

Receives the recovered text on Success, or the file's contents verbatim on LegacyPlaintext — an unencrypted file needs no password to read, which is the entire problem with it.

Returns

TwoFactorRecoveryReadResult

Why the read succeeded or failed.

Remarks

Empty also covers "the file is not there", which is the distinction a caller needs: "nothing is stored" is a normal state, "something is stored and would not open" is not, and the second must never be treated as the first — that is how a good envelope gets deleted after one mistyped password.

TryRestrictPermissions(string, string)

Best-effort tightening of a path's permissions so other local users cannot read it.

public static void TryRestrictPermissions(string path, string mode)

Parameters

path string

The file or directory to restrict.

mode string

The chmod mode, e.g. 600 for a file or 700 for a directory.

Remarks

POSIX only, and best effort even there — which is exactly why the contents are now encrypted rather than relying on this.

TrySave(string, string, string, out string)

Encrypts and stores a recovery payload.

public static bool TrySave(string directory, string password, string payload, out string path)

Parameters

directory string

The recovery directory, from EnsureDirectory(string).

password string

The account password. Never written to disk and never logged.

payload string

The text to protect.

path string

Receives the path of the stored envelope on success.

Returns

bool

true if an envelope is on disk that has been proven readable.

Remarks

The write is verified before it is published: the envelope goes to a temporary file, is read back off the disk, is decrypted with the same password, and is only moved into place if the round trip reproduced the payload byte for byte. A recovery-code file that cannot be decrypted is worse than no file at all, because the player believes they have a copy. If anything fails the temporary file is removed and nothing is published.