Skip to content

M18, first half: authenticator apps and encrypted login keys - #30

Open
SebHenn wants to merge 1 commit into
mainfrom
m18-two-factor-and-keys
Open

SebHenn wants to merge 1 commit into
mainfrom
m18-two-factor-and-keys

Conversation

@SebHenn

@SebHenn SebHenn commented Nov 7, 2026

Copy link
Copy Markdown
Owner

What

The first half of M18 "Accounts and system": logins with an authenticator app, and the login keys in the database encrypted with a key from outside it. The system page and how long things are kept follow in a second pull request.

Authenticator apps

  • Under "Change password" → "Log in with an authenticator app": scan a QR code, type one code, get ten recovery codes, shown once. After that, the login asks for a code after the password. A recovery code works once, with or without its dash.
  • New recovery codes and turning it off ask for the password again, and count towards the lockout like a login.
  • "Settings" → "Logging in" can require it of everyone who manages the archive. Until someone has set it up, nothing behind the curators' policy opens for them, original files included, and they are sent to the setup page. Then "Turn off" becomes "Set up on a new phone".
  • "Accounts" shows who uses an app. An admin can turn it off for someone else, for a lost phone, with a confirmation. For the only admin, or after the key was lost: docker compose run --rm web reset-two-factor <email> on the server.
  • The app's secret and the recovery codes are encrypted in the database (ProtectedUserStore), not kept in plain text as Identity does by default.
  • Every change, and every login with a recovery code, is in the activity log.
  • The QR code is SVG drawn in the page from QRCoder (MIT), so it needs no image address or script. M20 can use it for posters.

Login keys

  • The keys that sign logins, setup links and form tokens stay in the database, so logins survive a restore, but each is now encrypted with AES-256-GCM using COMMONAGE_LOGIN_KEY from .env, which is not in the database or the backups.
  • The first start with a key removes the keys that were stored readable.
  • A key that is set but unusable stops the app with a message that says how to make one. Without a key, a server (not a development machine) shows admins a notice.
  • scripts/update.sh adds a key to .env when it updates an archive that has none, after the backup, and says to keep a copy of .env.
  • Install docs, .env.example and compose pass it on. operations.md says what happens when it is lost, and security.md now has a table of where each secret is kept.

Please decide or look at

  • Everyone logs in again once after this update, and setup links created before stop working, because the readable keys are removed. Keeping them would leave something in every old backup that can forge logins. The changelog says so in bold.
  • A setup link still only sets a new password. The roadmap says setup links stay the recovery path. I kept them for passwords, so someone who only forgot theirs keeps their app. For a lost phone, the admin turns the app off on the account page. This does mean an admin can take over a curator's account, which security.md now says.
  • The key is in .env next to the database password. Someone on the server itself has both anyway. The point is the backups, which are copied elsewhere. Losing .env now costs everyone their logins and their authenticator apps, so the docs ask for a copy kept apart from the backups.
  • No "remember this device": the code is asked at every login, because curators often work on shared computers.
  • New dependency: QRCoder 1.6.0 (MIT).

Checked

  • 358 HTTP and 167 browser tests pass locally, built the way CI builds, and the backup-and-restore test.
  • 17 rules broken on purpose, one at a time, and all caught, each by the test meant for it: secrets stored readable, the requirement not asked or always passed, no way to the setup page, the password alone logging in, recovery codes not logged, refused without their dash or not counted down, turning off without the password, an admin turning off their own, the lost phone still working after it was turned off, the server command not logged, the setting not saved, keys not encrypted, readable keys kept, any key accepted, and no notice without a key.
  • Walked through in the running app with a login key, at phone and desktop width and in German: setting up, the recovery codes, logging in with the app's code and with a recovery code, "Accounts", "Settings" and the activity log.
  • The backup test now runs with a login key, checks that no key in the database can be read before the backup, and that a login from before the backup still works after the restore. The update test checks that update.sh adds exactly one key and that no readable key is left afterwards.
  • Codes are made in the tests as a phone makes them (RFC 6238), including one from the previous half minute. A made-up key fails, and so does a used recovery code.
  • The lost-key path end to end: an archive whose key changed can read nothing from before, logs nobody in with an app, and reset-two-factor gets the admin back in.
  • A browser test sets up an app at phone and desktop width, copies the recovery codes and logs in with one, with axe on each step. It found Bootstrap's pink code colour and the grey Copy button too faint on the green box, so the codes now sit in a plain bordered box. It also found that the activity log, whose entries got longer, scrolled sideways on a phone without a way for the keyboard to reach it.

Not checked

  • A real phone with a real authenticator app scanning the code on screen. OpenCV's QR reader read the screenshot from the walk-through, and gave the right otpauth:// address with the key shown beside it, but no app has tried.
  • update.sh on a real server. The update test runs it end to end in CI.

🤖 Generated with Claude Code

…ey from outside the database

- Curators and admins can add a code from an authenticator app to their login, with ten recovery codes. Settings can
  require it. An admin turns it off for someone who lost their phone; reset-two-factor on the server does it for the
  last admin. The secret and the codes are encrypted in the database.
- The Data Protection keys are encrypted with AES-256-GCM using COMMONAGE_LOGIN_KEY from .env. The first start with a
  key removes those stored readable. Without one, admins on a server see a notice; update.sh adds one to .env.
- The backup test checks that no login key in the database can be read, and that old logins survive the restore.
- QRCoder draws the QR code as SVG in the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Seb H. <138793908+SebHenn@users.noreply.github.com>

This branch has not been deployed

No deployments
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