@le-space/orbitdb-identity-provider-webauthn-did 0.7.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,83 @@
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
+
41
+ ## 0.8.0
42
+
43
+ Two functions for wallets, and nothing that changes for anyone else. They were written against
44
+ 0.5.4 on 16 September and vendored into simple-todo's `escrow01` as `0.5.5-p256.8366ed8`, but
45
+ never pushed or released until now — which is why 0.6.0 and 0.7.0 do not have them, and why #70
46
+ took their absence for a removal.
47
+
48
+ ### Added
49
+
50
+ - **Passkey primitives for wallets**, in `./standalone` (#60). A smart
51
+ account that verifies P-256 WebAuthn signatures on-chain — Uniswap's
52
+ Calibur, through webauthn-sol — can use the identity's passkey, without
53
+ the wallet duplicating key extraction and DER parsing or creating a
54
+ second passkey when it cannot recover the key.
55
+ - `getP256CredentialDescriptor(credential)` returns
56
+ `{ credentialId, rawCredentialId, x, y, rpId, userVerification }`, with
57
+ `x` and `y` as 32-byte big-endian `Uint8Array`s, from either credential
58
+ shape, a stored or JSON copy, or a P-256 `did:key` alone (a varsig DID
59
+ holds the key compressed). It returns `null` for RS256 and Ed25519 keys,
60
+ for anything unreadable, and for the placeholder `createCredential`
61
+ writes when it cannot read the public key — also once that has lost its
62
+ `synthetic` flag, which largeBlob metadata does not carry: the point has
63
+ to lie on the curve.
64
+ - `signP256Challenge(descriptor, challenge)` signs exactly 32 bytes, with
65
+ `allowCredentials` pinned to the descriptor whatever the discoverable
66
+ credential policy, and user verification required. It refuses an
67
+ assertion whose `rawId` differs and returns
68
+ `{ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }`:
69
+ `r` and `s` left-padded to 32 bytes, `s` normalised to low-s, and the
70
+ indices as the byte offsets of `"type":"webauthn.get"` and
71
+ `"challenge":"…"` that webauthn-sol reads. The signature is verified
72
+ against `x`/`y` before it is returned.
73
+
74
+ ### Changed
75
+
76
+ - `createCredential` returns `rpId` on both paths, and a stored varsig
77
+ (hardware signer) credential keeps it. An assertion has to name the rpId
78
+ the credential was registered under, which need not be the page's
79
+ hostname.
80
+
3
81
  ## 0.7.0
4
82
 
5
83
  **One breaking change, in the function 0.6.0 added.** `restoreIdentityFromAuthenticator()`
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
 
@@ -179,6 +178,55 @@ const ucanUcantoSigner = signer.toUcantoSigner({
179
178
 
180
179
  The verifier side and app protocol should define which domain label is required. IPFS deployment does not change this requirement.
181
180
 
181
+ ### Passkeys for smart accounts (P-256)
182
+
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.
184
+
185
+ ```javascript
186
+ import {
187
+ getP256CredentialDescriptor,
188
+ signP256Challenge,
189
+ } from '@le-space/orbitdb-identity-provider-webauthn-did/standalone';
190
+
191
+ // A credential from either createCredential, storage, largeBlob or signer.credential
192
+ const descriptor = getP256CredentialDescriptor(credential);
193
+ if (!descriptor) throw new Error('This passkey has no usable P-256 key');
194
+ // { credentialId, rawCredentialId, x, y, rpId, userVerification: 'required' }
195
+
196
+ // Exactly 32 bytes, e.g. a user operation hash
197
+ const { authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s } =
198
+ await signP256Challenge(descriptor, hash);
199
+ ```
200
+
201
+ - **Key.** `x` and `y` are 32-byte big-endian `Uint8Array`s, like `publicKey.x`/`y` on the default path; Calibur's `abi.encode(uint256 x, uint256 y)` is the two concatenated. They come from `publicKey` (`{ x, y }` or SEC1 bytes) or, failing that, from a P-256 `did:key`; where both are present they must agree. `rpId` is the one `createCredential` returns; a credential stored before it returned one falls back to `window.location.hostname`.
202
+ - **`null`** for RS256 and Ed25519 keys, for the placeholder `createCredential` writes when it cannot read the public key (also once largeBlob metadata has dropped its `synthetic` flag: the point is not on the curve), and for anything unreadable.
203
+ - **Signing.** Unlike other requests, this one pins `allowCredentials` to the descriptor, whatever `configureWebAuthn` says, and requires user verification. An assertion from another credential is refused. The challenge is used as the WebAuthn challenge itself, as Calibur passes its hash.
204
+ - **Result**: the fields of webauthn-sol's `WebAuthnAuth`. `r` and `s` are 32 bytes big-endian, `s` normalised to low-s (webauthn-sol refuses `s > n/2`). `clientDataJSON` is a string; `typeIndex` and `challengeIndex` are the UTF-8 byte offsets of `"type":"webauthn.get"` and `"challenge":"<base64url(challenge), unpadded>"` in it. The signature is verified against `x`/`y` before it is returned; anything a verifier would refuse throws instead.
205
+
206
+ | Signing mode | Descriptor |
207
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
208
+ | Default (passkey DID), hardware P-256 (varsig) | yes: the DID encodes the same key |
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 |
210
+ | Hardware Ed25519 (varsig) | `null`: there is no P-256 key |
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
+
182
230
  ### Keystore-based DID (WebAuthn + OrbitDB keystore)
183
231
 
184
232
  ```mermaid
@@ -329,7 +377,8 @@ sequenceDiagram
329
377
  ## Examples
330
378
 
331
379
  Three demo apps, one per option, in one pnpm workspace (`examples/README.md`
332
- 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
333
382
  authenticator (Chromium's virtual one in CI), a badge that is the verdict of
334
383
  the same checks a peer makes, and a panel that runs those checks on two
335
384
  forgeries.
@@ -345,12 +394,36 @@ forgeries.
345
394
  - `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
346
395
  no keystore. Live:
347
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/
348
403
 
349
404
  The identity for each option is built by one module under
350
405
  `examples/shared/lib/options/`, which is also the shortest way to read the
351
406
  API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
352
407
  prompt counts.
353
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
+
354
427
  ## Documentation
355
428
 
356
429
  - `docs/API.md`
@@ -393,11 +466,14 @@ Since **0.6.0** an identity can be restored on a device that has stored nothing:
393
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.
394
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 })`.
395
468
 
396
- 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)).
397
472
 
398
473
  Practical implication:
399
474
 
400
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.
401
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.
402
478
  - Credentials registered before 0.6.0 keep the random PRF input stored with them, so their identities do not move.
403
479
 
package/docs/API.md CHANGED
@@ -77,6 +77,7 @@ Returns a credential info object containing:
77
77
  - `credentialId`
78
78
  - `rawCredentialId`
79
79
  - `publicKey`
80
+ - `rpId` (the relying party the credential is registered under)
80
81
  - `userId`
81
82
  - `displayName`
82
83
  - `attestationObject`
@@ -105,6 +106,9 @@ property:
105
106
  - `WebAuthnAuthenticationError`
106
107
  - `WebAuthnVerificationError`
107
108
  - `KeystoreEncryptionError`
109
+ - `PrfUnavailableError` (`PRF_UNAVAILABLE`) — the authenticator answered
110
+ without a PRF output
111
+ - `VaultError` — a vault refused; see [Vaults](#vaults)
108
112
  - `VarsigVerificationError`
109
113
 
110
114
  ```js
@@ -164,6 +168,9 @@ Common options:
164
168
  - `keystoreEncryptionMethod`
165
169
  - `keyType`
166
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)
167
174
 
