@le-space/orbitdb-identity-provider-webauthn-did 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changes
2
2
 
3
+ ## 0.9.0
4
+
5
+ What applications kept writing for themselves, once and here: one read of the PRF output for every
6
+ key an application derives from it, and a vault, so that a second security key can open what the
7
+ first one sealed. Nothing changes for code that does not call it, and every derivation that exists
8
+ today gives the same bytes.
9
+
10
+ ### Added
11
+
12
+ - **`readPrfOutput(credential, options?)`**: one assertion, the PRF output. It always asks with a
13
+ fixed input — the credential's own, else the relying party's from `prfInputForRelyingParty` —
14
+ and never with a random one, whose answer would never come back. `PrfUnavailableError` when the
15
+ authenticator has no PRF; a refused prompt stays the browser's `NotAllowedError`.
16
+ - **`deriveSubkey(prfOutput, info, { length })` and `deriveAesKey(prfOutput, info)`**: HKDF-SHA-256
17
+ with an empty salt, as bytes or as a non-extractable AES-GCM key. They give the same bytes as the
18
+ HKDF that Le-Space/belege, Le-Space/invoice and simple-todo's escrow01 each wrote themselves, and
19
+ as this package's signing-key derivation, which now runs on them. Vectors from all four are
20
+ tests.
21
+ - **Vaults**: `createVault`, `openVault`, `addSlot`, `removeSlot`, `replacePayload`, `slotIdFor`
22
+ and `VaultError`. A random vault key seals the payload, and each registered authenticator gets a
23
+ slot holding that key, sealed under a key from its own PRF output, so any of them opens the
24
+ vault. The record is plain JSON with nothing secret in the clear; every ciphertext is bound to
25
+ the vault id and every slot to its authenticator. Removing a slot revokes nothing already
26
+ opened. Tested in Chromium with two virtual authenticators, each with its own PRF secret, as two
27
+ YubiKeys have.
28
+ - **`prfOutput`**, an identity-provider option: the OrbitDB signing key is derived from a PRF
29
+ output the application already read, so unlocking its data and creating the identity cost one
30
+ touch instead of two. It is the same key the provider derives when it asks itself.
31
+ - All of it in `./standalone` as well, and declared in `types/`.
32
+
33
+ ### Changed
34
+
35
+ - `extractPrfSeedFromCredential` says on the console when it has no `prfInput`: it then asks a
36
+ random question, and the seed it returns never comes back. What it returns is unchanged.
37
+ - `types/index.d.ts` declared `extractPrfSeedFromCredential` as synchronous and returning bytes. It
38
+ is async and returns `{ seed, source }`, as `types/standalone.d.ts` already said.
39
+ `PrfUnavailableError` is now declared in the root types as well.
40
+
3
41
  ## 0.8.0
4
42
 
5
43
  Two functions for wallets, and nothing that changes for anyone else. They were written against
package/README.md CHANGED
@@ -21,12 +21,12 @@ This package provides:
21
21
  - Registration is still the point where this package extracts the credential public key from attestation.
22
22
  - Later `navigator.credentials.get()` assertions do not reliably return the public key again, so identity reconstruction still needs metadata from somewhere else.
23
23
 
24
- In this repo today, metadata recovery works in two layers:
24
+ Since **0.6.0** the passkey alone is enough, and stored metadata is a convenience rather than a requirement:
25
25
 
