Skip to content

feat(auth): passkeys as a second factor (#106) - #110

Merged
kipavy merged 1 commit into
mainfrom
feat/passkeys-106
Oct 11, 2026
Merged

kipavy merged 1 commit into
mainfrom
feat/passkeys-106

Conversation

@kipavy

@kipavy kipavy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Part of #106. Server side of passkeys as a second factor. Merge and deploy this one first; the portal and client PRs work against an older server and simply offer no passkeys.

What changes

  • An account can hold an authenticator app, passkeys, or both. Two-factor is on while at least one exists. The first factor added issues the recovery codes and ends earlier sessions; removing the last one turns it off.
  • The WebAuthn ceremony runs in the browser, on the portal. A client opens it with a one-time handle and gets a single-use code back: a redirect to 127.0.0.1:<port> for the desktop app, a voltius://passkey link for a phone, or in the response for the portal's own sign-in.
  • passkey_code is accepted wherever code and recovery_code are: login/2fa, disable, recovery codes, password change, and adding another factor.
  • New: WEBAUTHN_RP_ID. Unset, no passkeys and /v1/meta says passkeys: false. The portal origin is VOLTIUS_APP_URL.
  • Migration 060: user_passkeys, passkey_ceremonies. Ceremony state lives in Postgres, nothing per process.

Endpoints

POST /v1/auth/login/2fa/passkey open a sign-in ceremony for a pending token
POST /v1/auth/passkey/begin, /finish the browser's half, by handle; own rate bucket (3x AUTH_RATE_LIMIT)
GET /v1/auth/2fa factors the account holds, unused recovery codes
POST /v1/auth/2fa/passkeys/register, /activate add a passkey: password (+ proof when a factor exists), then the code from the browser
POST /v1/auth/2fa/passkeys/step-up open a ceremony that confirms a change
POST /v1/auth/2fa/passkeys/:id/remove, /v1/auth/2fa/totp/remove remove one factor

/2fa/setup now takes proof when the account already has a factor. /2fa/disable removes everything and also returns two_factor_enabled. The admin clear removes passkeys too.

Things a reviewer should look at

  • Pending tokens get a jti. Two sign-ins for one account in the same second produced identical pending tokens, so a passkey code bound to one fitted the other. A test caught it by flaking.
  • A sign-in code only fits the pending token it was opened for, and a step-up code never signs in. Without this, someone holding the password could open a sign-in, send the victim the portal link, and ride on their passkey.
  • A TOTP seed that was never confirmed no longer counts at sign-in. It used to be checked whether enabled or not; with passkeys an account can be "on" with an unconfirmed seed lying around.
  • A failed assertion does not count toward the 5-strike lock. A signature cannot be guessed, and counting would let anyone with the password lock the account from the portal page. A wrong passkey_code does count.
  • openssl-libs-static in the builder image. webauthn-rs links OpenSSL and the musl build is static. The release image builds and runs; checked on arm64.
  • Released clients (0.54.0) are unaffected for authenticator accounts. On a passkey-only account they can sign in with a recovery code only.

Deploy

Set WEBAUTHN_RP_ID before the first passkey is registered and never change it: every passkey is bound to it. For the hosted service voltius.app covers app.voltius.app and survives moving the portal. It is not a secret.

Tests

730 pass (13 new), clippy clean. The passkey tests drive real registration and assertion with webauthn-rs's software authenticator. Also run end to end in Chromium with a virtual authenticator against the portal branch, and from the desktop app.

An account can now hold an authenticator app, passkeys, or both. Two-factor
is on while at least one exists: the first factor added issues the recovery
codes and ends earlier sessions, removing the last one turns it off.

The WebAuthn ceremony runs in the browser, on the portal. A client opens it
with a one-time handle and gets a single-use code back, by a loopback
redirect on desktop, a voltius:// link on a phone, or in the response for
the portal's own sign-in. A sign-in code only fits the pending token it was
opened for, so pending tokens now carry a unique id: two issued in the same
second used to be identical.

Step-up actions accept a passkey code beside a TOTP or recovery code, and
adding a factor to an account that has one needs proof of it. A TOTP seed
that was never confirmed no longer passes as a factor at sign-in.

The relying party comes from WEBAUTHN_RP_ID and VOLTIUS_APP_URL; without it
the server offers no passkeys and /v1/meta says so. webauthn-rs links
OpenSSL statically, hence openssl-libs-static in the builder image.
@kipavy
kipavy merged commit e87034d into main Oct 11, 2026
1 check passed
@kipavy
kipavy deleted the feat/passkeys-106 branch October 11, 2026 19:27
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