9.5 KiB
ADR-0043: Two-factor authentication as an opt-in session gate
Status: Accepted Date: 2026-05-24 Supersedes: — Superseded by: —
Context
Morphit is a non-custodial peer-to-peer marketplace. The user's identity material — four BLURT role keys plus a BIP-39 seed — lives only on the user's device, encrypted under their password via Argon2id + XChaCha20-Poly1305 (ADR-0017).
Users in our target population are accustomed to two-factor authentication (2FA) on every other consumer service they use. "Does Morphit have 2FA?" is one of the first questions a security-conscious newcomer asks. Answering "no" is technically defensible (the keystore IS encrypted; if your password is good the keys are safe) but it costs trust with users who don't draw the distinction between custodial 2FA and crypto-wallet 2FA.
This ADR documents the design of an OPT-IN TOTP-based 2FA layer that protects against a real-but-narrow threat model, while being honest about what it does NOT add.
Decision
Morphit ships TOTP-based 2FA as a session gate, layered after the existing password-decrypts-keystore flow.
Opt-in, never required
The user MUST initiate enrollment from Settings → Security → 2FA. There is no:
- Nag banner suggesting enrollment
- "Your account is insecure" interrupt at login
- Onboarding step that mentions 2FA
- Push to enroll after first trade, after first withdrawal, or at any other milestone
Users who never click "Set up 2FA" experience Morphit identically
to a pre-2FA build. The totpSecret field on FullIdentity is
optional and defaults to null; the bootFromEnvelope gate only
fires if (full.totpSecret).
This is a privacy/agency stance. On a non-custodial wallet, a required second factor is contradictory with the design: if the user loses the second factor, NOBODY can recover access — there is no admin, no support email, no "lost 2FA" support form. So 2FA on Morphit can only be opt-in, and the UI must respect that.
Honest threat-model framing in the UI itself
The enrollment page surfaces, in plain language, what 2FA does and doesn't protect against:
Does protect against:
- Shoulder-surfing: someone watching you type the password
- Borrowed-device replay: a friend who saw you log in once
- Casual local malware: malware that grabs the encrypted keystore + sniffs the password but doesn't know to extract the TOTP secret from the same blob
Does NOT protect against:
- A determined offline attacker who has BOTH the encrypted keystore AND a cracked password. The TOTP secret lives in the same encrypted blob as the keys; once that blob is decrypted, the attacker has all of it.
For cryptographically-meaningful 2FA where the second factor's secret never lives on the protected device, the path forward is FIDO2/WebAuthn hardware keys (see ADR-0017 §3 — YubiKey-protected keystores). Morphit has exploratory support; it's not production-ready yet. The TOTP layer documented here is explicitly the lesser cryptographic guarantee, but it covers the actually-common threat scenarios that users worry about.
Open-source-only recommended-app policy
Morphit's recommended-apps list (in recommendedAuthenticatorApps.ts)
is curated under a strict policy. To be on it, an app MUST:
- Be open source under an OSI-approved license
- Offer encrypted backups (cloud or local)
- Not phone home with telemetry by default
- Be available on at least one of: F-Droid, iOS App Store, Android Play Store, or direct platform distribution
The current list (in display order):
- Aegis Authenticator (GPL-3.0, Android-only): encrypted local backups, biometric lock, no cloud sync. The gold standard for privacy-conscious Android users.
- 2FAS Authenticator (GPL-3.0, iOS + Android): optional encrypted iCloud/Google Drive backup is opt-in, not the default.
- Ente Auth (AGPL-3.0, all platforms): end-to-end-encrypted cross-device sync via Ente's E2E infrastructure.
Apps explicitly not recommended, with reasons surfaced in the UI under an expandable "apps we don't recommend" section:
- Google Authenticator: closed source; cloud backup syncs unencrypted secrets to Google by default (opt-out, not opt-in).
- Microsoft Authenticator: closed source; mandatory Microsoft-account telemetry.
- Authy: closed source; cloud-only backups with a history of SIM-swap account-recovery exploits; desktop app deprecated 2024.
Users with other authenticators they trust (KeePassXC, Bitwarden, Yubico Authenticator, andOTP, OpenOTP) are welcome to use them — the list is "what we recommend to a new user," not a denylist.
Backup codes
10 single-use Crockford-base32 backup codes generated at enrollment, displayed once, hashed with Argon2id MODERATE. Each code is 8 characters → ~40 bits of entropy, which combined with Argon2id MODERATE makes offline brute force infeasible even with the keystore stolen.
The unlock-flow gate accepts EITHER a 6-digit TOTP code OR an
8-char backup code (with optional dash); auto-detection happens
in keystoreTotp.ts. On successful backup-code redemption, the
keystore is re-encrypted with the slot's used flag flipped
and persisted before returning — this prevents replay by an
attacker who reads the same encrypted blob between redemption
and re-save.
Rate limiting
Session-local: 5 invalid TOTP attempts trigger a 30-second lockout. This is meant to slow down a human typing wildly, not to defeat automated brute force (which would need to also break the keystore encryption first; the TOTP secret isn't reachable until then anyway). Reloading the page resets the counter, which is fine for the threat model.
Consequences
Positive
- Users who want 2FA can have it
- No degradation for users who don't enroll
- Honest framing avoids over-promising
- Open-source-only policy avoids leaking secrets to closed-source backup paths that contradict Morphit's privacy stance
- The same code path will support layered envelope
- 2FA in a future iteration when needed
Negative
- Adds a code surface that didn't exist before
- Users who enroll and then lose both their authenticator AND all 10 backup codes need to recover via seed phrase (which they should have anyway, but it's a real operational risk)
- TOTP requires the user's device clock to be roughly in sync; clock drift > 90s (1 step on either side of current) fails validation
- The TOTP code field is autocomplete="one-time-code" which on iOS Safari can sometimes interfere with paste; documented in FAQ
Neutral
- The decision NOT to nag users about enrolling means some users who would benefit from 2FA won't enable it because they don't think about it. This is acceptable given the explicit anti-paternalism stance — we tell the user 2FA exists, we tell them where to find it, and we trust them to decide.
Rejected alternatives
Hardcoded "you must enable 2FA before withdrawing more than $X"
Rejected: contradicts the non-custodial design. The user's keys are the user's keys; Morphit must not impose operational requirements that can lock the user out of their own funds. Some other crypto services do this; Morphit does not.
Server-stored TOTP shared secret
Rejected: requires a server-side account, which Morphit doesn't have. The relay holds no per-user state of this kind; the indexer holds chain-replicated public data only. Storing TOTP secrets server-side would create a custodial component where none exists today.
WebAuthn / FIDO2 only
Rejected as the only 2FA path because:
- WebAuthn hardware keys are not yet ubiquitous in the user population
- Mobile WebAuthn implementations vary in completeness across browsers
- A subset of users want 2FA but don't own a hardware key
WebAuthn IS the cryptographically stronger path and remains
on the roadmap (see the /dev/yubikey-probe exploratory
route). TOTP is the bridge available today.
"Recommend Google Authenticator because it's familiar"
Rejected: contradicts Morphit's privacy stance. Recommending an authenticator that backs up secrets to Google's cloud by default would undo the secrecy of the second factor for exactly the users (privacy-conscious Bitcoin/Monero traders) who chose Morphit specifically to avoid that pattern.
Implementation
apps/web/src/lib/auth/totp.ts— RFC 6238 HMAC-SHA1 TOTP primitives + base32 codec + otpauth:// URI builderapps/web/src/lib/auth/backupCodes.ts— Crockford-base32 code generation + Argon2id MODERATE hashing + redemptionapps/web/src/lib/auth/recommendedAuthenticatorApps.ts— canonical apps list with explicit policyapps/web/src/lib/crypto/keystoreTotp.ts— unlock-time TOTP-or-backup-code verification gateapps/web/src/lib/crypto/keystoreTotpEnroll.ts— enroll, unenroll, regenerate-backup-codes operationsapps/web/src/lib/crypto/keygen.ts—FullIdentityextended with optionaltotpSecret+totpBackupCodesapps/web/src/lib/crypto/keystore.ts—identityToJson/jsonToIdentityserialize/parse the new fields; newKeystoreErrorKindvalues'totp_required'and'totp_invalid'apps/web/src/lib/stores/identity.ts—bootFromEnvelopegates on TOTP after password decrypt; backup-code redemption re-encrypts + persistsapps/web/src/routes/[lang]/settings/security/2fa/+page.svelte— full enrollment + status + regen + unenroll state machineapps/web/src/routes/[lang]/login/+page.svelte— handlestotp_requiredby surfacing a TOTP entry field; session-local rate limit ontotp_invalidapps/web/src/lib/i18n/locales/*.json—settings.totp.*subtree in all 10 locales