fix: stop deriving the WebAuthn user handle from the typed name - #46
Merged
Conversation
`user.id` was `TextEncoder().encode(userId)`. An authenticator keeps one discoverable credential per (rp.id, user.id) and replaces the previous one when both match — silently, no prompt, nothing to undo. So two people registering as "anna" on a shared device destroyed each other's passkey, and with it the DID and every entry signed under it. One person re-entering their usual name after clearing storage did the same to themselves. WebAuthn L2 §5.4.3 is explicit on both counts: the handle MUST NOT carry personally identifying information such as a username or e-mail address, and SHOULD be 64 random bytes. It is now exactly that, and `userId` keeps its rightful place as `user.name`, the label the picker shows. Nothing needs migrating. No path in this package resolves a credential by handle — recovery goes through discoverable credentials, or an explicit credential ID where those are off — so credentials registered under the old scheme keep working. The derived did:key comes from the credential's public key and is unaffected. The mock authenticator now models the replacement rule, which it did not before: it files credentials by handle and drops what was in the slot. That is what makes the regression test meaningful rather than decorative. Verified: with the old line restored, three of the four new tests fail — including the one that asserts what the library sends, which holds regardless of how faithfully the mock behaves. All 67 node tests pass with the fix, prettier and eslint are clean. Closes #45 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 16, 2026
Merged
NiKrause
added a commit
that referenced
this pull request
Aug 16, 2026
A patch release: nothing here breaks. Credentials registered under 0.5.0 keep working, the derived did:key is unchanged, and no peer has to move in step with another. The headline is the WebAuthn user handle (#45/#46). It was the UTF-8 of whatever the caller passed as `userId`, so an authenticator — which keeps one credential per (rp.id, user.id) and replaces it when both match — silently destroyed the first passkey when a second person on the same device used the same name, and the DID with it. It is 64 random bytes now; the typed value stays as `user.name`, the label the picker shows. Consumers get one behavioural difference worth naming in their own UI: re-registering under a name used before adds a passkey instead of replacing one. That is the point, but it means a picker can now show several, so the labels want to be distinguishable. Also in this release: the largeBlob keystore actually persists its secret key (#43), the credential carries the authenticator's own answer on extension support rather than the browser's guess (#43), and extension detection asks `getClientCapabilities()` instead of probing a prototype that never had the properties (#9/#42) — that last one had the encrypted-keystore demo disabling its headline feature in every browser ever shipped. 67 node tests pass against the bumped version. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
NiKrause
added a commit
to Le-Space/yogasuci
that referenced
this pull request
Aug 21, 2026
This branch removed the second field and the warning about it on the argument that the WebAuthn user handle is no longer derived from what somebody types. That is only true from Le-Space/orbitdb-identity-provider-webauthn-did#46, which is merged upstream but was in no release when this was opened — so the PR said it must not merge until one existed. One does: 0.5.1 on npm. Its `createCredential` builds the handle as const userHandle = crypto.getRandomValues(new Uint8Array(64)) and passes it as `user.id`, which is exactly the change this depends on. It is easy to miss, and that is worth recording: 0.5.1 was published to npm without a GitHub release, so the release list still ends at v0.5.0 from a day before the merge. Looking there says the blocker is still there. Both the override and the dependency range move together; leaving the range at ^0.5.0 while the override said 0.5.1 would be two sources of truth for one decision. Refs #85
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #45.
user.idwasTextEncoder().encode(userId). An authenticator keeps one discoverable credential per(rp.id, user.id)and replaces the previous one when both match — silently, with no prompt and nothing to undo. So two people registering as "anna" on a shared device destroyed each other's passkey, and with it the DID and every entry signed under it. One person re-entering their usual name after clearing storage did the same to themselves.WebAuthn L2 §5.4.3 is explicit on both counts: the handle MUST NOT carry personally identifying information such as a username or e-mail address, and SHOULD be 64 random bytes. It is now exactly that.
userIdkeeps its rightful place asuser.name, the label the credential picker shows — a label only: it identifies nothing, two credentials may carry the same one, and nothing here looks a credential up by it.Nothing needs migrating
No path in this package resolves a credential by handle. Recovery goes through discoverable credentials (
readLargeBlobMetadatacallsget()with noallowCredentials), or through an explicit credential ID where those are switched off. Credentials registered under the old scheme keep working untouched, and the deriveddid:keycomes from the credential's public key, so it does not change.What consumers will notice
Re-registering under a name that was used before now adds a passkey instead of replacing one. That is the point — a replaced passkey is data loss, a second entry is a choice — but the picker can now show several, so
user.nameanddisplayNameshould be distinguishing enough to choose from. The new handle is returned ascredential.userHandle(base64url) for callers that will one day passallowCredentials; the authenticator keeps its own copy, so storing it is optional.Two consumer-side follow-ups, out of scope here: the two input fields in yogasuci's
StudioGate.sveltecan become one — a code comment there already anticipates exactly this change — and its hint "If somebody on this device has already used the same name, pick another: theirs would be replaced" is now false and should go. simple-todo'spasskey01/acl01chapters carry the same two fields plus auser id (e.g. alice@example.com)placeholder that should stop inviting an e-mail address.The mock now models the rule it was silent about
tests/helpers/mock-authenticator.jsreturned one fixed credential ID per instance, so replacement could not be observed at all. It now files credentials by handle and drops whatever was in the slot, which is what makes the regression test meaningful rather than decorative.Verification
userdictionary handed tocreate(), so it holds regardless of how faithfully the mock behaves.playwright.node.config.js, so it gatesnpm publishrather than only the browser run.