webauthn: stop broadcasting all credentials at login, add discoverable + scoped flow with username-claim gate` #365

Merged
vkobel merged 30 commits from refs/pull/365/head into main 2026-07-14 23:37:08 +00:00
vkobel commented 2026-07-06 12:19:18 +00:00 (Migrated from codeberg.org)

Adapts the login flows to usernames (includes a DB migration). Tested with unit + e2e tests and manually end-to-end.

  • What this branch does (one line): introduces usernames across the passkey login flows so we can move off broadcasting all credentials, with a zero-touch migration path for existing users.
  • Migration — no passkey churn: because we don't re-derive passkeys, there's nothing to remove/re-add. The migration is simply: pick a username if you haven't — same for everyone, non-resident or resident keys.
    • UI forces it: you can't do anything until you've claimed a username.
    • CLI forces it too: it requires the passkey to sign the claim.
    • Works with --qr, and I added a --username flag you can pass alongside --qr for non-resident keys.
  • New gateway flag LOGIN_ALLOW_BROADCAST: set it to false and we stop sending the full allowCredentials list — this stops leaking everyone's credentials. Trade-off: anyone with non-resident-only keys and no username can no longer log in. Mitigation for those users: contact us and we hand them their auto-generated legacy username (e.g. u_2dbdee49).
  • e2e tests: added a software-passkey round-trip test (test_webauthn_roundtrip.sh + a standalone soft-authenticator Rust helper using webauthn-authenticator-rs) that does a real register + login assertion ceremony against a live gateway — covering the finish-side signature verification that the existing begin-side tests couldn't. Note this covers the non-resident path; resident/discoverable browser coverage (Chrome virtual authenticator) is a planned follow-up.
  • Chrome CDP e2e test (discoverable login): Puppeteer test (test_webauthn_browser.sh → browser-authenticator/cdp-passkey.mjs) attaches a CDP virtual authenticator, registers a real resident passkey through the actual Vue UI, then logs in username-less via the discoverable path (LOGIN_ALLOW_BROADCAST=false, empty allowCredentials, resolved by userHandle). Unlike the earlier Rust soft-authenticator test (ee7, non-resident only — SoftPasskey can't mint resident credentials), this covers the branch's actual feature end-to-end: the real useWebAuthn.js glue, the gateway's discoverable begin/finish path, and Chrome's own WebAuthn stack.
Adapts the login flows to usernames (includes a DB migration). Tested with unit + e2e tests and manually end-to-end. - What this branch does (one line): introduces usernames across the passkey login flows so we can move off broadcasting all credentials, with a zero-touch migration path for existing users. - Migration — no passkey churn: because we don't re-derive passkeys, there's nothing to remove/re-add. The migration is simply: pick a username if you haven't — same for everyone, non-resident or resident keys. - UI forces it: you can't do anything until you've claimed a username. - CLI forces it too: it requires the passkey to sign the claim. - Works with --qr, and I added a --username flag you can pass alongside --qr for non-resident keys. - New gateway flag LOGIN_ALLOW_BROADCAST: set it to false and we stop sending the full allowCredentials list — this stops leaking everyone's credentials. Trade-off: anyone with non-resident-only keys and no username can no longer log in. Mitigation for those users: contact us and we hand them their auto-generated legacy username (e.g. u_2dbdee49). - e2e tests: added a software-passkey round-trip test (test_webauthn_roundtrip.sh + a standalone soft-authenticator Rust helper using webauthn-authenticator-rs) that does a real register + login assertion ceremony against a live gateway — covering the finish-side signature verification that the existing begin-side tests couldn't. ~Note this covers the non-resident path; resident/discoverable browser coverage (Chrome virtual authenticator) is a planned follow-up.~ - Chrome CDP e2e test (discoverable login): Puppeteer test (test_webauthn_browser.sh → browser-authenticator/cdp-passkey.mjs) attaches a CDP virtual authenticator, registers a real resident passkey through the actual Vue UI, then logs in username-less via the discoverable path (LOGIN_ALLOW_BROADCAST=false, empty allowCredentials, resolved by userHandle). Unlike the earlier Rust soft-authenticator test (ee7, non-resident only — SoftPasskey can't mint resident credentials), this covers the branch's actual feature end-to-end: the real useWebAuthn.js glue, the gateway's discoverable begin/finish path, and Chrome's own WebAuthn stack.
caution-bot commented 2026-07-08 06:00:51 +00:00 (Migrated from codeberg.org)

Finding 1 — username-scoped login can still authenticate a different resident account

The username-scoped login path still falls back to a discoverable/decoy ceremony in cases where the supplied username is unknown or where the known user has zero credentials. In those branches, the finish path resolves whichever resident credential the browser/phone returns and creates a session for that credential's user.

That means if a caller enters alice, but the server takes the decoy/discoverable path, a browser/phone can still complete with bob's resident credential and log in as bob. This avoids an immediate username-enumeration response, but it makes a username-scoped login not actually scoped in those branches.

Relevant paths I looked at:

  • src/gateway/src/handlers.rs:1082-1147
  • src/gateway/src/handlers.rs:1310-1351
  • QR path has the same pattern around src/gateway/src/handlers.rs:1966-1993

Suggested fix: make AuthState carry expected-user/decoy context. For username-scoped decoys or known-zero-credential cases, verify the finished credential belongs to the expected user; if there is no expected user, consume the ceremony but return a generic login failure instead of creating a session.

**Finding 1 — username-scoped login can still authenticate a different resident account** The username-scoped login path still falls back to a discoverable/decoy ceremony in cases where the supplied username is unknown or where the known user has zero credentials. In those branches, the finish path resolves whichever resident credential the browser/phone returns and creates a session for that credential's user. That means if a caller enters `alice`, but the server takes the decoy/discoverable path, a browser/phone can still complete with `bob`'s resident credential and log in as `bob`. This avoids an immediate username-enumeration response, but it makes a username-scoped login not actually scoped in those branches. Relevant paths I looked at: - `src/gateway/src/handlers.rs:1082-1147` - `src/gateway/src/handlers.rs:1310-1351` - QR path has the same pattern around `src/gateway/src/handlers.rs:1966-1993` Suggested fix: make `AuthState` carry expected-user/decoy context. For username-scoped decoys or known-zero-credential cases, verify the finished credential belongs to the expected user; if there is no expected user, consume the ceremony but return a generic login failure instead of creating a session.
caution-bot commented 2026-07-08 06:00:53 +00:00 (Migrated from codeberg.org)

Finding 4 — CLI register prompt lacks the noninteractive guard added for login

Login now correctly fails fast in noninteractive mode if no username is provided, but register still calls prompt_for_claimed_username() directly when --username is absent or empty.

In a noninteractive context, EOF can produce an empty username and then fail later at the server with a less clear error.

Relevant path:

  • src/cli/src/lib.rs:8384-8388

Suggested fix: reuse the same is_terminal guard pattern that the login path uses before prompting for a username.

**Finding 4 — CLI register prompt lacks the noninteractive guard added for login** Login now correctly fails fast in noninteractive mode if no username is provided, but register still calls `prompt_for_claimed_username()` directly when `--username` is absent or empty. In a noninteractive context, EOF can produce an empty username and then fail later at the server with a less clear error. Relevant path: - `src/cli/src/lib.rs:8384-8388` Suggested fix: reuse the same `is_terminal` guard pattern that the login path uses before prompting for a username.
xenushka commented 2026-07-09 17:59:55 +00:00 (Migrated from codeberg.org)

@vkobel I previously had a ticket for optionally collecting email at registration - https://codeberg.org/caution/platform/issues/142 - is this already captured in this PR?

if so, I'll close 142

@vkobel I previously had a ticket for optionally collecting email at registration - https://codeberg.org/caution/platform/issues/142 - is this already captured in this PR? if so, I'll close 142
vkobel commented 2026-07-14 08:21:05 +00:00 (Migrated from codeberg.org)

Hey @xenushka — not captured in this PR. #365 is about usernames (discoverable credentials, username claiming, login hardening). Email at registration from #142 is a separate concern; users can already add an email from account settings, but the optional collection step at signup isn't implemented here.

Hey @xenushka — not captured in this PR. #365 is about usernames (discoverable credentials, username claiming, login hardening). Email at registration from #142 is a separate concern; users can already add an email from account settings, but the optional collection step at signup isn't implemented here.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
public/platform!365
No description provided.