26
- - **Preferred recovery path**: store identity metadata in WebAuthn `largeBlob` and recover it later through discoverable authentication.
27
- - **Fallback recovery path**: store the same metadata in browser `localStorage`.
26
+ - **From the authenticator**: `restoreIdentityFromAuthenticator()` asks the same passkey twice and reconstructs the DID from the two signatures and the signing key from the PRF output, with nothing kept anywhere — see [Identity Recovery Summary](#identity-recovery-summary).
27
+ - **From a stored copy**, when an application keeps one: WebAuthn `largeBlob`, or browser `localStorage`. It costs no touch at all, which is the only reason to keep one.
28
28
 
29
- This means discoverable credentials remove the need to pre-select the credential for authentication, but they do not by themselves eliminate the need for identity metadata persistence.
29
+ `largeBlob` is requested at registration since 0.5.4 ([#55](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/pull/55)), but it is not a path to rely on. Measured on hardware: Android Chrome writes the blob and does not return it on read, while desktop Chrome reads it ([#48](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/issues/48)).
30
30
 
31
31
  **Which option, and what each one actually gives you:**
32
32
 
@@ -105,11 +105,10 @@ For the DID-based flow:
105
105
  - later discoverable `navigator.credentials.get()` proves possession of the credential, but usually returns only `rawId`, `authenticatorData`, `clientDataJSON`, `signature`, and maybe `userHandle`
106
106
  - that assertion is not enough on its own to reconstruct the DID
107
107
 
108
- To address this, the demos now attempt to:
108
+ Two ways past that, and the first keeps nothing:
109
109
 
110
- 1. write identity metadata to `largeBlob` after passkey creation
111
- 2. recover that metadata later through discoverable authentication
112
- 3. fall back to local browser storage if `largeBlob` is unavailable or empty
110
+ 1. `restoreIdentityFromAuthenticator()` — two discoverable assertions, from which `recoverPublicKey()` returns the credential's public key and with it the DID (0.6.0)
111
+ 2. metadata written at registration — `largeBlob` where it comes back, otherwise browser storage — which the demos still do, because a stored copy spares both touches
113
112
 
114
113
  ### Hardware-Secured Varsig Quick Start
115
114
 
@@ -181,7 +180,7 @@ The verifier side and app protocol should define which domain label is required.
181
180
 
182
181
  ### Passkeys for smart accounts (P-256)
183
182
 
184
- One passkey can be both the OrbitDB identity and the key of a smart account that verifies P-256 WebAuthn signatures on-chain, such as Uniswap's [Calibur](https://github.com/Uniswap/calibur) with Base's [webauthn-sol](https://github.com/base/webauthn-sol). The standalone export hands wallet code the key and the signature parts; turning them into a transaction (key hashes, ABI encoding, the account client) stays in the wallet.
183
+ Since **0.8.0**, one passkey can be both the OrbitDB identity and the key of a smart account that verifies P-256 WebAuthn signatures on-chain, such as Uniswap's [Calibur](https://github.com/Uniswap/calibur) with Base's [webauthn-sol](https://github.com/base/webauthn-sol). The standalone export hands wallet code the key and the signature parts; turning them into a transaction (key hashes, ABI encoding, the account client) stays in the wallet.
185
184
 
186
185
  ```javascript
187
186
  import {
@@ -210,6 +209,24 @@ const { authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s } =
210
209
  | Keystore or worker Ed25519 DID | yes, from the credential's `x`/`y`; the DID does not commit to the passkey, so link the wallet key to it yourself |
211
210
  | Hardware Ed25519 (varsig) | `null`: there is no P-256 key |
212
211
 
212
+ ### One touch, and a second security key
213
+
214
+ Since **0.9.0**, an application that keeps data under keys from the passkey reads the PRF output once with `readPrfOutput()`, derives each key from it with `deriveSubkey()` or `deriveAesKey()`, and hands the same output to the identity provider as `prfOutput`: unlocking and signing cost one touch. What has to outlive one security key goes into a vault, with a slot for every registered authenticator, so a second YubiKey opens what the first one sealed.
215
+
216
+ ```javascript
217
+ const prfOutput = await readPrfOutput(credential);
218
+ const slot = {
219
+ slotKey: await deriveAesKey(prfOutput, 'myapp/vault-slot/v1'),
220
+ rawCredentialId: credential.rawCredentialId,
221
+ };
222
+ const { vault, vaultKey } = await createVault(payload, slot); // later: openVault(vault, slot)
223
+
224
+ // With the vault open, touch the second key and give it a slot of its own.
225
+ const withSecondKey = await addSlot(vault, vaultKey, secondKeysSlot);
226
+ ```
227
+
228
+ How it works, and what a vault does not do: [docs/API.md](docs/API.md#prf-keys-and-vaults).
229
+
213
230
  ### Keystore-based DID (WebAuthn + OrbitDB keystore)
214
231
 
215
232
  ```mermaid
@@ -360,7 +377,8 @@ sequenceDiagram
360
377
  ## Examples
361
378
 
362
379
  Three demo apps, one per option, in one pnpm workspace (`examples/README.md`
363
- has the install). Each proves its option in the browser: a real
380
+ has the install), and a probe that measures the authenticator itself. Each
381
+ demo proves its option in the browser: a real
364
382
  authenticator (Chromium's virtual one in CI), a badge that is the verdict of
365
383
  the same checks a peer makes, and a panel that runs those checks on two
366
384
  forgeries.
@@ -376,12 +394,36 @@ forgeries.
376
394
  - `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
377
395
  no keystore. Live:
378
396
  https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
397
+ - `examples/passkey-probe/` — not an option but an instrument, for the
398
+ authenticator in front of you: create a passkey, ask for PRF, derive a key
399
+ from it, write an encrypted record, read it back, recover it on what stands
400
+ in for a second device. One static file in two languages, no build step, so
401
+ what is served is what a reader gets by opening it. Live:
402
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/passkey-probe/
379
403
 
380
404
  The identity for each option is built by one module under
381
405
  `examples/shared/lib/options/`, which is also the shortest way to read the
382
406
  API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
383
407
  prompt counts.
384
408
 
409
+ ### In other repositories
410
+
411
+ Three consumers that show the package doing a whole job rather than one call:
412
+
413
+ - [orbitdb-storage-bridge `examples/svelte/recovery`](https://github.com/NiKrause/orbitdb-storage-bridge/tree/main/examples/svelte/recovery)
414
+ — a page that takes a security key and nothing else, and ends up with an
415
+ identity OrbitDB accepts and a list it can write to. Its browser test stands
416
+ a virtual authenticator in for the YubiKey the two phones used. It ran in
417
+ funkpost until 2026-09-23, and moved to the packages it demonstrates. Live:
418
+ https://nikrause.github.io/orbitdb-storage-bridge/recovery/
419
+ - [orbitdb-storage-bridge, _Getting a database back on a device that has
420
+ nothing_](https://github.com/NiKrause/orbitdb-storage-bridge/blob/main/docs/RECOVERY-ON-A-SECOND-DEVICE.md)
421
+ — the other half of a recovery: this package returns the identity, that one
422
+ returns the database, and the PRF output is what both are derived from.
423
+ - [simple-todo `escrow01`](https://github.com/Le-Space/simple-todo/tree/main/apps/escrow01)
424
+ — one passkey as the OrbitDB identity _and_ the admin key of a Calibur smart
425
+ account, using the P-256 primitives of the standalone export.
426
+
385
427
  ## Documentation
386
428
 
387
429
  - `docs/API.md`
@@ -424,11 +466,14 @@ Since **0.6.0** an identity can be restored on a device that has stored nothing:
424
466
  - The PRF input is fixed per relying party, so a second device asks the authenticator the same question and receives the same secret; the signing key is derived from it with the DID mixed in.
425
467
  - `restoreIdentityFromAuthenticator()` does both touches and returns the DID, the public key and the signing key — and, since 0.7.0, the credential the provider takes as it is: `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential: restored.credential })`.
426
468
 
427
- Metadata persistence is therefore a **convenience, not a requirement**. The demos still try `largeBlob` first and fall back to browser storage, which saves a touch of the key; neither is needed to reconstruct the identity.
469
+ **What that costs the person holding the key.** Two presentations of it: with a security key, two taps; with a platform passkey, two fingerprints. A third follows when OrbitDB builds the identity and asks for its signature — unless the application puts the returned `signingKey` into its keystore first, in which case the restore's own PRF read is not repeated. Applications can name each step through the `onTouch` callback rather than leaving a person wondering why they are being asked again.
470
+
471
+ Metadata persistence is therefore a **convenience, not a requirement**. A stored copy spares those touches, which is why the demos still write one; `largeBlob` is the less dependable half of that, since Android Chrome writes it but does not return it ([#48](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/issues/48)).
428
472
 
429
473
  Practical implication:
430
474
 
431
475
  - A passkey created in one browser profile reconstructs the same OrbitDB identity in a fresh profile — or on another phone — with nothing carried over. Measured on one YubiKey across a Galaxy Fold 5 and a Galaxy A57 (2026-09-19): same PRF value, same recovered DID, same derived signing key.
476
+ - The same thing, as an application rather than a measurement: on 2026-09-21 a Fold 5 made a list and was reset, and an A57 holding the same YubiKey had the same identity, brought the list back with the key alone and wrote to it ([funkpost#93](https://github.com/NiKrause/funkpost/issues/93)). The page it ran is [`examples/svelte/recovery`](https://github.com/NiKrause/orbitdb-storage-bridge/tree/main/examples/svelte/recovery), which has since moved to orbitdb-storage-bridge, with a browser test that stands a virtual authenticator in for the key.
432
477
  - An authenticator without PRF gets a **refusal**, not a substitute. An identity derived from something else is a different identity that looks like success.
433
478
  - Credentials registered before 0.6.0 keep the random PRF input stored with them, so their identities do not move.
434
479
 
package/docs/API.md CHANGED
@@ -106,6 +106,9 @@ property:
106
106
  - `WebAuthnAuthenticationError`
107
107
  - `WebAuthnVerificationError`
108
108
  - `KeystoreEncryptionError`
109
+ - `PrfUnavailableError` (`PRF_UNAVAILABLE`) — the authenticator answered
110
+ without a PRF output
111
+ - `VaultError` — a vault refused; see [Vaults](#vaults)
109
112
  - `VarsigVerificationError`
110
113
 
111
114
  ```js
@@ -165,6 +168,9 @@ Common options:
165
168
  - `keystoreEncryptionMethod`
166
169
  - `keyType`
167
170
  - `usePersistentKey`
171
+ - `prfOutput` — a PRF output already read with `readPrfOutput`, so the signing
172
+ key costs no second touch; see
173
+ [One touch for the data and the identity](#one-touch-for-the-data-and-the-identity)
168
174
 
169
175
  ### WebAuthn Configuration
170
176
 
@@ -187,6 +193,189 @@ Returns the current WebAuthn config.
187
193
 
188
194
  Restores default WebAuthn config.
189
195
 
196
+ ### Identity Restore
197
+
198
+ #### `restoreIdentityFromAuthenticator(options?)`
199
+
200
+ Reconstructs an identity on a device that has stored nothing — a second device,
201
+ or the same one after its profile was cleared. Two touches of the same passkey:
202
+ the first is asked with the relying party's fixed PRF input, the second signs
203
+ another challenge, and the two signatures together give back the credential's
204
+ public key, which a single assertion does not carry. The DID follows from that
205
+ key, and the signing key from the PRF output with the DID mixed in.
206
+
207
+ ```js
208
+ import { restoreIdentityFromAuthenticator } from '@le-space/orbitdb-identity-provider-webauthn-did';
209
+
210
+ const restored = await restoreIdentityFromAuthenticator({
211
+ onTouch: ({ touch, of }) => setStatus(`Touch ${touch} of ${of}`),
212
+ });
213
+
214
+ const identity = await identities.createIdentity({
215
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
216
+ webauthnCredential: restored.credential,
217
+ }),
218
+ });
219
+ ```
220
+
221
+ Options, all optional:
222
+
223
+ - `rpId` — relying party id; the current host by default.
224
+ - `timeout` — per touch, in ms; `120000`. Generous on purpose: an NFC key has to
225
+ be found, held and read.
226
+ - `signingKeyType` — `'secp256k1'` by default, as the keystore wants it. Pass
227
+ the same type the identity was created with.
228
+ - `onTouch({ touch, of })` — called before each ceremony, so an application can
229
+ say which touch this is rather than leaving a person wondering why they are
230
+ asked twice.
231
+
232
+ Returns `{ did, publicKey: { x, y }, credentialId, rawCredentialId, signingKey,
233
+ prfInput, credential }`. `credential` is what
234
+ `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential })` takes;
235
+ `credentialId` is base64url text and `rawCredentialId` the bytes (before 0.7.0,
236
+ `credentialId` was the bytes).
237
+
238
+ Putting `signingKey` into the keystore under the DID before OrbitDB builds the
239
+ identity spares one further touch: the provider would otherwise read the PRF
240
+ output a second time to derive the very same key.
241
+
242
+ Throws rather than substituting anything:
243
+
244
+ - `WebAuthnIdentityError` (`WEBAUTHN_NOT_SUPPORTED`) where WebAuthn is absent,
245
+ and when no credential is offered for this relying party.
246
+ - `PrfUnavailableError` when the authenticator cannot evaluate PRF. Nothing
247
+ stands in for it: an identity derived from something else is a different
248
+ identity that looks like success.
249
+
250
+ Since 0.6.0. Measured on hardware across two phones sharing one YubiKey, and
251
+ used in [the recovery example](https://github.com/NiKrause/orbitdb-storage-bridge/tree/main/examples/svelte/recovery);
252
+ the database half of the same problem is in
253
+ [orbitdb-storage-bridge](https://github.com/NiKrause/orbitdb-storage-bridge/blob/main/docs/RECOVERY-ON-A-SECOND-DEVICE.md).
254
+
255
+ ### PRF Keys and Vaults
256
+
257
+ Since 0.9.0. Applications that keep data under keys from the passkey used to
258
+ write their own HKDF over the PRF output, read it once per key, and had no way
259
+ for a second security key to open what the first one sealed. These functions
260
+ replace that: one read, any number of keys derived from it, and a vault any
261
+ registered authenticator opens.
262
+
263
+ #### `readPrfOutput(credential, options?)`
264
+
265
+ One assertion — one touch — returning the credential's PRF output, 32 bytes.
266
+ It always asks the same question: `options.prfInput`, else the input the
267
+ credential was registered with, else `prfInputForRelyingParty(rpId)`, which
268
+ every device computes alike. It never draws a random input, which would give
269
+ bytes that never come back.
270
+
271
+ - `rpId` — the current host by default.
272
+ - `prfInput` — overrides the credential's. Leave it out when the output also
273
+ goes to the identity provider, which asks with the credential's.
274
+
275
+ Throws `PrfUnavailableError` when the authenticator answers without a PRF
276
+ output. A refused prompt stays the browser's `NotAllowedError`, so "the user
277
+ said no" and "this authenticator has no PRF" can be told apart.
278
+
279
+ #### `deriveSubkey(prfOutput, info, { length = 32 })`
280
+
281
+ HKDF-SHA-256 with an empty salt, 1 to 8160 bytes. `info` is part of the data
282
+ format: the same output and `info` always give the same bytes, and a different
283
+ `info` gives unrelated ones. Name it after the application and the purpose, and
284
+ version it — `invoice/db-key/v1`; bumping the version rotates the key and makes
285
+ everything sealed under the old one unreadable.
286
+
287
+ The derivations Le-Space/belege, Le-Space/invoice and simple-todo's escrow01
288
+ wrote for themselves give the same bytes through it, and so does this
289
+ package's own signing key; `tests/prf-keys.test.js` holds vectors from each.
290
+
291
+ #### `deriveAesKey(prfOutput, info)`
292
+
293
+ The same derivation as a non-extractable AES-GCM-256 `CryptoKey`: it encrypts
294
+ and decrypts, and no script can read its bytes.
295
+
296
+ #### One touch for the data and the identity
297
+
298
+ Hand the output to the identity provider as `prfOutput`, and it derives the
299
+ OrbitDB signing key from it instead of asking the passkey again:
300
+
301
+ ```js
302
+ const prfOutput = await readPrfOutput(credential);
303
+
304
+ const { payload } = await openVault(vault, {
305
+ slotKey: await deriveAesKey(prfOutput, 'invoice/vault-slot/v1'),
306
+ rawCredentialId: credential.rawCredentialId,
307
+ });
308
+
309
+ const identity = await identities.createIdentity({
310
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
311
+ webauthnCredential: credential,
312
+ prfOutput,
313
+ }),
314
+ });
315
+ ```
316
+
317
+ It is the same key the provider would have derived by asking itself, so a
318
+ device where nobody hands it over arrives at the same identity document. The
319
+ provider instance drops its reference once the key is in the keystore and
320
+ never alters the bytes; the options object passed to
321
+ `OrbitDBWebAuthnIdentityProviderFunction` still holds them for as long as the
322
+ application keeps it. `encryptKeystore` still asks on its own.
323
+
324
+ #### Vaults
325
+
326
+ A key derived from a passkey exists only where that passkey is. A second
327
+ security key has its own PRF secret, so it derives different keys and cannot
328
+ open what the first one sealed. A vault holds what must be the same for every
329
+ key — a database key, database names, a peer seed — sealed under a random
330
+ vault key, and gives each registered authenticator a slot: the vault key,
331
+ sealed under that authenticator's
332
+ `deriveAesKey(prfOutput, '<app>/vault-slot/v1')`. Any slot opens the vault.
333
+
334
+ - `createVault(payload, { slotKey, rawCredentialId })` → `{ vault, vaultKey }`.
335
+ The creating authenticator's slot comes with it: a vault without one could
336
+ never be opened.
337
+ - `openVault(vault, { slotKey, rawCredentialId })` → `{ payload, vaultKey }`.
338
+ - `addSlot(vault, vaultKey, { slotKey, rawCredentialId })` → a new record with
339
+ the new authenticator's slot. It needs the vault open and the new key
340
+ touched, so in practice both keys are at one computer.
341
+ - `removeSlot(vault, rawCredentialId)` → a new record without that slot. It
342
+ needs no key: whoever may write the record may shorten it, so guard the
343
+ record where it is stored.
344
+ - `replacePayload(vault, vaultKey, payload)` → a new record with a new
345
+ payload, the same vault key and the same slots.
346
+ - `slotIdFor(rawCredentialId)` → the id a slot is filed under, the SHA-256 of
347
+ the raw credential id; the id itself is never written into the record.
348
+
349
+ Each function returns a new record and leaves the one passed in unchanged. The
350
+ record is plain JSON with nothing secret in the clear (format version 1, in
351
+ `src/keystore/vault.js`), so it can be stored anywhere, replicated, and backed
352
+ up in public. Every ciphertext in it is bound to the vault's id, and every slot
353
+ also to its authenticator, so neither can be moved to another vault or
354
+ relabelled to another authenticator.
355
+
356
+ Refusals are `VaultError`, with `code`:
357
+
358
+ - `VAULT_MALFORMED` — not a version-1 vault
359
+ - `VAULT_NO_SLOT` — this authenticator has no slot
360
+ - `VAULT_LOCKED` — the key does not open it, or the record was altered
361
+ - `VAULT_SLOT_EXISTS` — the authenticator already has a slot
362
+ - `VAULT_LAST_SLOT` — the last slot cannot go
363
+
364
+ What a vault does not do:
365
+
366
+ - **Removing a slot revokes nothing already opened.** An authenticator that
367
+ opened the vault has seen the vault key, and older copies of the record still
368
+ carry its slot. Against a stolen key, make a new vault with a new key and move
369
+ what the old one protected.
370
+ - **It cannot tell the current record from an earlier copy of itself.** Where a
371
+ rollback matters, the place the record is stored has to say which is current.
372
+ - **It does not make two keys one person.** Each authenticator keeps its own DID
373
+ and signing key; who may write to a database is its access controller's
374
+ decision.
375
+
376
+ `tests/vault-two-authenticators.test.js` runs this in Chromium with two virtual
377
+ authenticators, each with its own PRF secret as two YubiKeys have.
378
+
190
379
  ### largeBlob Metadata Helpers
191
380
 
192
381
  These helpers encode and recover identity metadata used by the demo recovery
@@ -200,8 +389,13 @@ flows.
200
389
  - `writeLargeBlobMetadata(credentialId, payload, options)`
201
390
 
202
391
  Discoverable passkeys can identify a credential later, but WebAuthn assertions
203
- do not reliably return the public key after registration. Persisted metadata is
204
- therefore still required to reconstruct an OrbitDB identity.
392
+ do not reliably return the public key after registration. Persisted metadata was
393
+ therefore the only way to reconstruct an identity until 0.6.0; since then
394
+ `restoreIdentityFromAuthenticator()` does it from two signatures instead, and a
395
+ stored copy only spares the touches. `largeBlob` is the less dependable place to
396
+ keep one: Android Chrome writes the blob and does not return it on read, while
397
+ desktop Chrome reads it
398
+ ([#48](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/issues/48)).
205
399
 
206
400
  ### Credential Storage Helpers
207
401
 
@@ -216,7 +410,11 @@ Safer storage helpers:
216
410
  - `storeWebAuthnCredentialSafe(credential, key?)`
217
411
  - `loadWebAuthnCredentialSafe(key?)`
218
412
  - `clearWebAuthnCredentialSafe(key?)`
219
- - `extractPrfSeedFromCredential(credential)`
413
+ - `extractPrfSeedFromCredential(credential, options?)` →
414
+ `{ seed, source: 'prf' }`, or `{ seed: null, source: 'none' }` without PRF.
415
+ Without a `prfInput` — in `options` or on the credential — it asks a random
416
+ question, so the seed never comes back and nothing derived from it survives
417
+ the page; it says so on the console. Prefer `readPrfOutput`.
220
418
 
221
419
  ### Keystore Encryption Helpers
222
420
 
@@ -347,7 +545,15 @@ const ucanSigner = signer.toUcantoSigner({
347
545
  - `storeWebAuthnCredentialSafe(credential, key?)`
348
546
  - `loadWebAuthnCredentialSafe(key?)`
349
547
  - `clearWebAuthnCredentialSafe(key?)`
350
- - `extractPrfSeedFromCredential(credential)`
548
+ - `extractPrfSeedFromCredential(credential, options?)` — see
549
+ [Credential Storage Helpers](#credential-storage-helpers)
550
+
551
+ ### PRF Keys and Vaults (standalone)
552
+
553
+ The same functions as the root export, without OrbitDB: `readPrfOutput`,
554
+ `deriveSubkey`, `deriveAesKey`, `createVault`, `openVault`, `addSlot`,
555
+ `removeSlot`, `replacePayload`, `slotIdFor`, `PrfUnavailableError` and
556
+ `VaultError`. See [PRF Keys and Vaults](#prf-keys-and-vaults).
351
557
 
352
558
  ### P-256 Wallet Primitives
353
559
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "WebAuthn-based DID identity provider for OrbitDB for hardware-secured wallets and biometric Passkey authentication",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -118,6 +118,7 @@
118
118
  "libp2p": "^3.3.4",
119
119
  "mocha": "^10.0.0",
120
120
  "prettier": "^3.6.2",
121
+ "viem": "^2.56.5",
121
122
  "vite-plugin-node-polyfills": "^0.24.0"
122
123
  },
123
124
  "engines": {
@@ -145,7 +146,7 @@
145
146
  "iso-web": "^3.1.2",
146
147
  "iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.3.0",
147
148
  "conf>ajv": "^8.18.0",
148
- "ajv>fast-uri": "^3.1.6"
149
+ "ajv>fast-uri": "^3.1.8"
149
150
  },
150
151
  "onlyBuiltDependencies": [
151
152
  "@ipshipyard/node-datachannel",
package/src/errors.js CHANGED
@@ -6,6 +6,11 @@ export const ERROR_CODES = Object.freeze({
6
6
  KEYSTORE_ENCRYPTION_FAILED: 'KEYSTORE_ENCRYPTION_FAILED',
7
7
  VARSIG_VERIFICATION_FAILED: 'VARSIG_VERIFICATION_FAILED',
8
8
  INVALID_INPUT: 'INVALID_INPUT',
9
+ VAULT_MALFORMED: 'VAULT_MALFORMED',
10
+ VAULT_NO_SLOT: 'VAULT_NO_SLOT',
11
+ VAULT_LOCKED: 'VAULT_LOCKED',
12
+ VAULT_SLOT_EXISTS: 'VAULT_SLOT_EXISTS',
13
+ VAULT_LAST_SLOT: 'VAULT_LAST_SLOT',
9
14
  });
10
15
 
11
16
  export class WebAuthnIdentityError extends Error {
@@ -86,6 +91,22 @@ export class PrfUnavailableError extends KeystoreEncryptionError {
86
91
  }
87
92
  }
88
93
 
94
+ /**
95
+ * A vault refused: it is not a vault (`VAULT_MALFORMED`), this authenticator
96
+ * has no slot in it (`VAULT_NO_SLOT`), the key does not open it or it was
97
+ * altered (`VAULT_LOCKED`), the authenticator already has a slot
98
+ * (`VAULT_SLOT_EXISTS`), or the slot is the last one (`VAULT_LAST_SLOT`).
99
+ */
100
+ export class VaultError extends KeystoreEncryptionError {
101
+ constructor(message, options = {}) {
102
+ super(message, {
103
+ ...options,
104
+ code: options.code || ERROR_CODES.VAULT_MALFORMED,
105
+ });
106
+ this.name = 'VaultError';
107
+ }
108
+ }
109
+
89
110
  export class VarsigVerificationError extends WebAuthnIdentityError {
90
111
  constructor(message, options = {}) {
91
112
  super(message, {
package/src/index.js CHANGED
@@ -296,7 +296,7 @@ export {
296
296
  createSessionKeystore,
297
297
  isSessionKeystore,
298
298
  } from './keystore/session-keystore.js';
299
- export { PrfUnavailableError } from './errors.js';
299
+ export { PrfUnavailableError, VaultError } from './errors.js';
300
300
  // Restoring on a device that has stored nothing (#61): the fixed PRF input,
301
301
  // the public key recovered from two assertions, and the two together.
302
302
  export {
@@ -314,6 +314,21 @@ export {
314
314
  deriveSigningKeyBytes,
315
315
  getPrfOutput,
316
316
  } from './keystore/derived-signing-key.js';
317
+ // Read the PRF output once and derive everything from that one answer, and keep
318
+ // what must outlive one security key in a vault any registered one can open.
319
+ export {
320
+ readPrfOutput,
321
+ deriveSubkey,
322
+ deriveAesKey,
323
+ } from './keystore/prf-keys.js';
324
+ export {
325
+ createVault,
326
+ openVault,
327
+ addSlot,
328
+ removeSlot,
329
+ replacePayload,
330
+ slotIdFor,
331
+ } from './keystore/vault.js';
317
332
 
318
333
  export default {
319
334
  WebAuthnDIDProvider,
@@ -36,6 +36,7 @@ import {
36
36
  import { CRYPTO_ALGORITHMS, KEY_TYPES } from '../constants.js';
37
37
  import { buildCredentialRequestOptions } from '../webauthn/config.js';
38
38
  import { prfInputForRelyingParty } from '../webauthn/prf-input.js';
39
+ import { hkdfSha256 } from './prf-keys.js';
39
40
 
40
41
  const log = logger('orbitdb-identity-provider-webauthn-did:derived-key');
41
42
 
@@ -45,30 +46,15 @@ const DERIVATION_INFO = 'orbitdb-identity-provider-webauthn-did:signing-key:v1';
45
46
  const SECP256K1_KEY_BYTES = 32;
46
47
 
47
48
  /**
48
- * HKDF-SHA256 a 32-byte candidate from the PRF seed.
49
+ * HKDF-SHA256 a 32-byte candidate from the PRF seed — the same HKDF that
50
+ * `deriveSubkey` exposes, without its input checks, so this derivation accepts
51
+ * exactly what it accepted before.
49
52
  * @param {Uint8Array} seed - PRF output.
50
53
  * @param {string} info - Domain separation string.
51
54
  * @returns {Promise<Uint8Array>} 32 bytes.
52
55
  */
53
- async function hkdf(seed, info) {
54
- const baseKey = await crypto.subtle.importKey(
55
- 'raw',
56
- seed,
57
- CRYPTO_ALGORITHMS.HKDF,
58
- false,
59
- ['deriveBits']
60
- );
61
- const bits = await crypto.subtle.deriveBits(
62
- {
63
- name: CRYPTO_ALGORITHMS.HKDF,
64
- hash: CRYPTO_ALGORITHMS.SHA_256,
65
- salt: new Uint8Array(0),
66
- info: new TextEncoder().encode(info),
67
- },
68
- baseKey,
69
- SECP256K1_KEY_BYTES * 8
70
- );
71
- return new Uint8Array(bits);
56
+ function hkdf(seed, info) {
57
+ return hkdfSha256(seed, info, SECP256K1_KEY_BYTES);
72
58
  }
73
59
 
74
60
  /**
@@ -186,6 +172,10 @@ export async function getPrfOutput(credential, { rpId } = {}) {
186
172
  * @param {Object} params.credential - Stored WebAuthn credential info.
187
173
  * @param {string} [params.rpId] - Relying party id.
188
174
  * @param {string} [params.keyType='secp256k1'] - Type of key to derive.
175
+ * @param {Uint8Array} [params.seed] - A PRF output already read, e.g. by
176
+ * `readPrfOutput` while unlocking something else. When given, the passkey is
177
+ * not asked again: one touch serves the signing key and whatever else the
178
+ * application derives from the same answer.
189
179
  * @returns {Promise<'derived'|'existing'|'unavailable'>} What happened.
190
180
  */
191
181
  export async function ensureDerivedSigningKey({
@@ -194,6 +184,7 @@ export async function ensureDerivedSigningKey({
194
184
  credential,
195
185
  rpId,
196
186
  keyType = KEY_TYPES.SECP256K1,
187
+ seed: prfSeed,
197
188
  }) {
198
189
  if (!keystore || !did || !credential) return 'unavailable';
199
190
 
@@ -218,7 +209,10 @@ export async function ensureDerivedSigningKey({
218
209
  return 'unavailable';
219
210
  }
220
211
 
221
- const seed = await getPrfOutput(credential, { rpId });
212
+ const seed =
213
+ prfSeed instanceof Uint8Array
214
+ ? prfSeed
215
+ : await getPrfOutput(credential, { rpId });
222
216
  if (!seed) {
223
217
  log(
224
218
  'no PRF output; falling back to a keystore-generated key, so this identity is stable per device but not reproducible across devices'