@le-space/orbitdb-identity-provider-webauthn-did 0.5.3 → 0.6.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,6 +1,213 @@
1
1
  # Changes
2
2
 
3
- ## Unreleased
3
+ ## 0.6.0
4
+
5
+ An identity can be restored on a second device with **nothing stored**: two
6
+ touches of the same passkey give back the DID and the signing key, so
7
+ largeBlob and `localStorage` become a convenience rather than the only road.
8
+ Measured on two phones with one YubiKey before it was written (#61).
9
+
10
+ Nothing existing moves: a credential registered before this keeps the PRF
11
+ input it was made with, and the identity it derives is unchanged. Credentials
12
+ registered from here on are asked the input every device can compute, which is
13
+ what makes the restore work. Two behaviours do change — `getPrfOutput()` now
14
+ falls back to that derivable input instead of returning `null` when a
15
+ credential has none stored, and `createDID()` throws instead of inventing a
16
+ "base58-like" identifier when it cannot encode a key.
17
+
18
+ ### Added
19
+
20
+ - **An identity can be restored on a device that has stored nothing** (#61).
21
+ `restoreIdentityFromAuthenticator()` touches the same passkey twice: once
22
+ with user verification and the PRF input every device can compute, once for a
23
+ second signature. Two ECDSA signatures admit exactly one common public key —
24
+ which an assertion does not carry, and which is why the identity metadata has
25
+ had to live in largeBlob or `localStorage` until now. From it: the DID, and
26
+ the signing key derived from the PRF output with the DID mixed in. The pieces
27
+ are exported on their own as well: `prfInputForRelyingParty()`,
28
+ `recoverPublicKey()` and `recoverPublicKeyCandidates()`.
29
+
30
+ Measured on hardware before it was written — one YubiKey, a Galaxy Fold 5 and
31
+ a Galaxy A57, 2026-09-19: the same PRF value on both phones, the same DID
32
+ recovered from two signatures, the same derived signing key. What of that is
33
+ machine-checkable runs in `tests/webauthn-recovery.test.js`, against a
34
+ software authenticator with a real P-256 key.
35
+
36
+ Nothing stands in for a missing PRF: an authenticator without it gets a
37
+ refusal, because an identity derived from something else is a different
38
+ identity that looks like success.
39
+
40
+ ### Changed
41
+
42
+ - **The PRF input is now fixed per relying party** instead of drawn at random
43
+ per credential (#61). It was stored with the credential metadata, so the
44
+ derived signing key — and with it the identity — was reachable only from the
45
+ device that kept that metadata; a second device asking the same authenticator
46
+ a different question got a different answer. The input is not a secret, the
47
+ output is. Credentials registered earlier keep working: their stored input is
48
+ still used when there is one, and `getPrfOutput()` now falls back to the
49
+ derivable input instead of giving up when there is none.
50
+ - **`createDID` refuses a key it cannot encode.** Its `catch` built a
51
+ "base58-like" identifier out of the same coordinates: a string that looked
52
+ like a DID, was not `did:key`, and could not be reproduced by anything else
53
+ reading that key — including this library on another device. It throws now.
54
+
55
+ ### Changed
56
+
57
+ - CI now runs all 22 test files. Eight never ran there: `identity-forgery`,
58
+ `p256-signature-shapes`, `session-keystore`, `webauthn-debug-log`,
59
+ `webauthn-extension-support`, `webauthn-user-handle`,
60
+ `webauthn-varsig-challenge` and `worker-signer`. They were listed in
61
+ `playwright.node.config.js`, which only the local `preversion` and
62
+ `prepublishOnly` hooks ran, while `release.yml` publishes with
63
+ `--ignore-scripts` because CI is its gate — so no automated gate ran them
64
+ before a release. CI now runs that config in one step, before installing
65
+ browsers; `standalone-toolkit`, `webauthn-attestation-parsing` and
66
+ `webauthn-two-peer-replication` lose their separate steps, which booted a
67
+ demo server they do not use.
68
+ - `identity-forgery` did not load in a clean install. It imported `concat`
69
+ from `uint8arrays`, which this package does not declare; only a hoisted
70
+ `node_modules` (`node-linker=hoisted`) made it resolvable. It now takes
71
+ `concat` from `iso-base/utils`, a declared dependency.
72
+ - `scripts/check-ci-runs-every-test.mjs` fails CI when a file in `tests/` is
73
+ selected by no step. This is the second time suites went unrun: 0.4.0 found
74
+ five.
75
+
76
+ ## 0.5.4
77
+
78
+ Fixes a verification bug that refused about one P-256 passkey identity in
79
+ 128 at every peer, since 0.5.2 (see _Fixed_). Upgrade if you use P-256
80
+ passkeys, which is most platform authenticators.
81
+
82
+ **Upgrade note.** `encryptKeystore` no longer falls back to the credential
83
+ id when the authenticator has no PRF: `wrapSKWithPRF`/`unwrapSKWithPRF`
84
+ throw `PrfUnavailableError`, and `extractPrfSeedFromCredential` returns
85
+ `{ seed: null, source: 'none' }`. The credential id sits in localStorage in
86
+ clear, so a key sealed that way was never sealed. An app that used
87
+ `encryptKeystore` with users whose authenticator has no PRF cannot unseal
88
+ their old key; those users get a fresh keystore DID. The default path and
89
+ varsig are unaffected.
90
+
91
+ ### Added
92
+
93
+ - `signer` option and `createSessionKeystore({ signer })`: a key that signs
94
+ elsewhere — typically a Web Worker — becomes the identity. The keystore
95
+ answers for the signer's DID with a key object whose `sign()` calls the
96
+ signer, OrbitDB signs through it, and no private key exists in the page.
97
+ From the standalone toolkit, `client.deriveSigner(prfSeed)` derives an
98
+ Ed25519 key from the passkey's PRF output inside the worker and returns
99
+ only its DID and public key; `createWorkerSigner(client, { did, publicKey })`
100
+ turns that into the signer.
101
+ - `createSessionKeystore()`: an OrbitDB keystore on memory storage. OrbitDB
102
+ signs with whatever its keystore returns, and the default keystore writes
103
+ every key to disk — so with `encryptKeystore` the unlocked key was sitting
104
+ in IndexedDB unencrypted, and the sealed copy protected nothing after the
105
+ first session. Hand this keystore to `Identities()` and the unlocked key
106
+ lives in memory only; the provider warns when it is given any other.
107
+ - `provider.encryptionState`: `{ enabled, method | reason }`, so an app can
108
+ show whether the key is actually sealed.
109
+
110
+ ### Changed
111
+
112
+ - **No PRF, no seal.** When the authenticator returns no PRF output,
113
+ `wrapSKWithPRF`/`unwrapSKWithPRF` throw `PrfUnavailableError` and
114
+ `extractPrfSeedFromCredential` returns `{ seed: null, source: 'none' }`.
115
+ They used to fall back to the raw credential id, which sits in
116
+ localStorage in clear, so a key "wrapped with PRF" could be wrapped with
117
+ nothing secret. The provider carries on without encryption in that case
118
+ (`encryptionState.reason === 'prf-unavailable'`) and warns. Anything that
119
+ relied on the `'credentialId'` source has to decide for itself now.
120
+ - A varsig identity's id is always derived from its public key.
121
+ `createWebAuthnVarsigIdentity` used to let a stored `credential.did` win,
122
+ so metadata recovered from largeBlob or localStorage — data anyone with
123
+ access to either can edit — decided which DID an identity claimed.
124
+ Verification would then refuse it (0.5.2+), but an app showed the wrong
125
+ DID first.
126
+ - Both `createCredential` paths register with
127
+ `largeBlob: { support: 'preferred' }`. Identity metadata is written to
128
+ largeBlob after registration, and an authenticator only allows that for a
129
+ credential created asking for it; without this the write was refused and
130
+ recovery through "Use Existing Passkey" found nothing once localStorage
131
+ was gone. A `required` set by the largeBlob encryption method stays.
132
+ - The largeBlob identity payload (`createDidLargeBlobPayload`, version 2)
133
+ carries the credential's `prfInput`. Without it a recovered credential
134
+ could name its DID but not re-derive its signing key, so a second device
135
+ got a key of its own — the one thing the derived key exists to prevent.
136
+ Version 1 payloads still parse.
137
+ - `clearIdentityProofs` is exported from the package root, so an app can
138
+ forget an identity completely: credential metadata, stored identity
139
+ assertion, keystore.
140
+
141
+ ### Fixed
142
+
143
+ - **Genuine P-256 signatures were refused about once in 90.** An
144
+ authenticator returns an ES256 signature as DER with minimal integers, so r
145
+ or s can be 31 bytes long. The default path's identity binding unwrapped
146
+ it with a helper that did not pad them back to 32, so about one passkey
147
+ identity in 128 failed verification — at every peer, and permanently,
148
+ because the proof is stored and reused (since 0.5.2). The varsig path had
149
+ the same unwrap plus a second fault: the raw r‖s went to a verifier that
150
+ takes DER, and one that started with 0x30 was parsed as DER and refused.
151
+ Both now leave the conversion to `derToRawSignature`/`verifyP256Signature`
152
+ from `iso-webauthn-varsig`. Found as a 2% flake in the forgery suite;
153
+ `tests/p256-signature-shapes.test.js` forces each shape through the mock
154
+ authenticator's new `signatureShape` option.
155
+ - The `/keystore` subpath exports `PrfUnavailableError`, which its type
156
+ declarations already promised.
157
+ - Creating an encrypted keystore no longer unlocks it straight afterwards:
158
+ the pair is already in memory, and the second prompt bought nothing.
159
+ - The static `OrbitDBWebAuthnIdentityProvider.createIdentity` created a new
160
+ encrypted keystore on every call, replacing the sealed key; it now loads
161
+ the existing one and creates only when there is none.
162
+
163
+ ### Examples
164
+
165
+ - One module per option under `examples/shared/lib/options/`
166
+ (`default-path.js`, `encrypted-keystore.js`, `varsig.js`) builds the
167
+ identity for that option; the demos import them, and each is covered by
168
+ the suite that drives its demo. The four scripted examples in
169
+ `examples/*.js` are gone — none of them ran (Node globals in the browser,
170
+ browser globals in Node, an import that did not exist), and no test ever
171
+ executed them.
172
+ - The three demos share one stack (`examples/shared/lib/stack.js`) and one
173
+ todo store. Each demo prefixes its IndexedDB stores with its own name, so
174
+ "Reset DB" in one demo no longer wipes the other two on the shared
175
+ GitHub Pages origin; the varsig demo's reset now waits for the deletes and
176
+ closes the Level stores first, like the others already did. No more HTTP
177
+ gateway fallback in the demos.
178
+ - `docs/EXAMPLE-SEQUENCES.md` rewritten around the three options, with the
179
+ prompt counts the suites assert; `docs/WEBAUTHN-ENCRYPTED-KEYSTORE-INTEGRATION.md`
180
+ no longer promises what `encryptKeystore` cannot do on the default keystore,
181
+ and no longer names a `secretKey` option that does not exist.
182
+ - The encrypted-keystore demo gives OrbitDB a session keystore in both of its
183
+ modes, so its "encrypted at rest" is true, and its worker mode is now the
184
+ real signer: the key is derived from the passkey inside the worker and
185
+ OrbitDB signs through it (`createWorkerSigner`, `signer` option). The
186
+ archive the worker used to hand to the page, the probe panel beside the
187
+ real signing, and the credential-id fallback are gone; the panel shows the
188
+ signing backend, what is at rest and the prompt count.
189
+ - The default demo lets you pick the derived key's type (`signingKeyType`),
190
+ shows the identity document's hash, the key in use and the number of
191
+ WebAuthn prompts, and splits "Logout" (keeps the passkey metadata) from
192
+ "Forget identity" (removes everything this device holds). Its own
193
+ credential store dropped `prfInput`; it uses the library's now.
194
+ - One pnpm workspace for the three demos: one lockfile, one set of
195
+ dependencies, `examples/shared` for what they share.
196
+ - The "Verified" badge is now the verdict of a real check — entry signature,
197
+ identity block, DID binding, write list — and each demo carries a panel
198
+ that runs the same verifier on two forgeries. The old badge compared the
199
+ database's identity with itself.
200
+ - The varsig demo no longer honours `window.__PLAYWRIGHT__`; its browser
201
+ test drives the real flow with a virtual authenticator, including
202
+ recovery from largeBlob.
203
+ - Demo READMEs, the README's option table and SECURITY.md describe what the
204
+ code does: which key signs, what sits at rest, how many prompts.
205
+
206
+ ### Tests
207
+
208
+ - The mock authenticator returns DER-encoded P-256 signatures, as
209
+ authenticators do. Raw r‖s starting with 0x30 (one in 256) was read as
210
+ DER by the varsig decoder and refused.
4
211
 
5
212
  ## 0.5.3
6
213
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ This package provides:
11
11
  - A keystore helper export (`@le-space/orbitdb-identity-provider-webauthn-did/keystore`) for encrypted keystore utilities and provider wiring.
12
12
  - **WebAuthn-Varsig**: No insecure OrbitDB keystore at all. Each entry is signed by WebAuthn (varsig envelope), so keys never leave the authenticator, one Passkey (WebAuthn) prompt per write.
13
13
  - **Passkey DID with a derived signing key** (the default): the DID is the credential's own P-256 key, and OrbitDB signs entries with a key derived from the passkey's PRF output (HKDF-SHA256, domain-separated by the DID) — secp256k1 by default, Ed25519 with `signingKeyType: 'Ed25519'`. The same passkey yields the same identity document on every device. That key lives in OrbitDB's keystore and is **not** covered by `encryptKeystore`; without PRF the keystore generates one, which then stays on that device.
14
- - **Keystore-based DID**: Generates an Ed25519/secp256k1 keystore keypair for OrbitDB signing in browser memory. When `encryptKeystore` is enabled, the private key is encrypted with AES-GCM and only rehydrated in memory after a WebAuthn unlock (PRF, largeBlob, or hmac-secret).
14
+ - **Keystore-based DID**: an Ed25519/secp256k1 key pair in the OrbitDB keystore is the identity and signs entries. With `encryptKeystore` a sealed copy of that key sits in localStorage and the passkey unlocks it once per session (PRF; largeBlob or hmac-secret where supported). The unlocked key then lives in OrbitDB's own keystore — give OrbitDB `createSessionKeystore()` so that copy stays in memory; with the default keystore it is written to IndexedDB unencrypted, and the provider warns. Without PRF nothing is sealed: the key goes in unencrypted and `provider.encryptionState` says so.
15
15
 
16
16
  ## Current WebAuthn Model
17
17
 
@@ -28,10 +28,20 @@ In this repo today, metadata recovery works in two layers:
28
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
30
 
31
- **Recommendation (security-first):**
31
+ **Which option, and what each one actually gives you:**
32
32
 
33
- - **Best security:** Varsig provider (hardware-backed key for every write).
34
- - **Best balance:** Keystore provider with WebAuthn-encrypted keystore (fewer prompts, faster writes, key material in memory during session).
33
+ | | Default (passkey DID + derived key) | Keystore DID + `encryptKeystore` | Varsig |
34
+ | -------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------- |
35
+ | DID | the passkey's P-256 key | the keystore key (Ed25519/secp256k1) | the passkey's key |
36
+ | Signs an entry | a key derived from the passkey's PRF output (secp256k1, or Ed25519 with `signingKeyType`) | the keystore key, sealed by the passkey and unlocked once per session | the passkey, every write |
37
+ | Prompts per write | 0 | 0 | 1 |
38
+ | Prompts per session | 0 after the first (the identity assertion is stored) | 1 (unlock) | 2 (identity assertions) |
39
+ | Same identity on a second device | yes, given PRF | no — a new keystore key, unless the sealed copy travels | yes |
40
+ | At rest in the browser | the derived key, **unencrypted**, in OrbitDB's keystore | the sealed copy only, **if** OrbitDB gets `createSessionKeystore()`; otherwise the unlocked key too | nothing |
41
+ | Without PRF | a generated key, one device only | not sealed at all (`encryptionState.reason === 'prf-unavailable'`) | unaffected |
42
+ | What a peer verifies | the WebAuthn assertion binding the DID's key to the derived key, then each entry's signature | the DID is the signing key, then each entry's signature | each entry's varsig against the DID's key |
43
+
44
+ Varsig is the only option with no signing key in JavaScript at all; it costs a prompt per write and binds entries to the origin. The default is the cheapest and moves with the passkey, but leaves a software key on disk. The sealed keystore sits between, and only means something at rest with a session keystore.
35
45
 
36
46
  Note: WebAuthn varsig support in this repo relies on our forked `@le-space/iso-*` packages of [Hugo Dias iso-repo](https://github.com/hugomrdias/iso-repo/) (notably `@le-space/iso-did` and `@le-space/iso-webauthn-varsig`) to align with the updated varsig flow.
37
47
 
@@ -178,50 +188,102 @@ sequenceDiagram
178
188
  participant App
179
189
  participant WebAuthn
180
190
  participant Auth as Authenticator
181
- participant KS as OrbitDB Keystore
182
- participant Enc as KeystoreEncryption
191
+ participant Prov as Keystore-DID provider
192
+ participant LS as localStorage
193
+ participant KS as OrbitDB keystore
183
194
  participant DB as OrbitDB
184
195
 
185
196
  User->>App: Create credential
186
- App->>WebAuthn: create()
197
+ App->>WebAuthn: create() with PRF and largeBlob requested
187
198
  WebAuthn->>Auth: Create passkey
188
199
  Auth-->>WebAuthn: Attestation
189
200
  WebAuthn-->>App: Credential
190
201
 
191
- App->>KS: getKey() or add generated Ed25519 key
192
- KS-->>App: Keystore keypair
193
-
194
- opt encryptKeystore=true
195
- App->>Enc: generateSecretKey()
196
- Enc-->>App: sk
197
- App->>Enc: encrypt keystore private key (AES-GCM)
198
- alt prf
199
- App->>WebAuthn: get() with PRF
202
+ App->>Prov: createIdentity({ useKeystoreDID, encryptKeystore })
203
+ alt encryptKeystore, first session
204
+ Prov->>Prov: generateSecretKey() gives sk
205
+ Prov->>Prov: generateKeyPair() gives a random Ed25519 key
206
+ Prov->>Prov: AES-GCM encrypt the private key with sk
207
+ alt prf (the default)
208
+ Prov->>WebAuthn: get() with PRF
200
209
  WebAuthn->>Auth: User verification
201
- Auth-->>WebAuthn: PRF output
202
- WebAuthn-->>App: PRF bytes
203
- App->>Enc: wrap sk with PRF
210
+ alt PRF output
211
+ Auth-->>Prov: PRF output
212
+ Prov->>Prov: wrap sk with a key derived from it
213
+ Prov->>LS: sealed key, wrapped sk, PRF input
214
+ Prov->>KS: addKey(did, the unlocked key)
215
+ else no PRF
216
+ Auth-->>Prov: no PRF result
217
+ Note right of Prov: Nothing is sealed. encryptionState is { enabled false, reason prf-unavailable }
218
+ Prov->>KS: getKey(), or add a generated key
219
+ end
204
220
  else largeBlob
205
- App->>WebAuthn: get() with largeBlob write
221
+ Prov->>WebAuthn: get() with largeBlob write
206
222
  WebAuthn->>Auth: User verification
207
- Auth-->>WebAuthn: Store sk in largeBlob
208
- WebAuthn-->>App: largeBlob stored
223
+ alt written
224
+ Auth-->>Prov: written is true
225
+ Prov->>LS: sealed key, sk kept on the authenticator
226
+ Prov->>KS: addKey(did, the unlocked key)
227
+ else not written
228
+ Auth-->>Prov: written is false
229
+ Note right of Prov: Throws. Nothing is stored.
230
+ end
209
231
  else hmac-secret
210
- App->>WebAuthn: get() with hmac-secret
232
+ Prov->>WebAuthn: get() with hmac-secret
211
233
  WebAuthn->>Auth: User verification
212
- Auth-->>WebAuthn: HMAC output
213
- WebAuthn-->>App: HMAC bytes
214
- App->>Enc: wrap sk with HMAC
234
+ Auth-->>Prov: HMAC output
235
+ Prov->>Prov: wrap sk with it
236
+ Prov->>LS: sealed key, wrapped sk
237
+ Prov->>KS: addKey(did, the unlocked key)
215
238
  end
239
+ else encryptKeystore with prf, later session
240
+ Prov->>LS: load the sealed key
241
+ Prov->>WebAuthn: get() with PRF, the stored input
242
+ Auth-->>Prov: PRF output
243
+ Prov->>Prov: unwrap sk, then decrypt the key
244
+ Prov->>KS: addKey(did, the unlocked key)
245
+ else not encrypted
246
+ Prov->>KS: getKey(), or add a generated key
216
247
  end
248
+ Prov-->>App: Identity, DID = did:key of the keystore key
217
249
 
250
+ User->>App: Add entry
218
251
  App->>DB: db.put()
219
- DB->>KS: sign entry with keystore key
252
+ DB->>KS: sign entry with the keystore key
220
253
  KS-->>DB: Entry signature
221
254
 
222
- Note over App,KS: Keystore private key is encrypted at rest when `encryptKeystore=true`.
255
+ Note over App,KS: The sealed copy stays at rest. The unlocked key lives in the OrbitDB keystore, so create it with createSessionKeystore() to keep it in memory.
223
256
  ```
224
257
 
258
+ OrbitDB signs with whatever its keystore returns, and the default keystore
259
+ persists every key it holds. So `encryptKeystore` needs a keystore that
260
+ forgets:
261
+
262
+ ```js
263
+ import { Identities } from '@orbitdb/core';
264
+ import {
265
+ OrbitDBWebAuthnIdentityProviderFunction,
266
+ createSessionKeystore,
267
+ } from '@le-space/orbitdb-identity-provider-webauthn-did';
268
+
269
+ const keystore = await createSessionKeystore(); // memory only
270
+ const identities = await Identities({ ipfs, keystore });
271
+ const identity = await identities.createIdentity({
272
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
273
+ webauthnCredential,
274
+ useKeystoreDID: true,
275
+ keystoreKeyType: 'Ed25519',
276
+ encryptKeystore: true,
277
+ keystore,
278
+ }),
279
+ });
280
+ ```
281
+
282
+ Every session unlocks the sealed copy again (one passkey prompt). Without
283
+ PRF the provider does not seal anything — it used to fall back to the
284
+ credential id, which is not a secret — and reports
285
+ `encryptionState: { enabled: false, reason: 'prf-unavailable' }`.
286
+
225
287
  ### Varsig (no keystore)
226
288
 
227
289
  ```mermaid
@@ -235,24 +297,28 @@ sequenceDiagram
235
297
  participant DB as OrbitDB
236
298
 
237
299
  User->>App: Create credential
238
- App->>WebAuthn: create()
300
+ App->>WebAuthn: create() with largeBlob requested
239
301
  WebAuthn->>Auth: Create passkey
240
302
  Auth-->>WebAuthn: Attestation
241
303
  WebAuthn-->>App: Credential
242
304
 
243
305
  User->>App: Create varsig identity
244
- App->>Var: createIdentity()
245
- Var->>WebAuthn: get()
306
+ App->>Var: createWebAuthnVarsigIdentity({ credential })
307
+ Var->>Var: id = did:key of the passkey's public key
308
+ Var->>WebAuthn: get() over the id
246
309
  WebAuthn->>Auth: User verification
247
310
  Auth-->>WebAuthn: Assertion
248
- WebAuthn-->>Var: Assertion
249
- Var->>Var: encode varsig envelope
311
+ WebAuthn-->>Var: Assertion, becomes signatures.id
312
+ Var->>WebAuthn: get() over publicKey + idSignature
313
+ WebAuthn->>Auth: User verification
314
+ Auth-->>WebAuthn: Assertion
315
+ WebAuthn-->>Var: Assertion, becomes signatures.publicKey
250
316
  Var-->>App: Identity
251
317
 
252
318
  User->>App: Add entry
253
319
  App->>DB: db.put()
254
- DB->>Var: signIdentity(payload)
255
- Var->>WebAuthn: get()
320
+ DB->>Var: sign(entry)
321
+ Var->>WebAuthn: get() over the entry
256
322
  WebAuthn->>Auth: User verification
257
323
  Auth-->>WebAuthn: Assertion
258
324
  WebAuthn-->>Var: Assertion
@@ -262,22 +328,28 @@ sequenceDiagram
262
328
 
263
329
  ## Examples
264
330
 
265
- Svelte demos:
266
-
267
- - Live demo directory: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/
268
- - `examples/ed25519-encrypted-keystore-demo/` - Ed25519 keystore DID; keystore encrypted at rest with WebAuthn (PRF when available, otherwise largeBlob/hmac-secret). Live demo: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/ed25519-encrypted-keystore-demo/
269
- - `examples/webauthn-varsig-demo/` - Varsig provider with passkey signing for each entry. Includes discoverable credential diagnostics, `Use Existing Passkey`, `largeBlob` varsig metadata recovery, and local fallback recovery. Live demo: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
270
- - `examples/webauthn-todo-demo/` - WebAuthn DID (no keystore signing; identity-only). Includes discoverable credential diagnostics, `Use Existing Passkey`, `largeBlob` identity metadata recovery, and local fallback recovery. Live demo: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-todo-demo/
271
-
272
- Scripted examples:
273
-
274
- - `examples/ed25519-keystore-did-example.js` - Keystore DID flow.
275
- - `examples/encrypted-keystore-example.js` - Keystore encryption flow.
276
- - `examples/simple-encryption-integration.js` - Keystore + database content encryption.
277
-
278
- Mermaid sequences for scripts:
279
-
280
- - `docs/EXAMPLE-SEQUENCES.md`
331
+ 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
333
+ authenticator (Chromium's virtual one in CI), a badge that is the verdict of
334
+ the same checks a peer makes, and a panel that runs those checks on two
335
+ forgeries.
336
+
337
+ - `examples/webauthn-todo-demo/` — the default: passkey DID, entries signed by
338
+ a key derived from the passkey (secp256k1 or Ed25519), no prompt per write.
339
+ Live: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-todo-demo/
340
+ - `examples/ed25519-encrypted-keystore-demo/` — an Ed25519 keystore DID,
341
+ either sealed by the passkey and unlocked into a session keystore, or
342
+ signed in a Web Worker that derives the key from the passkey and never
343
+ hands it to the page. Live:
344
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/ed25519-encrypted-keystore-demo/
345
+ - `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
346
+ no keystore. Live:
347
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
348
+
349
+ The identity for each option is built by one module under
350
+ `examples/shared/lib/options/`, which is also the shortest way to read the
351
+ API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
352
+ prompt counts.
281
353
 
282
354
  ## Documentation
283
355
 
package/SECURITY.md CHANGED
@@ -2,12 +2,16 @@
2
2
 
3
3
  ## Supported Versions
4
4
 
5
- Security fixes are provided for the current `0.3.x` release line.
5
+ Security fixes are provided for the current `0.5.x` release line.
6
6
 
7
7
  | Version | Supported |
8
8
  | ------- | --------- |
9
- | 0.3.x | Yes |
10
- | < 0.3 | No |
9
+ | 0.5.x | Yes |
10
+ | < 0.5 | No |
11
+
12
+ Every release up to and including 0.5.2 is affected by
13
+ [GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg)
14
+ (identity verification checked shapes, not signatures). Use 0.5.3 or later.
11
15
 
12
16
  ## Reporting a Vulnerability
13
17
 
@@ -35,22 +39,49 @@ This package provides WebAuthn-based identity providers and helper APIs for
35
39
  OrbitDB. It handles sensitive identity material and should be reviewed in the
36
40
  context of the application that embeds it.
37
41
 
42
+ There are three ways to use it, and they protect different things. The
43
+ README carries a table; the short version:
44
+
38
45
  ### Varsig Provider
39
46
 
40
- The varsig provider is the preferred security model when available. Entry
41
- signing is performed through WebAuthn assertions, so the private key remains
42
- inside the authenticator and is not exported to application memory.
47
+ The only option with no signing key in JavaScript: every entry is a WebAuthn
48
+ assertion in a varsig envelope, so the private key never leaves the
49
+ authenticator (or the passkey provider — synced passkeys are not
50
+ device-bound). It costs one prompt per write, and entries are bound to the
51
+ origin they were signed on.
52
+
53
+ ### Default: passkey DID with a derived signing key
54
+
55
+ The DID is the passkey's own P-256 key. OrbitDB entries are signed by a
56
+ software key **derived** from the passkey's PRF output (HKDF-SHA256,
57
+ domain-separated by the DID; secp256k1, or Ed25519 with `signingKeyType`).
58
+ The passkey signs the identity document once, binding its key to the derived
59
+ key; that assertion is stored and reused.
60
+
61
+ The derived key sits in OrbitDB's keystore, **unencrypted**, in the
62
+ browser's IndexedDB. `encryptKeystore` does not apply to this path. Anyone who
63
+ can read that storage can sign as the DID until the identity is rotated.
64
+ Without PRF the keystore generates a key instead, which stays on one device.
65
+
66
+ ### Keystore Provider (`useKeystoreDID` + `encryptKeystore`)
67
+
68
+ The DID is a keystore key (Ed25519 or secp256k1). With `encryptKeystore` a
69
+ sealed copy of that key is kept in localStorage, unlocked once per session by
70
+ the passkey (PRF; largeBlob or hmac-secret where supported).
43
71
 
44
- ### Keystore Provider
72
+ Two things decide whether that means anything at rest:
45
73
 
46
- The keystore provider can generate browser-side Ed25519 or secp256k1 keys for
47
- OrbitDB signing. When encrypted keystore mode is enabled, key material is
48
- encrypted at rest with AES-GCM and unlocked through WebAuthn-supported secret
49
- recovery mechanisms such as PRF, largeBlob, or hmac-secret.
74
+ - OrbitDB signs with whatever its keystore returns, and its default keystore
75
+ writes every key to disk. Give `Identities()` the keystore from
76
+ `createSessionKeystore()` (memory only). With any other keystore the
77
+ unlocked key lands in IndexedDB unencrypted, and the provider warns.
78
+ - Without PRF output nothing is sealed. The provider used to fall back to
79
+ the credential id — a value that sits in localStorage in clear — as the
80
+ wrapping seed; since 0.5.4 it does not, and reports
81
+ `encryptionState: { enabled: false, reason: 'prf-unavailable' }`.
50
82
 
51
- During an unlocked session, private key material may exist in browser memory.
52
- Applications with stricter requirements should prefer the varsig provider or
53
- keep keystore sessions short.
83
+ During an unlocked session the private key is in page memory in every
84
+ option but varsig.
54
85
 
55
86
  ### Identity Metadata Recovery
56
87
 
@@ -70,8 +101,14 @@ as a replacement for authenticator-backed key protection.
70
101
 
71
102
  - Serve production applications over HTTPS.
72
103
  - Configure WebAuthn relying party IDs and origins deliberately.
73
- - Prefer the varsig provider for hardware-backed signing on every write.
74
- - Enable encrypted keystore mode if using the keystore provider in production.
104
+ - Prefer the varsig provider where a prompt per write is acceptable; nothing
105
+ else keeps the signing key out of JavaScript.
106
+ - With the keystore provider, use `encryptKeystore` together with
107
+ `createSessionKeystore()`, and check `provider.encryptionState` before
108
+ telling users their key is encrypted.
109
+ - Verify identities and entries the way a peer would (see
110
+ `examples/shared/lib/verification.js`); a badge that checks nothing is
111
+ worse than none.
75
112
  - Keep dependencies updated and review `npm audit --omit=dev` output before
76
113
  production deployments.
77
114
  - Do not log private keys, PRF seeds, decrypted keystore archives, or raw secret
package/docs/API.md CHANGED
@@ -132,7 +132,7 @@ Creates a signer instance from credential metadata.
132
132
  ```js
133
133
  const provider = new WebAuthnDIDProvider(credential);
134
134
  const signature = await provider.sign(new TextEncoder().encode('hello'));
135
- const valid = await provider.verify(signature);
135
+ const valid = await provider.verify(signature, data, credential.publicKey);
136
136
  ```
137
137
 
138
138
  ### OrbitDB Identity Provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.5.3",
3
+ "version": "0.6.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",
package/src/errors.js CHANGED
@@ -72,6 +72,20 @@ export class KeystoreEncryptionError extends WebAuthnIdentityError {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * The authenticator produced no PRF output. Nothing else can stand in for
77
+ * it: a key derived from a value anyone can read (the credential id used to
78
+ * be that stand-in) is not a secret, so callers decide what to do without
79
+ * PRF instead of getting a "wrapped" key that is not.
80
+ */
81
+ export class PrfUnavailableError extends KeystoreEncryptionError {
82
+ constructor(message = 'PRF output unavailable', options) {
83
+ super(message, options);
84
+ this.name = 'PrfUnavailableError';
85
+ this.code = 'PRF_UNAVAILABLE';
86
+ }
87
+ }
88
+
75
89
  export class VarsigVerificationError extends WebAuthnIdentityError {
76
90
  constructor(message, options = {}) {
77
91
  super(message, {
package/src/index.js CHANGED
@@ -291,6 +291,30 @@ export {
291
291
  extensionSupportFromCredential,
292
292
  } from './keystore/encryption.js';
293
293
 
294
+ export { clearIdentityProofs } from './webauthn/identity-proof-store.js';
295
+ export {
296
+ createSessionKeystore,
297
+ isSessionKeystore,
298
+ } from './keystore/session-keystore.js';
299
+ export { PrfUnavailableError } from './errors.js';
300
+ // Restoring on a device that has stored nothing (#61): the fixed PRF input,
301
+ // the public key recovered from two assertions, and the two together.
302
+ export {
303
+ PRF_INPUT_INFO,
304
+ prfInputForRelyingParty,
305
+ } from './webauthn/prf-input.js';
306
+ export {
307
+ recoverPublicKey,
308
+ recoverPublicKeyCandidates,
309
+ } from './webauthn/recovery.js';
310
+ export { restoreIdentityFromAuthenticator } from './webauthn/restore.js';
311
+ // The two halves of it, for a caller that already holds one: a PRF output, or
312
+ // a credential it wants the output for.
313
+ export {
314
+ deriveSigningKeyBytes,
315
+ getPrfOutput,
316
+ } from './keystore/derived-signing-key.js';
317
+
294
318
  export default {
295
319
  WebAuthnDIDProvider,
296
320
  OrbitDBWebAuthnIdentityProvider,