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

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,62 @@
1
1
  # Changes
2
2
 
3
+ ## 0.9.1
4
+
5
+ `createCredential` honours the choice of authenticator that its options, its types and
6
+ `docs/API.md` have offered all along.
7
+
8
+ ### Fixed
9
+
10
+ - `authenticatorType` (`'platform'`, `'cross-platform'` or `'any'`) and its WebAuthn name
11
+ `authenticatorAttachment` reach the browser. Since 0.2.8 neither did: `buildAuthenticatorSelection`
12
+ dropped them, so the request named no attachment and the browser offered every kind of
13
+ authenticator, a security key as much as the device's own — though the code asked for
14
+ `'platform'`. That stays the default, so nothing changes for a caller that passes neither.
15
+ - An unknown value, or the two disagreeing, is a `TypeError` before anyone is asked, instead of
16
+ being ignored.
17
+
18
+ Tested in Chromium with a virtual authenticator on the USB transport: offered by default, asked for
19
+ with `'cross-platform'`, never asked with `'platform'`. The vault test from 0.9.0 now also runs with
20
+ two such security keys.
21
+
22
+ ## 0.9.0
23
+
24
+ What applications kept writing for themselves, once and here: one read of the PRF output for every
25
+ key an application derives from it, and a vault, so that a second security key can open what the
26
+ first one sealed. Nothing changes for code that does not call it, and every derivation that exists
27
+ today gives the same bytes.
28
+
29
+ ### Added
30
+
31
+ - **`readPrfOutput(credential, options?)`**: one assertion, the PRF output. It always asks with a
32
+ fixed input — the credential's own, else the relying party's from `prfInputForRelyingParty` —
33
+ and never with a random one, whose answer would never come back. `PrfUnavailableError` when the
34
+ authenticator has no PRF; a refused prompt stays the browser's `NotAllowedError`.
35
+ - **`deriveSubkey(prfOutput, info, { length })` and `deriveAesKey(prfOutput, info)`**: HKDF-SHA-256
36
+ with an empty salt, as bytes or as a non-extractable AES-GCM key. They give the same bytes as the
37
+ HKDF that Le-Space/belege, Le-Space/invoice and simple-todo's escrow01 each wrote themselves, and
38
+ as this package's signing-key derivation, which now runs on them. Vectors from all four are
39
+ tests.
40
+ - **Vaults**: `createVault`, `openVault`, `addSlot`, `removeSlot`, `replacePayload`, `slotIdFor`
41
+ and `VaultError`. A random vault key seals the payload, and each registered authenticator gets a
42
+ slot holding that key, sealed under a key from its own PRF output, so any of them opens the
43
+ vault. The record is plain JSON with nothing secret in the clear; every ciphertext is bound to
44
+ the vault id and every slot to its authenticator. Removing a slot revokes nothing already
45
+ opened. Tested in Chromium with two virtual authenticators, each with its own PRF secret, as two
46
+ YubiKeys have.
47
+ - **`prfOutput`**, an identity-provider option: the OrbitDB signing key is derived from a PRF
48
+ output the application already read, so unlocking its data and creating the identity cost one
49
+ touch instead of two. It is the same key the provider derives when it asks itself.
50
+ - All of it in `./standalone` as well, and declared in `types/`.
51
+
52
+ ### Changed
53
+
54
+ - `extractPrfSeedFromCredential` says on the console when it has no `prfInput`: it then asks a
55
+ random question, and the seed it returns never comes back. What it returns is unchanged.
56
+ - `types/index.d.ts` declared `extractPrfSeedFromCredential` as synchronous and returning bytes. It
57
+ is async and returns `{ seed, source }`, as `types/standalone.d.ts` already said.
58
+ `PrfUnavailableError` is now declared in the root types as well.
59
+
3
60
  ## 0.8.0
4
61
 
5
62
  Two functions for wallets, and nothing that changes for anyone else. They were written against
package/README.md CHANGED
@@ -19,14 +19,15 @@ This package provides:
19
19
  - Authentication/assertion requests omit `allowCredentials` by default, so the browser/authenticator can resolve the credential discoverably.
