-
The WebAuthn user handle is 64 random bytes instead of the UTF-8 of whatever the caller passes as
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. Deriving the handle from a typed name therefore meant 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. The same happened to one person re-entering their usual name after clearing storage. WebAuthn L2 §5.4.3 also forbids putting personally identifying information in the handle, which a name or e-mail plainly is, and the handle is stored in the authenticator indefinitely.userIdkeeps its rightful place asuser.name, the label the credential picker shows. It is a label only: it identifies nothing, two credentials may carry the same one, and nothing in this package looks a credential up by it. Closes #45.Nothing needs migrating. Recovery goes through discoverable credentials (
readLargeBlobMetadatacallsget()with noallowCredentials), or through an explicit credential ID where those are switched off — no path resolves a credential by handle, so credentials registered under the old scheme keep working untouched. The deriveddid:keycomes from the credential's public key and does not change.Consumers should expect a behavioural difference: 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 it means the picker can show several, so
user.nameanddisplayNameshould be distinguishing enough to pick from. The new random handle is returned ascredential.userHandle(base64url); the authenticator keeps its own copy, so storing it is optional. -
Actually write the secret key into
largeBlob. The keystore record carriedsecretKey: sk, // Will be moved to largeBlob— and nothing ever moved it. That was the only occurrence of the field insrc/, andstoreEncryptedKeystore()serialises a fixed whitelist that does not include it, so the key was dropped at persist time. Choosing largeBlob therefore produced a keystore that worked for the session that created it and could never be unlocked again: on the next loadretrieveSKFromLargeBlob()read a blob nobody had written and failed with "No largeBlob data found in credential".The key never reached disk in the clear — the whitelist saw to that — but the feature was inert.
writeSKToLargeBlob()now performs the assertion that stores it (a blob can only be written during an assertion, never at registration, so this costs one extra prompt) and throws unless the authenticator reportswritten: true. Failing there beats persisting a record that cannot be opened. -
Carry the authenticator's own answer on the credential.
createCredential()now recordsextensionSupport— what the authenticator agreed to during the ceremony — because the rawPublicKeyCredentialdoes not survive past that function, so the answer is read there or lost. Client support is not authenticator support: measured on a macOS platform authenticator in Brave, the client advertiseshmacCreateSecretwhile the authenticator refuses it and PRF works. Reading only the client is what left the demo offering hmac-secret and then failing at the ceremony, which is the failure originally reported in #9.The encrypted-keystore demo consumes it: methods the authenticator refused read "Browser yes, this passkey no" rather than "Supported", become unselectable, and a selection it cannot honour falls back automatically. It also no longer logs
encrypted: Yes (largeBlob)off the back of the requested options — that line reported intent as though it were outcome, so a failed write looked identical to a successful one. It now names the request and the authenticator's capability separately, so a mismatch is visible. -
Detect WebAuthn extensions by asking the browser instead of inspecting a prototype.
checkExtensionSupport()tested'largeBlob' in PublicKeyCredential.prototype— but extensions are never properties of that interface, they arrive throughgetClientExtensionResults(). The test therefore returnedfalsein every browser ever shipped, including those with complete support, andhmacSecretwas hard-codedfalsebeside it. Both together meant the encrypted-keystore demo disabled its own headline feature everywhere and could not be switched on at all.Measured on Chrome 148: the prototype probe answered
falseforlargeBlob,prfandhmacCreateSecretwhilegetClientCapabilities()reported all threetrue. Every extension name behaves the same way, so no browser could ever have passed. Closes #9.checkExtensionSupport()now readsgetClientCapabilities()and reportsprfalongside the other two. It also returnsknown, distinguishing "the browser says no" from "the browser cannot say" — callers must not treat the second as a refusal, or they disable a feature that may well work.
extensionSupportFromCredential(credential)reads what the authenticator actually agreed to, from the registration response. This is the authoritative answer and the one worth persisting: a browser may supportlargeBlobwhile the security key in front of it does not, and only the ceremony settles that.addLargeBlobToCredentialOptions()takes an optional support level for the same reason — it stays'required'by default, because under'preferred'an unsupported authenticator yields a credential whose secret was silently never written.
- The encrypted-keystore demo offers PRF as a keystore encryption method and
prefers it, matching the library's own default since 0.4.0. It had hard-coded
largeBlob, so it overrode that default and never exercised PRF at all. Methods the browser cannot vouch for are now labelled "Unknown" rather than "Not supported", and stay selectable.
-
The varsig signing context moves into the signed payload, which changes the bytes a credential signs. Entries and identities produced by 0.4.x and earlier no longer verify, and peers running different major versions cannot replicate with each other — the whole set has to move together.
The challenge used to be
SHA-256(label ‖ payload). That separated the three signing contexts, but it meant the challenge was no longer the hash of the payload, and the varsig WebAuthn draft has a verifier re-hash the payload and compare it against the challenge (step 5). Nothing outside this package could reproduce that value.The label itself is not the problem: one credential signs an identity, a public key and an oplog entry, and a signature over one must not be replayable as a signature over another. That separation cannot live in the varsig header either, because a WebAuthn signature only covers
authenticatorData ‖ SHA-256(clientDataJSON)— anything carried beside the challenge is unauthenticated. So the context moves into the signed bytes:bindContext()frames it length-prefixed ahead of the payload, and the challenge becomes a plain SHA-256 multihash of those bytes. Both properties now hold — the three contexts still yield different challenges, and a spec verifier can reproduce the digest.The length prefix is load-bearing. Without it, context
awith payloadbcand contextabwith payloadcare the same bytes, so a chosen payload could absorb the label and sidestep the separation.This is a bet on a draft: ChainAgnostic/varsig#11 is still open with
webauthn-varsig-header = TODO. If the draft settles differently, the wire format changes again. -
iso-webauthn-varsigmoves to 0.3.0, which corrects the Ed25519 curve code in the varsig header from0xed01to0xed.0xed01is how the multicodeced25519-pubis written in prose — but that is its varint encoding, and using it as the code meant emitting 60673, which no other implementation reads as Ed25519. Ed25519 varsigs written by earlier versions do not decode. P-256 bytes are unchanged by this second break, so P-256 deployments are affected only by the challenge change above.
Nothing carries over automatically. Re-register credentials and re-create any database whose entries were signed under 0.4.x, and upgrade every peer — including relays that verify identities — in one step rather than gradually.
-
Restore debug logging in the browser.
weald, which@libp2p/loggeruses, doesimport { ms as humanize } from 'ms'— a named export that only exists inms@3+. All three demos pinnedmsto2.1.3, which is CommonJS with no named export, so the import resolved toundefinedand the first debug call threwTypeError: (0, import_ms2.ms) is not a function. EnablingDEBUGin a browser therefore crashed credential creation outright. Dropping the pin letswealdresolve the version it declares.This is why
webauthn-logging-e2ehad been failing continuously: it is the only suite that setslocalStorage.debug, so it was the only one to reach the broken path. -
Clear the production dependency advisories.
iso-web > iso-kv > conf > ajvcarried four highfast-uriadvisories and one moderateajvone; the scoped overridesconf>ajvandajv>fast-uriresolve it. Scoped deliberately: a blanketajvoverride also hits ESLint, which needs ajv 6 and dies on the removedmissingRefsoption.pnpm audit --prodnow reports nothing, so the enforced CI gate moves fromcriticaltomoderate. -
Stop writing WebAuthn credentials to the console.
logWebAuthnResponse()dumped the whole credential —rawId,attestationObject,clientDataJSON,signature,getPublicKey()andgetClientExtensionResults()— on everynavigator.credentials.create()and.get(), unconditionally and in production code paths. Two things made that more than noise: extension results can carry the PRF output, which is what both the keystore encryption and (since 0.4.1) the OrbitDB signing key derive from, andrawIdis a stable per-user, per-RP identifier that landed in any console capture or session replay the embedding app happened to run. Replaced by a single shared helper that logs shape only — byte lengths, presence flags and the names of the extensions that returned results — behind the package debug logger, so it is silent unlessDEBUG=orbitdb-identity-provider-webauthn-did*is set. Closes #22. -
Keep the demos' P2P component out of the server render. Turning on
ssrso prerendering emitted real content also meant+page.sveltewas evaluated in Node, and it imported the libp2p stack at module scope — reaching@libp2p/webrtcand its nativenode-datachannelbinding, which the demos do not build. The dev server returned 500 for every request. The component is loaded in the browser only now; the shared shell still renders server-side, so prerendering keeps producing a real title and description.
- Route the remaining informational
console.logoutput through the debug logger. A library should not print unconditionally;console.errorandconsole.warnstay as they are, so genuine failures remain visible.src/went from 61console.*calls to 16, none of themconsole.log. - Deduplicate three byte-identical copies of the WebAuthn debug helper into
src/webauthn/debug-log.js.
- Derive the OrbitDB signing key from the passkey PRF output instead of letting
the keystore generate a random one.
Identities.createIdentitytakeskeystore.getKey(id) || keystore.createKey(id), andcreateKeyis random, so the same passkey previously produced a differentpublicKeyandsignatures.id— and therefore a different identity document — on every device. Seeding the keystore duringgetId(), the last point before OrbitDB asks for the key, makes the whole document reproducible: one block to keep retrievable for a DID instead of one per device, and a device that loses its keystore while keeping the passkey reconstructs the same identity rather than minting another. Needs no change to@orbitdb/core. createCredential()now requests the PRF extension even when the keystore is not encrypted, and stores the input. Without a stored input the output would differ per call, so a credential registered without one can never get a reproducible identity. Requesting it is harmless where unsupported.
- Falls back cleanly. No PRF support, no stored input, or a refused assertion
leaves the keystore to generate its own key — 0.4.0 behaviour, stable per
device but not across devices. Opt out with
deriveSigningKeyFromPrf: false. - An existing keystore key is never replaced. Swapping it under a device that already has history would mint a second document for the DID, which is what this avoids. Installs upgrading from 0.4.0 keep their key; fresh installs get a reproducible identity.
- Costs one extra assertion the first time an identity is created on a device. The key is persisted, so later loads do not repeat it.
- WebAuthn credentials now yield the authenticator's actual public key, so the
derived
did:keychanges for anyone who registered against 0.3.x or earlier. Existing OrbitDB identities keyed on the old DID will not match, and databases gated on it become unwritable under the new DID.extractPublicKey()never took its intended path:cbor-webreturns byte strings as views into the enclosing buffer, so readingcredentialIdLengththroughauthData.bufferwithout honouringbyteOffsetread bytes from insiderpIdHashand yielded 43690 for every credential. The COSE slice was then empty,cborthrewInsufficient data, and thecatchsilently returned a synthetic key derived fromSHA-256(credentialId).
- Keep the identity document stable across reloads.
signIdentity()reuses the proof it already produced instead of running a fresh WebAuthn assertion, which changedsignatures.publicKey— and therefore the content address of the identity document — on every page load. Peers then dropped entries:verifiedIdentitiesCachein@orbitdb/coreis keyed on the deterministicsignatures.id, so two documents from one keystore collide on a single cache entry andisEqual()rejects whichever was not verified first. The symptom was a database replicating some entries and silently never receiving the rest. - Remove the
timestampfield from the proof envelope and the 24-hour expiry check that read it. Both were wrong for a value embedded in content-addressed, permanent history: the timestamp changed the document hash on every call, and the expiry would have invalidated the identity behind every entry ever signed under it. Compatible in both directions — proofs that still carry a timestamp verify fine, and 0.3.1 verifying a proof without one computesNaN, which fails its> maxAgetest. - Prefer
response.getPublicKey()(WebAuthn L2) over parsing the attestation object, and correct the parser: honourbyteOffset, validate the AT flag, bounds-checkcredentialIdLength, and decode only the first CBOR item so trailing extension data (the ED flag, set when PRF is requested) no longer throws. The synthetic fallback is now markedsynthetic: true. - Fix an ambiguous locator in
ed25519-keystore-did:getByLabelmatches substrings, and the demo's worker toggle is labelled "Use worker-backed Ed25519 keystore".
- Two-peer OrbitDB replication tests: real libp2p over loopback TCP, Helia with bitswap, gossipsub, two OrbitDB instances, driven by a software WebAuthn authenticator with a real P-256 keypair, an incrementing signature counter and randomised signatures. Covers identity-document stability across reloads, replication of entries written before and after a reload, and that two devices sharing a passkey keep distinct, independently valid identities.
- Attestation-parsing unit tests: credential ID lengths 16/20/32/64/128, trailing extension data, missing AT flag, non-P-256 COSE keys and truncated coordinates. 13 of the 14 fail against 0.3.1.
- CI now runs all eleven test files. Five never ran:
webauthn-unit,webauthn-verification,standalone-toolkit,ed25519-keystore-didandsimple-encryption-integration.webauthn-unitimports through Vite's/@fsendpoint, which only the dev server exposes, so its step runs againstdevrather than thepreviewbuild CI otherwise uses. - Publishing moves into CI. A
v*tag runs the full suite and then publishes via npm Trusted Publishing (OIDC), with provenance. A manual run defaults to a check mode that verifies tag/version agreement and packaging without publishing. test:ci, whichpreversionandprepublishOnlyrun, now points at the Node-context suites viaplaywright.node.config.js— 32 tests in about ten seconds. It previously ranwebauthn-verificationalone: five tests that check regexes against hardcoded DID literals and one fully mocked database object, none of which creates a credential, touches the keystore or opens an OrbitDB.- Restore the
security-audit,package-validationandnotifyjobs. The enforced audit gate is--prod --audit-level=critical; auditing the full tree atmoderatereports 86 advisories from the helia and libp2p dev tree, and even--prodreports 5 throughiso-web > iso-kv > conf > ajv, so both wider audits run informational until that chain is bumped.
Entries that had accumulated under Unreleased since 0.3.1:
- Add
SECURITY.mdwith vulnerability reporting and supported-version policy. - Rename
changes.mdtoCHANGELOG.mdand include it in the published package. - Add public TypeScript declarations for the root, standalone, verification, and keystore package entrypoints.
- Add
docs/API.mdand expose@le-space/orbitdb-identity-provider-webauthn-did/keystoreas a typed package subpath. - Remove the Vite node polyfill plugin from production dependencies, update
the varsig support stack to
iso-web@^3.1.2, and verifynpm audit --omit=devreports zero production advisories. - Re-enable the CI lint step and verify
pnpm run lintpasses. - Add shared public constants and catchable error classes for WebAuthn, keystore, and varsig flows.
- Add root Prettier scripts, ignore rules, and a CI formatting check.
- Add
CODE_OF_CONDUCT.mdand include it in the published package.
- Bump package metadata to
0.3.1and create the clean release tag after the post-0.3.0CI fixes landed. - Update example lockfiles so all demos install successfully with
pnpm install --frozen-lockfile. - Fix Playwright web server startup by passing Vite
preview/devarguments directly, avoiding CI timeouts waiting for the wrong port. - Update GitHub Actions to current action majors:
actions/checkout@v7,pnpm/action-setup@v6,actions/setup-node@v6, andactions/upload-artifact@v7. - Verify the release commit with GitHub Actions: root frozen install, all three example frozen installs, all three example builds, WebAuthn focused tests, logging E2E, integration E2E, varsig E2E, encrypted-keystore tests, and Ed25519 encrypted-keystore E2E.
- Verify the package tarball with
npm pack --dry-run; the package reports@le-space/orbitdb-identity-provider-webauthn-did@0.3.1with 27 published files.
- Upgrade the OrbitDB stack to
@orbitdb/core@^4.0.0. - Upgrade Helia to
helia@^7.0.1and add the current Helia service packages:@helia/libp2p,@helia/http, and@helia/bitswap. - Upgrade libp2p to
libp2p@^3.3.4and the current scoped packages, including@libp2p/gossipsub@^16.0.3,@libp2p/identify@^4.1.8,@libp2p/websockets@^10.1.15,@chainsafe/libp2p-noise@^17.0.0, and@chainsafe/libp2p-yamux@^8.0.1. - Confirm the gossipsub stream-registry fix from
libp2p/js-libp2p#3531is included via@libp2p/gossipsub@16.0.3. - Port all Svelte examples to the OrbitDB 4, Helia 7, and libp2p 3 stack:
webauthn-todo-demo,ed25519-encrypted-keystore-demo, andwebauthn-varsig-demo. - Update example libp2p configuration for the v3
connectionEncryptersoption and currentwithLibp2p,withHTTP, andwithBitswapHelia setup. - Update OrbitDB identity and keystore integration for the current OrbitDB 4 public APIs, including keystore key generation/storage.
- Replace older dynamic codec/hash imports with static multiformats imports where needed.
- Remove obsolete local OrbitDB patches.
- Clean up known Vite/polyfill build warnings in the examples.
- Fix varsig verification edge cases for replicated entries, mixed worker/hardware verification, and Node relay default exports.
- Release metadata update after varsig verification and worker/hardware compatibility fixes.
- Version metadata update after discoverable passkey recovery work.
- Add discoverable passkey recovery flows.
- Add worker-backed keystore demo coverage and fix the worker keystore demo build.
- Export varsig verification, identity storage, and
wrapWithVarsigVerification. - Stabilize Chromium E2E/unit coverage and align Ed25519 keystore tests.
- Sync docs, format the codebase, and restrict CI Playwright runs to Chromium.
- Release metadata update for the standalone compatibility series.
- Add standalone compatibility fallback release.
- Re-enable encrypted-keystore CI and stabilize demo checks.
- Sync pnpm lockfile with package dependencies.
- Run CI on pushes to all branches.
- Merge the standalone WebAuthn toolkit feature branch.
- Add reusable standalone WebAuthn worker and varsig toolkit exports.
- Restore ucanto signer metadata and add issuance regression coverage.
- Stabilize WebAuthn unit harness and mock credentials.
- Finalize standalone toolkit integration and README updates.
- Refresh pnpm lockfile.
- Ship patch-package in dependencies so postinstall works for consumers.
- Add WebAuthn varsig demo E2E coverage and test-mode stubs for CI.
- Update CI to focus on Chromium-only runs and disable failing encrypted keystore tests.
- Add @libp2p/crypto dependency and update lockfile.
- Publish varsig demo build to Storacha and link in README.
- Switch iso dependencies to the published
@le-spacefork and pin@le-space/iso-did@2.1.2. - Restore unscoped
iso-webfrom npm to satisfyiso-didruntime deps. - Document forked iso packages used for WebAuthn varsig support.
- Clarify Varsig vs keystore-based DID paths and reference the example demos.
- Initial preview release with WebAuthn DID and varsig provider.