From 1471d1d578bbb78cd6a45274185e0b250bdc1c6c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:27:55 -0400 Subject: [PATCH] fix(passkey): derive WebAuthn user.id deterministically to stop duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/encryptid/server.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 69b75915..20c65538 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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 = {