20
20
  - You can switch this centrally with `configureWebAuthn({ discoverableCredentials: true|false })`.
21
21
  - Registration is still the point where this package extracts the credential public key from attestation.
22
+ - Registration offers every kind of authenticator — the device's own, a security key such as a YubiKey, a phone — unless `createCredential({ authenticatorType: 'platform' | 'cross-platform' })` narrows it (honoured since 0.9.1).
22
23
  - Later `navigator.credentials.get()` assertions do not reliably return the public key again, so identity reconstruction still needs metadata from somewhere else.
23
24
 
24
- In this repo today, metadata recovery works in two layers:
25
+ Since **0.6.0** the passkey alone is enough, and stored metadata is a convenience rather than a requirement:
25
26
 
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`.
27
+ - **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).
28
+ - **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
29
 
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.
30
+ `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
31
 
31
32
  **Which option, and what each one actually gives you:**
32
33
 
@@ -105,11 +106,10 @@ For the DID-based flow:
105
106
  - later discoverable `navigator.credentials.get()` proves possession of the credential, but usually returns only `rawId`, `authenticatorData`, `clientDataJSON`, `signature`, and maybe `userHandle`
106
107
  - that assertion is not enough on its own to reconstruct the DID
107
108
 
108
- To address this, the demos now attempt to:
109
+ Two ways past that, and the first keeps nothing:
109
110
 
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
111
+ 1. `restoreIdentityFromAuthenticator()` — two discoverable assertions, from which `recoverPublicKey()` returns the credential's public key and with it the DID (0.6.0)
112
+ 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
113
 
114
114
  ### Hardware-Secured Varsig Quick Start
115
115
 
@@ -181,7 +181,7 @@ The verifier side and app protocol should define which domain label is required.
181
181
 
182
182
  ### Passkeys for smart accounts (P-256)
183
183
 
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.
184
+ 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
185
 
186
186
  ```javascript
187
187
  import {
@@ -210,6 +210,24 @@ const { authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s } =
210
210
  | 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
211
  | Hardware Ed25519 (varsig) | `null`: there is no P-256 key |
212
212
 
213
+ ### One touch, and a second security key
214
+
215
+ 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.
216
+
217
+ ```javascript
218
+ const prfOutput = await readPrfOutput(credential);
219
+ const slot = {
220
+ slotKey: await deriveAesKey(prfOutput, 'myapp/vault-slot/v1'),
221
+ rawCredentialId: credential.rawCredentialId,
222
+ };
223
+ const { vault, vaultKey } = await createVault(payload, slot); // later: openVault(vault, slot)
224
+
225
+ // With the vault open, touch the second key and give it a slot of its own.
226
+ const withSecondKey = await addSlot(vault, vaultKey, secondKeysSlot);
227
+ ```
228
+
229
+ How it works, and what a vault does not do: [docs/API.md](docs/API.md#prf-keys-and-vaults).
230
+
213
231
  ### Keystore-based DID (WebAuthn + OrbitDB keystore)
214
232
 
215
233
  ```mermaid
@@ -360,7 +378,8 @@ sequenceDiagram
360
378
  ## Examples
361
379
 
362
380
  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
