Mutli-user credentials #478
No reviewers
Labels
No labels
Compat/Breaking
Component/Enclave-Builder
Component/Infrastructure
Deploy/BYOC
Deploy/Full-managed
Deploy/Self-hosted
Interface/API
Interface/CLI
Interface/Git
Interface/Web
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority/Critical
Priority/High
Priority/Low
Priority/Medium
Reviewed/Confirmed
Reviewed/Duplicate
Reviewed/Invalid
Reviewed/Won't Fix
Status/Abandoned
Status/Blocked
Status/Need More Info
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
public/platform!478
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "ryansquared/multi-user-per-credential"
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?
Also includes
cargo fmtandcargo clippyfixes that were missed from previous PRs.Review: multi-user-per-credential (@
2270de34)Blocking
API session can resolve to the wrong account:
src/api/src/middleware.rs:191validate_sessionstill joinsauth_sessions → fido2_credentialsoncredential_idonly. With a shared key this returns one row per owner, andfetch_optionaltakes whichever comes first. So Bob's session can be authenticated as Alice on any API route. It should read the news.user_id.Migrations fail on existing DBs:
src/api/migrations/001_initial.sql:336001 now creates
idx_sessions_user_id. On an existing DB,CREATE TABLE IF NOT EXISTSdoes nothing, so theuser_idcolumn doesn't exist until 057 adds it.utils/makefile-run-migrations.shruns withON_ERROR_STOP=1, so the run stops at 001 and 056/057 never apply. Move the index into 057.Decoy login panics:
src/gateway/src/handlers/webauthn/login.rs:284expected_user_id()hitsunreachable!()onDecoy { expected_user_id: None }, which is the scope for an unknown or rate-limited username. All four finish paths call it for any Decoy (login.rs:784,865,qr_auth.rs:874,936). The connection drops instead of returning the opaque 401, which also tells an attacker whether a username exists.Deleting a passkey no longer ends its sessions:
src/api/migrations/056_credential_multi_account.sql:13Dropping
fk_auth_sessions_credentialalso removes itsON DELETE CASCADE, and nothing replaces it. Passkey delete (gateway/src/db.rs:858) and the admin WebAuthn reset now leave existing sessions valid. A composite FK(user_id, credential_id) → fido2_credentials(user_id, credential_id) ON DELETE CASCADEwould restore the cascade.Release approval can be hijacked by reusing a victim's credential ID:
src/gateway/src/release_relay.rs:43Credential IDs are public (they're sent in
allowCredentials), and with none-attestation the client chooses the rawId. Mallory registers Alice's credential ID with Mallory's own key, the owner set becomes[alice, mallory], andsole_ownerlets Mallory open Alice's approval page. The old code explicitly rejected this.Should fix
Username-less login rejects shared keys:
login.rs:857(alsologin.rs:771,qr_auth.rs:924)The unscoped finish requires exactly one owner and ignores the assertion's
userHandle. Legitimate shared-key users always get a 401, and the trick in #5 lets anyone lock a victim out of username-less login.users.fido2_user_handlecould pick the right owner. The comment "resolve the user from the assertion's userHandle" is now stale.Re-adding your own key returns 500:
src/gateway/src/handlers/credentials/passkey.rs:324excludeCredentialsand thecredential_exists409 check were removed instead of being scoped to(user_id, credential_id). Re-adding a key now hits the new unique index and returns a generic 500. For a resident key, it silently overwrites the credential on the authenticator and leaves a dead row in the DB.Nits
Backfill picks an arbitrary owner:
src/api/migrations/057_session_user_id.sql:19The
UPDATE … FROMjoins oncredential_idonly, so a shared credential gets any one of its owners. The comment says ambiguous sessions are deleted. This only matters on reruns or out-of-order runs.Owner-resolution block is copy-pasted 4 times:
login.rs:771,855,qr_auth.rs:862,924The #3 panic is in all four copies. One
resolve_owner(owners, scope) -> Option<Uuid>helper would fix both problems.One query per credential in the release relay:
src/gateway/src/release_relay.rs:173It runs one
get_user_ids_by_credentialper holder credential (up to 64) inside a time-limited window. The old batchedANY($1)query did this in a single round trip.unreachable!()Open questions
userHandle? Rejecting closes both.Working on QR but non-username'd users shouldn't exist anymore.
Needs fixing:
Review: ryansquared/multi-user-per-credential (@
2e56f17e)Fixed since last review: API session lookup, 001 index, decoy panic, and session cascade on passkey delete.
Blocking
src/gateway/src/db.rs:634(release_relay.rs:43,src/api/src/cryptographic_bundles/holders.rs:137)The check accepts a reused credential ID if the new registration's full key record matches the stored one exactly. Quorum bundles give every org member each holder's key record, and for passkeys without a signature counter (most synced passkeys) that record never changes. So an org member can replay it in an attestation-"none" registration, become a co-owner of the holder's credential, and pass
sole_owneron the holder's release approval page. Hardware keys have the opposite problem: every login rewrites the record (db.rs:948), so a legitimate second registration never matches. An attestation-"none" registration never proves the client holds the private key, so comparing public data can't fix this. Either require an assertion signed by the existing credential before adding it to another account, or restore the globalUNIQUE(credential_id).Should fix
Login breaks between
make migrateand the gateway restart, and on rollback:src/api/migrations/057_session_user_id.sql:32057 makes
auth_sessions.user_idNOT NULL, but the gateway onmaininserts sessions without that column. Migrations run as a separatemake migratestep (Makefile:486). Until the new gateway is live, or after rolling it back, every login, registration and QR login fails with a 500. Add the column as nullable now and make it NOT NULL in a later release.Username-less web login rejects any credential with two owners:
src/gateway/src/handlers/webauthn/login.rs:826Agreed that QR is fine, since it always requires a username. But web login still runs unscoped when the username field is empty (
frontend/src/composables/useWebAuthn.js:142). That path throws away the assertion's user handle and requires exactly one owner. Pick the account by user handle (users.fido2_user_handle), so a shared or squatted credential can't lock out its owner.Add-passkey has no
excludeCredentialsand no same-account 409:src/gateway/src/handlers/credentials/passkey.rs:339(:430)As you noted, re-adding a key that's already on the account creates a second credential, or overwrites the resident one and leaves a dead row. An exact same-account duplicate hits the unique index and returns a 500.
mainpassed the user's own credentials asexcludeCredentialsand returned 409, so restore both, scoped to(user_id, credential_id).Nits
057 backfill comment is wrong, and migrated DBs miss the
usersFK:src/api/migrations/057_session_user_id.sql:19(:12,:26)The comment says multi-owner sessions are deleted, but
UPDATE … FROMassigns one of the owners. That's harmless on the first run, because no shared credentials exist yet. Migrated DBs also lack theREFERENCES users(id)that fresh installs get from 001. Deleting a user still cascades through credentials, but the two schemas differ.One owner query per credential in the release relay:
src/gateway/src/release_relay.rs:174That's up to 64 queries in a row inside a time-limited window. A single
credential_id = ANY($1)query does the same job.Owner-resolution block copy-pasted 4 times, two copies partly dead:
login.rs:761,846,qr_auth.rs:863,932The
Unscopedbranches in the two QR copies can never run, because QR requires a username (qr_auth.rs:556). A singleresolve_owner(&owners, &scope) -> Option<Uuid>helper would replace all four.Redundant clippy allows:
src/gateway/src/main.rs:7gateway and api already allow
redundant_field_namesat the crate root, so the 19 per-module copies do nothing.Open questions
navigator.credentials.create()creates a new credential ID, even on the same physical key, so registering one key to a second account already works onmain. If no such flow exists, restoringUNIQUE(credential_id)removes #1, #3 and most of the ownership logic in this branch.UNIQUE(credential_id).Merged via CLI
Pull request closed