fix(passkey): derive WebAuthn user.id deterministically to stop duplicates

Retries of /api/register/start previously generated a fresh random user.id
each time, so the authenticator (iCloud Keychain, Windows Hello, 1Password,
etc.) stored a brand-new passkey per attempt. Users who hit the failing
registration flow ended up with three or four orphan passkeys in their
password manager for every successful one.

WebAuthn spec: a create() ceremony with the same (rpId, user.id) overwrites
the existing passkey. Deriving user.id as SHA-256(salt + username) means
repeated start calls for the same username produce the same user.id and the
authenticator overwrites in place.

Salt chain: USER_ID_SALT → JWT_SECRET → fallback constant. No new env var
needed in prod — JWT_SECRET is already set.
This commit is contained in:
Jeff Emmett 2026-04-17 10:27:55 -04:00
parent 43d68fd521
commit 1471d1d578
1 changed files with 11 additions and 2 deletions

View File

@ -579,8 +579,17 @@ app.post('/api/register/start', async (c) => {
// Generate challenge
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Generate user ID
const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Derive userId deterministically from username so repeated /start calls for
// the same username produce the same WebAuthn user.id. Per WebAuthn spec, a
// repeated create() ceremony with the same (rpId, user.id) overwrites the
// existing passkey instead of creating a new one — stops failed retries from
// leaving orphan passkeys behind in the user's password manager.
const userIdSalt = process.env.USER_ID_SALT || process.env.JWT_SECRET || 'encryptid-user-id';
const userIdBuf = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(`${userIdSalt}:${username.toLowerCase()}`),
);
const userId = Buffer.from(userIdBuf).toString('base64url');
// Store challenge in database
const challengeRecord: StoredChallenge = {