381
+ has the install), and a probe that measures the authenticator itself. Each
382
+ demo proves its option in the browser: a real
364
383
  authenticator (Chromium's virtual one in CI), a badge that is the verdict of
365
384
  the same checks a peer makes, and a panel that runs those checks on two
366
385
  forgeries.
@@ -376,12 +395,36 @@ forgeries.
376
395
  - `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
377
396
  no keystore. Live:
378
397
  https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
398
+ - `examples/passkey-probe/` — not an option but an instrument, for the
399
+ authenticator in front of you: create a passkey, ask for PRF, derive a key
400
+ from it, write an encrypted record, read it back, recover it on what stands
401
+ in for a second device. One static file in two languages, no build step, so
402
+ what is served is what a reader gets by opening it. Live:
403
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/passkey-probe/
379
404
 
380
405
  The identity for each option is built by one module under
381
406
  `examples/shared/lib/options/`, which is also the shortest way to read the
382
407
  API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
383
408
  prompt counts.
384
409
 
410
+ ### In other repositories
411
+
412
+ Three consumers that show the package doing a whole job rather than one call:
413
+
414
+ - [orbitdb-storage-bridge `examples/svelte/recovery`](https://github.com/NiKrause/orbitdb-storage-bridge/tree/main/examples/svelte/recovery)
415
+ — a page that takes a security key and nothing else, and ends up with an
416
+ identity OrbitDB accepts and a list it can write to. Its browser test stands
417
+ a virtual authenticator in for the YubiKey the two phones used. It ran in
418
+ funkpost until 2026-09-23, and moved to the packages it demonstrates. Live:
419
+ https://nikrause.github.io/orbitdb-storage-bridge/recovery/
420
+ - [orbitdb-storage-bridge, _Getting a database back on a device that has
421
+ nothing_](https://github.com/NiKrause/orbitdb-storage-bridge/blob/main/docs/RECOVERY-ON-A-SECOND-DEVICE.md)
422
+ — the other half of a recovery: this package returns the identity, that one
423
+ returns the database, and the PRF output is what both are derived from.
424
+ - [simple-todo `escrow01`](https://github.com/Le-Space/simple-todo/tree/main/apps/escrow01)
425
+ — one passkey as the OrbitDB identity _and_ the admin key of a Calibur smart
426
+ account, using the P-256 primitives of the standalone export.
427
+
385
428
  ## Documentation
386
429
 
387
430
  - `docs/API.md`
@@ -424,11 +467,14 @@ Since **0.6.0** an identity can be restored on a device that has stored nothing:
424
467
  - 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
468
  - `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
469
 
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.
470
+ **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.
471
+
472
+ 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
473
 
429
474
  Practical implication:
430
475
 
431
476
  - 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.
477
+ - 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
478
  - 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
479
  - Credentials registered before 0.6.0 keep the random PRF input stored with them, so their identities do not move.
434
480
 
package/docs/API.md CHANGED
@@ -61,7 +61,17 @@ Options:
61
61
  - `encryptKeystore?: boolean`
62
62
  - `keystoreEncryptionMethod?: 'prf' | 'largeBlob' | 'hmac-secret'`
63
63
  - `discoverableCredentials?: boolean`
64
- - `authenticatorType?: 'platform' | 'cross-platform' | 'any'`
64
+ - `authenticatorType?: 'platform' | 'cross-platform' | 'any'` — which
65
+ authenticators the browser offers: the device's own (Touch ID, Windows
66
+ Hello, a phone's screen lock), a security key or another phone, or every
67
+ kind. `'any'` is the default and names none in the request.
68
+ - `authenticatorAttachment?: 'platform' | 'cross-platform'` — the WebAuthn name
69
+ for the same choice. Giving both with different values, or a value neither
70
+ knows, throws a `TypeError` before anyone is asked.
71
+
72
+ Before 0.9.1 both were listed here and neither reached the browser: the request
73
+ named no attachment, so every kind of authenticator was offered — a YubiKey as
74
+ much as the device's own. That is still the default.
65
75
 
66
76
  ```js
67
77
  const credential = await WebAuthnDIDProvider.createCredential({
@@ -106,6 +116,9 @@ property:
106
116
  - `WebAuthnAuthenticationError`
107
117
  - `WebAuthnVerificationError`
108
118
  - `KeystoreEncryptionError`
119
+ - `PrfUnavailableError` (`PRF_UNAVAILABLE`) — the authenticator answered
120
+ without a PRF output
121
+ - `VaultError` — a vault refused; see [Vaults](#vaults)
109
122
  - `VarsigVerificationError`
110
123
 
111
124
  ```js
@@ -165,6 +178,9 @@ Common options:
165
178
  - `keystoreEncryptionMethod`
166
179
  - `keyType`
167
180
  - `usePersistentKey`
181
+ - `prfOutput` — a PRF output already read with `readPrfOutput`, so the signing
182
+ key costs no second touch; see
183
+ [One touch for the data and the identity](#one-touch-for-the-data-and-the-identity)
168
184
 
169
185
  ### WebAuthn Configuration
170
186
 
@@ -187,6 +203,189 @@ Returns the current WebAuthn config.
187
203
 
188
204
  Restores default WebAuthn config.
189
205
 
206
+ ### Identity Restore
207
+
208
+ #### `restoreIdentityFromAuthenticator(options?)`
209
+
210
+ Reconstructs an identity on a device that has stored nothing — a second device,
211
+ or the same one after its profile was cleared. Two touches of the same passkey:
212
+ the first is asked with the relying party's fixed PRF input, the second signs
213
+ another challenge, and the two signatures together give back the credential's
214
+ public key, which a single assertion does not carry. The DID follows from that
215
+ key, and the signing key from the PRF output with the DID mixed in.
216
+
217
+ ```js
218
+ import { restoreIdentityFromAuthenticator } from '@le-space/orbitdb-identity-provider-webauthn-did';
219
+
220
+ const restored = await restoreIdentityFromAuthenticator({
221
+ onTouch: ({ touch, of }) => setStatus(`Touch ${touch} of ${of}`),
222
+ });
223
+
224
+ const identity = await identities.createIdentity({
225
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
226
+ webauthnCredential: restored.credential,
227
+ }),
228
+ });
229
+ ```
230
+
231
+ Options, all optional:
232
+
233
+ - `rpId` — relying party id; the current host by default.
234
+ - `timeout` — per touch, in ms; `120000`. Generous on purpose: an NFC key has to
235
+ be found, held and read.
236
+ - `signingKeyType` — `'secp256k1'` by default, as the keystore wants it. Pass
237
+ the same type the identity was created with.
238
+ - `onTouch({ touch, of })` — called before each ceremony, so an application can
239
+ say which touch this is rather than leaving a person wondering why they are
240
+ asked twice.
241
+
242
+ Returns `{ did, publicKey: { x, y }, credentialId, rawCredentialId, signingKey,
243
+ prfInput, credential }`. `credential` is what
244
+ `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential })` takes;
245
+ `credentialId` is base64url text and `rawCredentialId` the bytes (before 0.7.0,
246
+ `credentialId` was the bytes).
247
+
248
+ Putting `signingKey` into the keystore under the DID before OrbitDB builds the
249
+ identity spares one further touch: the provider would otherwise read the PRF
250
+ output a second time to derive the very same key.
251
+
252
+ Throws rather than substituting anything:
253
+
254
+ - `WebAuthnIdentityError` (`WEBAUTHN_NOT_SUPPORTED`) where WebAuthn is absent,
255
+ and when no credential is offered for this relying party.
256
+ - `PrfUnavailableError` when the authenticator cannot evaluate PRF. Nothing
257
+ stands in for it: an identity derived from something else is a different
258
+ identity that looks like success.
259
+
260
+ Since 0.6.0. Measured on hardware across two phones sharing one YubiKey, and
261
+ used in [the recovery example](https://github.com/NiKrause/orbitdb-storage-bridge/tree/main/examples/svelte/recovery);
262
+ the database half of the same problem is in
263
+ [orbitdb-storage-bridge](https://github.com/NiKrause/orbitdb-storage-bridge/blob/main/docs/RECOVERY-ON-A-SECOND-DEVICE.md).
264
+
265
+ ### PRF Keys and Vaults
266
+
267
+ Since 0.9.0. Applications that keep data under keys from the passkey used to
268
+ write their own HKDF over the PRF output, read it once per key, and had no way
269
+ for a second security key to open what the first one sealed. These functions
270
+ replace that: one read, any number of keys derived from it, and a vault any
271
+ registered authenticator opens.
272
+
273
+ #### `readPrfOutput(credential, options?)`
274
+
275
+ One assertion — one touch — returning the credential's PRF output, 32 bytes.
276
+ It always asks the same question: `options.prfInput`, else the input the
277
+ credential was registered with, else `prfInputForRelyingParty(rpId)`, which
278
+ every device computes alike. It never draws a random input, which would give
279
+ bytes that never come back.
280
+
281
+ - `rpId` — the current host by default.
282
+ - `prfInput` — overrides the credential's. Leave it out when the output also
283
+ goes to the identity provider, which asks with the credential's.
284
+
285
+ Throws `PrfUnavailableError` when the authenticator answers without a PRF
286
+ output. A refused prompt stays the browser's `NotAllowedError`, so "the user
287
+ said no" and "this authenticator has no PRF" can be told apart.
288
+
289
+ #### `deriveSubkey(prfOutput, info, { length = 32 })`
290
+
291
+ HKDF-SHA-256 with an empty salt, 1 to 8160 bytes. `info` is part of the data
292
+ format: the same output and `info` always give the same bytes, and a different
293
+ `info` gives unrelated ones. Name it after the application and the purpose, and
294
+ version it — `invoice/db-key/v1`; bumping the version rotates the key and makes
295
+ everything sealed under the old one unreadable.
296
+
297
+ The derivations Le-Space/belege, Le-Space/invoice and simple-todo's escrow01
298
+ wrote for themselves give the same bytes through it, and so does this
299
+ package's own signing key; `tests/prf-keys.test.js` holds vectors from each.
300
+
301
+ #### `deriveAesKey(prfOutput, info)`
302
+
303
+ The same derivation as a non-extractable AES-GCM-256 `CryptoKey`: it encrypts
304
+ and decrypts, and no script can read its bytes.
305
+
306
+ #### One touch for the data and the identity
307
+
308
+ Hand the output to the identity provider as `prfOutput`, and it derives the
309
+ OrbitDB signing key from it instead of asking the passkey again:
310
+
311
+ ```js
312
+ const prfOutput = await readPrfOutput(credential);
313
+
314
+ const { payload } = await openVault(vault, {
315
+ slotKey: await deriveAesKey(prfOutput, 'invoice/vault-slot/v1'),
316
+ rawCredentialId: credential.rawCredentialId,
317
+ });
318
+
319
+ const identity = await identities.createIdentity({
320
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
321
+ webauthnCredential: credential,
322
+ prfOutput,
323
+ }),
324
+ });
325
+ ```
326
+
327
+ It is the same key the provider would have derived by asking itself, so a
328
+ device where nobody hands it over arrives at the same identity document. The
329
+ provider instance drops its reference once the key is in the keystore and
330
+ never alters the bytes; the options object passed to
331
+ `OrbitDBWebAuthnIdentityProviderFunction` still holds them for as long as the
332
+ application keeps it. `encryptKeystore` still asks on its own.
333
+
334
+ #### Vaults
335
+
336
+ A key derived from a passkey exists only where that passkey is. A second
337
+ security key has its own PRF secret, so it derives different keys and cannot
338
+ open what the first one sealed. A vault holds what must be the same for every
339
+ key — a database key, database names, a peer seed — sealed under a random
340
+ vault key, and gives each registered authenticator a slot: the vault key,
341
+ sealed under that authenticator's
342
+ `deriveAesKey(prfOutput, '<app>/vault-slot/v1')`. Any slot opens the vault.
343
+
344
+ - `createVault(payload, { slotKey, rawCredentialId })` → `{ vault, vaultKey }`.
345
+ The creating authenticator's slot comes with it: a vault without one could
346
+ never be opened.
347
+ - `openVault(vault, { slotKey, rawCredentialId })` → `{ payload, vaultKey }`.
348
+ - `addSlot(vault, vaultKey, { slotKey, rawCredentialId })` → a new record with
349
+ the new authenticator's slot. It needs the vault open and the new key
350
+ touched, so in practice both keys are at one computer.
351
+ - `removeSlot(vault, rawCredentialId)` → a new record without that slot. It
352
+ needs no key: whoever may write the record may shorten it, so guard the
353
+ record where it is stored.
354
+ - `replacePayload(vault, vaultKey, payload)` → a new record with a new
355
+ payload, the same vault key and the same slots.
356
+ - `slotIdFor(rawCredentialId)` → the id a slot is filed under, the SHA-256 of
357
+ the raw credential id; the id itself is never written into the record.
358
+
359
+ Each function returns a new record and leaves the one passed in unchanged. The
360
+ record is plain JSON with nothing secret in the clear (format version 1, in
361
+ `src/keystore/vault.js`), so it can be stored anywhere, replicated, and backed
362
+ up in public. Every ciphertext in it is bound to the vault's id, and every slot
363
+ also to its authenticator, so neither can be moved to another vault or
364
+ relabelled to another authenticator.
365
+
366
+ Refusals are `VaultError`, with `code`:
367
+
368
+ - `VAULT_MALFORMED` — not a version-1 vault
369
+ - `VAULT_NO_SLOT` — this authenticator has no slot
370
+ - `VAULT_LOCKED` — the key does not open it, or the record was altered
371
+ - `VAULT_SLOT_EXISTS` — the authenticator already has a slot
372
+ - `VAULT_LAST_SLOT` — the last slot cannot go
373
+
374
+ What a vault does not do:
375
+
376
+ - **Removing a slot revokes nothing already opened.** An authenticator that
377
+ opened the vault has seen the vault key, and older copies of the record still
378
+ carry its slot. Against a stolen key, make a new vault with a new key and move
379
+ what the old one protected.
380
+ - **It cannot tell the current record from an earlier copy of itself.** Where a
381
+ rollback matters, the place the record is stored has to say which is current.
382
+ - **It does not make two keys one person.** Each authenticator keeps its own DID
383
+ and signing key; who may write to a database is its access controller's
384
+ decision.
385
+
386
+ `tests/vault-two-authenticators.test.js` runs this in Chromium with two virtual
387
+ authenticators, each with its own PRF secret as two YubiKeys have.
388
+
190
389
  ### largeBlob Metadata Helpers
191
390
 
192
391
  These helpers encode and recover identity metadata used by the demo recovery
@@ -200,8 +399,13 @@ flows.
200
399
  - `writeLargeBlobMetadata(credentialId, payload, options)`
201
400
 
202
401
  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.
402
+ do not reliably return the public key after registration. Persisted metadata was
403
+ therefore the only way to reconstruct an identity until 0.6.0; since then
404
+ `restoreIdentityFromAuthenticator()` does it from two signatures instead, and a
405
+ stored copy only spares the touches. `largeBlob` is the less dependable place to
406
+ keep one: Android Chrome writes the blob and does not return it on read, while
407
+ desktop Chrome reads it
408
+ ([#48](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/issues/48)).
205
409
 
206
410
  ### Credential Storage Helpers
207
411
 
@@ -216,7 +420,11 @@ Safer storage helpers:
216
420
  - `storeWebAuthnCredentialSafe(credential, key?)`
217
421
  - `loadWebAuthnCredentialSafe(key?)`
218
422
  - `clearWebAuthnCredentialSafe(key?)`
219
- - `extractPrfSeedFromCredential(credential)`
423
+ - `extractPrfSeedFromCredential(credential, options?)` →
424
+ `{ seed, source: 'prf' }`, or `{ seed: null, source: 'none' }` without PRF.
425
+ Without a `prfInput` — in `options` or on the credential — it asks a random
426
+ question, so the seed never comes back and nothing derived from it survives
427
+ the page; it says so on the console. Prefer `readPrfOutput`.
220
428
 
221
429
  ### Keystore Encryption Helpers
222
430
 
@@ -347,7 +555,15 @@ const ucanSigner = signer.toUcantoSigner({
347
555
  - `storeWebAuthnCredentialSafe(credential, key?)`
348
556
  - `loadWebAuthnCredentialSafe(key?)`
349
557
  - `clearWebAuthnCredentialSafe(key?)`
350
- - `extractPrfSeedFromCredential(credential)`
558
+ - `extractPrfSeedFromCredential(credential, options?)` — see
559
+ [Credential Storage Helpers](#credential-storage-helpers)
560
+
561
+ ### PRF Keys and Vaults (standalone)
562
+
563
+ The same functions as the root export, without OrbitDB: `readPrfOutput`,
564
+ `deriveSubkey`, `deriveAesKey`, `createVault`, `openVault`, `addSlot`,
565
+ `removeSlot`, `replacePayload`, `slotIdFor`, `PrfUnavailableError` and
566
+ `VaultError`. See [PRF Keys and Vaults](#prf-keys-and-vaults).
351
567
 
352
568
  ### P-256 Wallet Primitives
353
569
 
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.1",
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'