168
175
  ### WebAuthn Configuration
169
176
 
@@ -186,6 +193,189 @@ Returns the current WebAuthn config.
186
193
 
187
194
  Restores default WebAuthn config.
188
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
+
189
379
  ### largeBlob Metadata Helpers
190
380
 
191
381
  These helpers encode and recover identity metadata used by the demo recovery
@@ -199,8 +389,13 @@ flows.
199
389
  - `writeLargeBlobMetadata(credentialId, payload, options)`
200
390
 
201
391
  Discoverable passkeys can identify a credential later, but WebAuthn assertions
202
- do not reliably return the public key after registration. Persisted metadata is
203
- 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)).
204
399
 
205
400
  ### Credential Storage Helpers
206
401
 
@@ -215,7 +410,11 @@ Safer storage helpers:
215
410
  - `storeWebAuthnCredentialSafe(credential, key?)`
216
411
  - `loadWebAuthnCredentialSafe(key?)`
217
412
  - `clearWebAuthnCredentialSafe(key?)`
218
- - `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`.
219
418
 
220
419
  ### Keystore Encryption Helpers
221
420
 
@@ -346,7 +545,31 @@ const ucanSigner = signer.toUcantoSigner({
346
545
  - `storeWebAuthnCredentialSafe(credential, key?)`
347
546
  - `loadWebAuthnCredentialSafe(key?)`
348
547
  - `clearWebAuthnCredentialSafe(key?)`
349
- - `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).
557
+
558
+ ### P-256 Wallet Primitives
559
+
560
+ For smart accounts that verify P-256 WebAuthn signatures (for example Calibur
561
+ with webauthn-sol). See the README section "Passkeys for smart accounts".
562
+
563
+ - `getP256CredentialDescriptor(credential)` →
564
+ `{ credentialId, rawCredentialId, x, y, rpId, userVerification: 'required' }`
565
+ or `null` (RS256, Ed25519, placeholder or unreadable key). `x` and `y` are
566
+ 32-byte big-endian `Uint8Array`s.
567
+ - `signP256Challenge(descriptor, challenge)` → `Promise` of
568
+ `{ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }`.
569
+ `challenge` must be exactly 32 bytes. `allowCredentials` is pinned to the
570
+ descriptor, user verification is required, an assertion from another
571
+ credential is refused. `r` and `s` are 32 bytes with `s` low; the indices
572
+ are UTF-8 byte offsets in `clientDataJSON`.
350
573
 
351
574
  ### Worker Keystore Client
352
575
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.7.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,