Skip to content

Add authenticator app (TOTP) and YubiKey OTP two-factor sign-in - #173

Merged
haileyok merged 1 commit into
mainfrom
hailey/totp-yubikey-2fa
Sep 28, 2026
Merged

haileyok merged 1 commit into
mainfrom
hailey/totp-yubikey-2fa

Conversation

@haileyok

Copy link
Copy Markdown
Owner

Summary

  • Adds authenticator app (TOTP) and YubiKey (Yubico OTP) two-factor sign-in, managed from a new /account/2fa page.
  • Works in both sign-in paths: com.atproto.server.createSession (the Bluesky app sends the code from its existing "emailed code" box) and /account/signin, which the OAuth authorize flow uses.
  • Once an account has an authenticator or YubiKey, emailed codes are no longer sent or accepted; single-use backup codes are the recovery path.

Changes

  • checkSecondFactor (server/two_factor.go) replaces the duplicated email-code logic in createSession and the signin page. It tells codes apart by shape: 6 digits → authenticator, 32–48 modhex characters → YubiKey, otherwise → backup code.
  • New internal/totp (RFC 6238, SHA1 / 6 digits / 30 s, ±1 step) and internal/yubiotp (modhex, AES-128 decryption, CRC and counter checks) packages. YubiKey OTPs are checked on the server using the slot's AES key; there is no YubiCloud dependency.
  • New tables two_factor_credentials and two_factor_backup_codes, plus repos.two_factor_failed_attempts / two_factor_locked_until (added by AutoMigrate).
  • Replay protection: the last TOTP step and the last YubiKey counter/use are claimed with conditional UPDATEs, so concurrent requests can't both use a code.
  • Ten wrong codes in a row lock second-factor attempts for 15 minutes. This is only reachable after a correct password. createSession returns RateLimitExceeded (429) while locked.
  • /account/2fa pages: add an authenticator app (with QR code), add a YubiKey (instructions for ykman otp yubiotp --serial-public-id --generate-private-id --generate-key 2), remove a method, and regenerate backup codes. Every change needs the password plus a current code: from an existing method, or an emailed code (via an "Email me a code" button) when the account only has email 2FA.
  • getSession / createSession now report emailAuthFactor: true for any second factor.
  • Account deletion removes the new rows; password reset leaves authenticators and YubiKeys in place.
  • Signin page fixes: a wrong password now says "Handle or password is incorrect" (the messages were swapped), and a wrong or expired 2FA code redirects back with a message instead of returning raw JSON.
  • New dependency: github.com/skip2/go-qrcode.

Validation

  • CGO_ENABLED=1 go test -race ./... — all packages pass.
  • go vet ./... and gofmt -l . — clean.
  • TOTP is tested against RFC 6238 Appendix B vectors, and Yubico OTP decoding against the vectors in Yubico's yubico-c (test-vectors.txt, tests/selftest.c).
  • Server tests cover both sign-in paths, replay rejection, cross-account keys, the lockout, single-use backup codes, the setup/removal pages, and password reset keeping authenticators.
  • Mutation checks: disabling YubiKey replay checks, accepting email codes alongside an authenticator, or reverting the signin handler each makes the relevant tests fail.
  • Roast cross-model review (two passes): one confirmed finding (the first authenticator could be added with only the password on an email-2FA account), now fixed and tested. One refuted candidate (no CSRF tokens on /account/2fa forms): the session cookie is SameSite=Lax and every change needs fresh secrets, which matches the rest of /account.
  • Not run: manual testing with a real authenticator app or YubiKey, or a visual check of the new pages in a browser — no browser or hardware available in this environment.

Review notes

  • The Bluesky app still says "Check your email for a sign in code" above the code box; the server can't change that text.
  • On iOS, tapping a YubiKey over NFC opens a URL instead of typing the OTP. Desktop, and Android over USB-C/NFC, work.
  • TOTP secrets and YubiKey AES keys are stored in plain form, the same way repo signing keys are.
  • If the Bluesky app changes the account email, it may switch email 2FA on as a dormant fallback. It is ignored while an authenticator or YubiKey exists.
  • The WebAuthn/FIDO2 security-key standard isn't supported: the Bluesky app's text box can't carry it. It could be added to the signin page later.

Accounts can now register authenticator apps and YubiKeys (Yubico OTP,
checked on the server with the slot's AES key; no YubiCloud) from
/account/2fa. Once one is registered, signing in needs a code from it or a
single-use backup code, and emailed codes are no longer sent or accepted.

Both sign-in paths share one check (checkSecondFactor):
- com.atproto.server.createSession: codes arrive in authFactorToken, which
  the Bluesky app fills from its existing "emailed code" box after an
  AuthFactorTokenRequired response.
- /account/signin, which the OAuth authorize flow uses.

Codes can't be replayed (last TOTP step and YubiKey counter are claimed
with conditional updates), and ten wrong codes in a row lock second-factor
attempts for 15 minutes. Adding or removing a method needs the password
plus a current code (an emailed code if the account only has email 2FA).
getSession/createSession report emailAuthFactor for any second factor.

Also fixes the signin page showing "Something went wrong!" for a wrong
password, and returning raw JSON instead of a message for a wrong code.
@haileyok
haileyok marked this pull request as ready for review September 28, 2026 17:26
@haileyok
haileyok merged commit efb00bf into main Sep 28, 2026
1 check passed
@haileyok
haileyok deleted the hailey/totp-yubikey-2fa branch September 28, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant