9
2
Fork
You've already forked platform
2

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

Open
vkobel wants to merge 29 commits from feat/webauthn-discoverable-login into main
pull from: feat/webauthn-discoverable-login
merge into: caution:main
caution:main
caution:feat/paddle-byoc-subscriptions
caution:feat/legal-publish-doc
caution:fix/issue-116-duplicate-ssh-key
caution:fix/issue-307-remove-apps
caution:fix/issue-265-http-port-private
caution:fix/issue-330-remove-build-binary
caution:feat/issue-164-account-id
caution:fix/issue-119-logout-confirmation
caution:feat/add-tofu-lockfile-cache
caution:docs/issue-339-install-instructions
caution:fix/issue-336-split-qr-login-tokens
caution:fix/verify-app-source-preflight
caution:fix/many-git-push-deploy
caution:fix/issue-334-email-verification-throttle
caution:fix/issue-273-billing-url
caution:fix/issue-333-final-usage-collection
caution:fix/issue-270-low-credit-email
caution:fix/issue-301-remove-auto-topup
caution:fix/issue-320-byoc-auth-first
caution:fix/issue-348-git-progress-checklist
caution:fix/issue-321-byoc-caution-hcl
caution:fix/issue-332-resource-id-suspension
caution:fix/eif-app-mode-normalization
caution:feat/cli-stagex-container
caution:fix/eif-s3-layer-cache
caution:fix/authz-role-checks
caution:fix/paddle-customdata-credit-mint
caution:fix/cli-hangs-unsupported-ssh-url
caution:feat/print-platform-commit-dashboard
caution:anton/feat/multi-user
caution:fix/config-domain-validation
caution:anton/support-ssh-destroy-get
caution:fix/cli-config-dir
caution:feat/publish-build-inputs
caution:feat/locksmith-polishing
caution:fix/procfile-run-command-no-split
caution:anton/byoc-require-login
caution:fix/hcl-init-template
caution:feat/egress-default-deny
caution:ryansquared/hcl
caution:quotas
caution:anton/feat/region-routing-resource-unavailable-alert
caution:fix/attestation-502-dashboard
caution:ryansquared/systemdize
caution:fix/verify-git-credential-hang
caution:anton/fix-low-credits-alert
caution:xenushka/rename-byoc
caution:ryansquared/add-download-eif
caution:add-debug-flag
caution:test/pr1-integration-harness
caution:anton/fix/only-pass-required-env-vars
caution:ryansquared/add-database-bucket

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.
Users choose a username at signup (validated, lowercased, unique); it
becomes the WebAuthn display name while user.id stays a random UUID.
Existing placeholder accounts get a one-time claim via GET/POST
/user/username (migration 044 adds username_is_placeholder).
Kill the credential-broadcast leak: /auth/login/begin and qr-login now do
scoped (username) / discoverable (username-less) / broadcast branching, gated
by LOGIN_ALLOW_BROADCAST (default on). Discoverable finish resolves the user
via userHandle; unknown username returns empty allowCredentials (no oracle).
Add username-claim gate middleware, credProps residency capture + resident
column, CLI --username flag, optional frontend username field, and a
deterministic e2e suite (make test-e2e-webauthn).
Gated users' checkAuth() call was itself gated, making them
appear logged-out and bouncing them to the register page
instead of the account/claim-username view.
- cli: require username at register (--username, prompt fallback)
- cli: stop looping on AlreadyClaimed (409) at username claim
- frontend: clone 403 body in authFetch; send clientExtensionResults
- gateway: zero-cred known username takes decoy path (mediation oracle)
- e2e: add register-username-required + already-claimed 409 steps
vkobel force-pushed feat/webauthn-discoverable-login from 392eb76af5 to b32d33acb9 2026年07月07日 10:12:56 +02:00 Compare
vkobel force-pushed feat/webauthn-discoverable-login from b2b79c5113 to 14f6a1ec15 2026年07月07日 16:51:16 +02:00 Compare
vkobel force-pushed feat/webauthn-discoverable-login from 14f6a1ec15 to b0ca32110c 2026年07月07日 17:19:36 +02:00 Compare
vkobel force-pushed feat/webauthn-discoverable-login from a3d682fb79 to ee7cc6eb62 2026年07月07日 18:30:28 +02:00 Compare
vkobel changed title from (削除) [WIP] webauthn: stop broadcasting all credentials at login, add discoverable + scoped flow with username-claim gate` (削除ここまで) to webauthn: stop broadcasting all credentials at login, add discoverable + scoped flow with username-claim gate` 2026年07月07日 18:30:40 +02:00

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.

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.
Thread UsernameScope through AuthState::Discoverable and check it at finish so a decoy/known-zero-cred challenge can't authenticate as a different resident user; UnexpectedCredentialOwner now returns 400 instead of 500. CLI: guard the register username prompt against non-interactive stdin.
Make a username-present /auth/login/begin indistinguishable whether the
username exists with credentials, exists with zero credentials, or does
not exist at all. Before this, a known account with credentials returned
a distinct non-empty allowCredentials list while every other case
returned an empty one, and /auth/login/finish leaked a status oracle
(unknown-user/bad-signature -> 500, decoy/scope reject -> 400). Together
those let an attacker enumerate registered usernames.
Three items land as one change because they are interdependent:
- Normalized finish errors: every credential-verification outcome
 (unknown credential, bad signature, decoy/scope rejection, expired or
 invalid session) collapses to one byte-identical 401
 {"error":"authentication_failed"}. Genuine infrastructure failures keep
 the same body but are logged at error/warn so outages stay visible.
- HMAC-synthesized decoys (new decoy.rs): an unknown or zero-credential
 username gets a deterministic, per-username-stable fake allowCredentials
 list keyed off the existing csrf_secret (domain-separated, no new
 secret to provision). Same shape as the real path -- non-empty list, no
 mediation, UV=Preferred. Synthetic IDs never exist in server state and
 check_username_scope rejects any decoy at finish, so a decoy can never
 complete a ceremony. DB round-trips are equalized across hit/miss; the
 residual per-credential deserialize timing cost is documented as a
 known, out-of-scope limitation.
- Rate limiting: a tighter per-IP budget on scoped begin (hard 429, keyed
 by IP so it leaks nothing about a username) plus a per-username budget
 that degrades to a decoy rather than a 429 (a hard per-username limit
 would let an attacker DoS a real user's login and would itself signal
 that the username is being targeted). In-memory, single-replica; the
 assumption is documented in rate_limit.rs.
Also from self-review: the per-IP scoped limiter is now enforced on the
QR cross-device authenticate path (it was documented as covered but only
wired into the direct begin handler), and the e2e suite asserts the
finish oracle is closed, decoys are byte-stable across probes, a real
user's scoped response still carries their actual credential id (not a
decoy), and the scoped rate limit trips while the no-username path is
unaffected.
Verified: cargo test -p gateway (87/0); e2e login (20/20), software
round-trip, and CDP resident browser all pass on amd64.
Code review of the login-begin timing-equalization fix found two gaps:
the decoy fixture was ES256-only (real RSA credentials cost more to
deserialize) and a broken fixture would fail open silently. Alternate an
RS256 fixture in and validate both at startup instead.
vkobel force-pushed feat/webauthn-discoverable-login from 4548ef702d to 774638c289 2026年07月09日 19:14:03 +02:00 Compare

@vkobel I previously had a ticket for optionally collecting email at registration - #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
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/webauthn-discoverable-login:feat/webauthn-discoverable-login
git switch feat/webauthn-discoverable-login

Merge

Merge the changes and update on Forgejo.
git switch main
git merge --no-ff feat/webauthn-discoverable-login
git switch feat/webauthn-discoverable-login
git rebase main
git switch main
git merge --ff-only feat/webauthn-discoverable-login
git switch feat/webauthn-discoverable-login
git rebase main
git switch main
git merge --no-ff feat/webauthn-discoverable-login
git switch main
git merge --squash feat/webauthn-discoverable-login
git switch main
git merge --ff-only feat/webauthn-discoverable-login
git switch main
git merge feat/webauthn-discoverable-login
git push origin main
Sign in to join this conversation.
No reviewers
Labels
Clear labels
Compat/Breaking
Breaking change that won't be backward compatible
Component/Enclave-Builder
This issue affects Enclave Builder (git-push and CLI)
Component/Infrastructure
This issue affects generic Caution hosted infra components
Deploy/BYOC
Relates to Caution Bring-your-own-compute
Deploy/Full-managed
Relates to Caution fully managed cloud
Deploy/Self-hosted
Relates to Caution deployed on self-hosted infra
Interface/API
This issue affects the API
Interface/CLI
This issue affects the command line interface
Interface/Git
This issue affects the Git `push` interface
Interface/Web
This issue affects the web dashboard
Kind/Bug
Something is not working
Kind/Documentation
Documentation changes
Kind/Enhancement
Improve existing functionality
Kind/Feature
New functionality
Kind/Security
This is security issue
Kind/Testing
Issue or pull request related to testing
Priority
Critical
The priority is critical
Priority
High
The priority is high
Priority
Low
The priority is low
Priority
Medium
The priority is medium
Reviewed
Confirmed
Issue has been confirmed
Reviewed
Duplicate
This issue or pull request already exists
Reviewed
Invalid
Invalid issue
Reviewed
Won't Fix
This issue won't be fixed
Status
Abandoned
Somebody has started to work on this but abandoned work
Status
Blocked
Something is blocking this issue or pull request
Status
Need More Info
Feedback is required to reproduce issue or to continue work
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
3 participants
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
caution/platform!365
Reference in a new issue
caution/platform
No description provided.
Delete branch "feat/webauthn-discoverable-login"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?