Class 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.
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
EnvelopeExtension
Extension for an encrypted envelope.
public const string EnvelopeExtension = ".2fa"
Field Value
LegacyExtension
Extension of the superseded plaintext payload, still recognised for migration.
public const string LegacyExtension = ".txt"
Field Value
Methods
Delete(string)
Deletes a stored recovery file, ignoring failures.
public static void Delete(string path)
Parameters
pathstringThe file to remove.
EnsureDirectory(string)
Creates the recovery directory if needed and tightens its permissions.
public static string EnsureDirectory(string persistentDataPath)
Parameters
persistentDataPathstringThe 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
directorystringThe recovery directory. May not exist.
legacyPlaintextbooltrueto list the superseded unencrypted payloads instead of the envelopes.
Returns
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
directorystringThe recovery directory.
legacyPathstringThe plaintext file to migrate.
passwordstringThe 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.
newPathstringReceives the path of the new envelope on success.
Returns
- bool
trueif 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
pathstringThe file to read.
passwordstringThe account password.
payloadstringReceives 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
pathstringThe file or directory to restrict.
modestringThe chmod mode, e.g.
600for a file or700for 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
directorystringThe recovery directory, from EnsureDirectory(string).
passwordstringThe account password. Never written to disk and never logged.
payloadstringThe text to protect.
pathstringReceives the path of the stored envelope on success.
Returns
- bool
trueif 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.