@le-space/orbitdb-identity-provider-webauthn-did 0.5.3 → 0.5.4

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
@@ -2,6 +2,142 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.4
6
+
7
+ Fixes a verification bug that refused about one P-256 passkey identity in
8
+ 128 at every peer, since 0.5.2 (see _Fixed_). Upgrade if you use P-256
9
+ passkeys, which is most platform authenticators.
10
+
11
+ **Upgrade note.** `encryptKeystore` no longer falls back to the credential
12
+ id when the authenticator has no PRF: `wrapSKWithPRF`/`unwrapSKWithPRF`
13
+ throw `PrfUnavailableError`, and `extractPrfSeedFromCredential` returns
14
+ `{ seed: null, source: 'none' }`. The credential id sits in localStorage in
15
+ clear, so a key sealed that way was never sealed. An app that used
16
+ `encryptKeystore` with users whose authenticator has no PRF cannot unseal
17
+ their old key; those users get a fresh keystore DID. The default path and
18
+ varsig are unaffected.
19
+
20
+ ### Added
21
+
22
+ - `signer` option and `createSessionKeystore({ signer })`: a key that signs
23
+ elsewhere — typically a Web Worker — becomes the identity. The keystore
24
+ answers for the signer's DID with a key object whose `sign()` calls the
25
+ signer, OrbitDB signs through it, and no private key exists in the page.
26
+ From the standalone toolkit, `client.deriveSigner(prfSeed)` derives an
27
+ Ed25519 key from the passkey's PRF output inside the worker and returns
28
+ only its DID and public key; `createWorkerSigner(client, { did, publicKey })`
29
+ turns that into the signer.
30
+ - `createSessionKeystore()`: an OrbitDB keystore on memory storage. OrbitDB
31
+ signs with whatever its keystore returns, and the default keystore writes
32
+ every key to disk — so with `encryptKeystore` the unlocked key was sitting
33
+ in IndexedDB unencrypted, and the sealed copy protected nothing after the
34
+ first session. Hand this keystore to `Identities()` and the unlocked key
35
+ lives in memory only; the provider warns when it is given any other.
36
+ - `provider.encryptionState`: `{ enabled, method | reason }`, so an app can
37
+ show whether the key is actually sealed.
38
+
39
+ ### Changed
40
+
41
+ - **No PRF, no seal.** When the authenticator returns no PRF output,
42
+ `wrapSKWithPRF`/`unwrapSKWithPRF` throw `PrfUnavailableError` and
43
+ `extractPrfSeedFromCredential` returns `{ seed: null, source: 'none' }`.
44
+ They used to fall back to the raw credential id, which sits in
45
+ localStorage in clear, so a key "wrapped with PRF" could be wrapped with
46
+ nothing secret. The provider carries on without encryption in that case
47
+ (`encryptionState.reason === 'prf-unavailable'`) and warns. Anything that
48
+ relied on the `'credentialId'` source has to decide for itself now.
49
+ - A varsig identity's id is always derived from its public key.
50
+ `createWebAuthnVarsigIdentity` used to let a stored `credential.did` win,
51
+ so metadata recovered from largeBlob or localStorage — data anyone with
52
+ access to either can edit — decided which DID an identity claimed.
53
+ Verification would then refuse it (0.5.2+), but an app showed the wrong
54
+ DID first.
55
+ - Both `createCredential` paths register with
56
+ `largeBlob: { support: 'preferred' }`. Identity metadata is written to
57
+ largeBlob after registration, and an authenticator only allows that for a
58
+ credential created asking for it; without this the write was refused and
59
+ recovery through "Use Existing Passkey" found nothing once localStorage
60
+ was gone. A `required` set by the largeBlob encryption method stays.
61
+ - The largeBlob identity payload (`createDidLargeBlobPayload`, version 2)
62
+ carries the credential's `prfInput`. Without it a recovered credential
63
+ could name its DID but not re-derive its signing key, so a second device
64
+ got a key of its own — the one thing the derived key exists to prevent.
65
+ Version 1 payloads still parse.
66
+ - `clearIdentityProofs` is exported from the package root, so an app can
67
+ forget an identity completely: credential metadata, stored identity
68
+ assertion, keystore.
69
+
70
+ ### Fixed
71
+
72
+ - **Genuine P-256 signatures were refused about once in 90.** An
73
+ authenticator returns an ES256 signature as DER with minimal integers, so r
74
+ or s can be 31 bytes long. The default path's identity binding unwrapped
75
+ it with a helper that did not pad them back to 32, so about one passkey
76
+ identity in 128 failed verification — at every peer, and permanently,
77
+ because the proof is stored and reused (since 0.5.2). The varsig path had
78
+ the same unwrap plus a second fault: the raw r‖s went to a verifier that
79
+ takes DER, and one that started with 0x30 was parsed as DER and refused.
80
+ Both now leave the conversion to `derToRawSignature`/`verifyP256Signature`
81
+ from `iso-webauthn-varsig`. Found as a 2% flake in the forgery suite;
82
+ `tests/p256-signature-shapes.test.js` forces each shape through the mock
83
+ authenticator's new `signatureShape` option.
84
+ - The `/keystore` subpath exports `PrfUnavailableError`, which its type
85
+ declarations already promised.
86
+ - Creating an encrypted keystore no longer unlocks it straight afterwards:
87
+ the pair is already in memory, and the second prompt bought nothing.
88
+ - The static `OrbitDBWebAuthnIdentityProvider.createIdentity` created a new
89
+ encrypted keystore on every call, replacing the sealed key; it now loads
90
+ the existing one and creates only when there is none.
91
+
92
+ ### Examples
93
+
94
+ - One module per option under `examples/shared/lib/options/`
95
+ (`default-path.js`, `encrypted-keystore.js`, `varsig.js`) builds the
96
+ identity for that option; the demos import them, and each is covered by
97
+ the suite that drives its demo. The four scripted examples in
98
+ `examples/*.js` are gone — none of them ran (Node globals in the browser,
99
+ browser globals in Node, an import that did not exist), and no test ever
100
+ executed them.
101
+ - The three demos share one stack (`examples/shared/lib/stack.js`) and one
102
+ todo store. Each demo prefixes its IndexedDB stores with its own name, so
103
+ "Reset DB" in one demo no longer wipes the other two on the shared
104
+ GitHub Pages origin; the varsig demo's reset now waits for the deletes and
105
+ closes the Level stores first, like the others already did. No more HTTP
106
+ gateway fallback in the demos.
107
+ - `docs/EXAMPLE-SEQUENCES.md` rewritten around the three options, with the
108
+ prompt counts the suites assert; `docs/WEBAUTHN-ENCRYPTED-KEYSTORE-INTEGRATION.md`
109
+ no longer promises what `encryptKeystore` cannot do on the default keystore,
110
+ and no longer names a `secretKey` option that does not exist.
111
+ - The encrypted-keystore demo gives OrbitDB a session keystore in both of its
112
+ modes, so its "encrypted at rest" is true, and its worker mode is now the
113
+ real signer: the key is derived from the passkey inside the worker and
114
+ OrbitDB signs through it (`createWorkerSigner`, `signer` option). The
115
+ archive the worker used to hand to the page, the probe panel beside the
116
+ real signing, and the credential-id fallback are gone; the panel shows the
117
+ signing backend, what is at rest and the prompt count.
118
+ - The default demo lets you pick the derived key's type (`signingKeyType`),
119
+ shows the identity document's hash, the key in use and the number of
120
+ WebAuthn prompts, and splits "Logout" (keeps the passkey metadata) from
121
+ "Forget identity" (removes everything this device holds). Its own
122
+ credential store dropped `prfInput`; it uses the library's now.
123
+ - One pnpm workspace for the three demos: one lockfile, one set of
124
+ dependencies, `examples/shared` for what they share.
125
+ - The "Verified" badge is now the verdict of a real check — entry signature,
126
+ identity block, DID binding, write list — and each demo carries a panel
127
+ that runs the same verifier on two forgeries. The old badge compared the
128
+ database's identity with itself.
129
+ - The varsig demo no longer honours `window.__PLAYWRIGHT__`; its browser
130
+ test drives the real flow with a virtual authenticator, including
131
+ recovery from largeBlob.
132
+ - Demo READMEs, the README's option table and SECURITY.md describe what the
133
+ code does: which key signs, what sits at rest, how many prompts.
134
+
135
+ ### Tests
136
+
137
+ - The mock authenticator returns DER-encoded P-256 signatures, as
138
+ authenticators do. Raw r‖s starting with 0x30 (one in 256) was read as
139
+ DER by the varsig decoder and refused.
140
+
5
141
  ## 0.5.3
6
142
 
7
143
  ### Security
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
 
@@ -219,9 +229,38 @@ sequenceDiagram
219
229
  DB->>KS: sign entry with keystore key
220
230
  KS-->>DB: Entry signature
221
231
 
222
- Note over App,KS: Keystore private key is encrypted at rest when `encryptKeystore=true`.
232
+ Note over App,KS: With `encryptKeystore=true` the sealed copy is at rest; the unlocked key lives in the OrbitDB keystore — make that `createSessionKeystore()` so it stays in memory.
223
233
  ```
224
234
 
235
+ OrbitDB signs with whatever its keystore returns, and the default keystore
236
+ persists every key it holds. So `encryptKeystore` needs a keystore that
237
+ forgets:
238
+
239
+ ```js
240
+ import { Identities } from '@orbitdb/core';
241
+ import {
242
+ OrbitDBWebAuthnIdentityProviderFunction,
243
+ createSessionKeystore,
244
+ } from '@le-space/orbitdb-identity-provider-webauthn-did';
245
+
246
+ const keystore = await createSessionKeystore(); // memory only
247
+ const identities = await Identities({ ipfs, keystore });
248
+ const identity = await identities.createIdentity({
249
+ provider: OrbitDBWebAuthnIdentityProviderFunction({
250
+ webauthnCredential,
251
+ useKeystoreDID: true,
252
+ keystoreKeyType: 'Ed25519',
253
+ encryptKeystore: true,
254
+ keystore,
255
+ }),
256
+ });
257
+ ```
258
+
259
+ Every session unlocks the sealed copy again (one passkey prompt). Without
260
+ PRF the provider does not seal anything — it used to fall back to the
261
+ credential id, which is not a secret — and reports
262
+ `encryptionState: { enabled: false, reason: 'prf-unavailable' }`.
263
+
225
264
  ### Varsig (no keystore)
226
265
 
227
266
  ```mermaid
@@ -262,22 +301,28 @@ sequenceDiagram
262
301
 
263
302
  ## Examples
264
303
 
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`
304
+ Three demo apps, one per option, in one pnpm workspace (`examples/README.md`
305
+ has the install). Each proves its option in the browser: a real
306
+ authenticator (Chromium's virtual one in CI), a badge that is the verdict of
307
+ the same checks a peer makes, and a panel that runs those checks on two
308
+ forgeries.
309
+
310
+ - `examples/webauthn-todo-demo/` — the default: passkey DID, entries signed by
311
+ a key derived from the passkey (secp256k1 or Ed25519), no prompt per write.
312
+ Live: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-todo-demo/
313
+ - `examples/ed25519-encrypted-keystore-demo/` — an Ed25519 keystore DID,
314
+ either sealed by the passkey and unlocked into a session keystore, or
315
+ signed in a Web Worker that derives the key from the passkey and never
316
+ hands it to the page. Live:
317
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/ed25519-encrypted-keystore-demo/
318
+ - `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
319
+ no keystore. Live:
320
+ https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
321
+
322
+ The identity for each option is built by one module under
323
+ `examples/shared/lib/options/`, which is also the shortest way to read the
324
+ API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
325
+ prompt counts.
281
326
 
282
327
  ## Documentation
283
328
 
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.5.4",
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,13 @@ 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
+
294
301
  export default {
295
302
  WebAuthnDIDProvider,
296
303
  OrbitDBWebAuthnIdentityProvider,
@@ -11,7 +11,7 @@ import {
11
11
  CRYPTO_ALGORITHMS,
12
12
  KEYSTORE_ENCRYPTION_METHODS,
13
13
  } from '../constants.js';
14
- import { KeystoreEncryptionError } from '../errors.js';
14
+ import { KeystoreEncryptionError, PrfUnavailableError } from '../errors.js';
15
15
 
16
16
  const log = logger(
17
17
  'orbitdb-identity-provider-webauthn-did:keystore-encryption'
@@ -44,25 +44,48 @@ async function deriveKeyFromPrfSeed(prfSeed) {
44
44
  return new Uint8Array(bits);
45
45
  }
46
46
 
47
- function getPrfSeed(credential, rawCredentialId) {
48
- if (credential) {
49
- try {
50
- const extensions = credential.getClientExtensionResults();
51
- const prfResults = extensions?.prf;
52
- if (prfResults?.results?.first) {
53
- log('Using WebAuthn PRF extension for key derivation');
54
- return {
55
- seed: new Uint8Array(prfResults.results.first),
56
- source: KEYSTORE_ENCRYPTION_METHODS.PRF,
57
- };
58
- }
59
- } catch (error) {
60
- log('Error reading PRF extension results: %s', error.message);
47
+ /**
48
+ * The PRF output of an assertion, or null when there is none.
49
+ *
50
+ * This used to fall back to the raw credential id, silently, so a key
51
+ * "wrapped with PRF" could in fact be wrapped with a value that sits in
52
+ * localStorage in clear. Without PRF there is no secret to derive from.
53
+ *
54
+ * @param {PublicKeyCredential} assertion
55
+ * @returns {{seed: Uint8Array, source: 'prf'}|null}
56
+ */
57
+ function getPrfSeed(assertion) {
58
+ if (!assertion) return null;
59
+ try {
60
+ const prfResults = assertion.getClientExtensionResults()?.prf;
61
+ if (prfResults?.results?.first) {
62
+ log('Using WebAuthn PRF extension for key derivation');
63
+ return {
64
+ seed: new Uint8Array(prfResults.results.first),
65
+ source: KEYSTORE_ENCRYPTION_METHODS.PRF,
66
+ };
61
67
  }
68
+ } catch (error) {
69
+ log('Error reading PRF extension results: %s', error.message);
62
70
  }
71
+ log('PRF extension not available');
72
+ return null;
73
+ }
63
74
 
64
- log('PRF extension not available, using rawCredentialId for key derivation');
65
- return { seed: rawCredentialId, source: 'credentialId' };
75
+ /**
76
+ * @param {PublicKeyCredential} assertion
77
+ * @param {string} verb - For the message: what could not be done.
78
+ * @returns {{seed: Uint8Array, source: 'prf'}}
79
+ * @throws {PrfUnavailableError}
80
+ */
81
+ function getPrfSeedOrThrow(assertion, verb) {
82
+ const prf = getPrfSeed(assertion);
83
+ if (!prf) {
84
+ throw new PrfUnavailableError(
85
+ `the authenticator returned no PRF output, so the key cannot be ${verb}`
86
+ );
87
+ }
88
+ return prf;
66
89
  }
67
90
 
68
91
  /**
@@ -423,7 +446,7 @@ export async function wrapSKWithPRF(credentialId, sk, rpId, prfInput) {
423
446
  })
424
447
  );
425
448
 
426
- const { seed, source } = getPrfSeed(assertion, credentialId);
449
+ const { seed, source } = getPrfSeedOrThrow(assertion, 'wrapped');
427
450
  const prfKey = await deriveKeyFromPrfSeed(seed);
428
451
  const wrapped = await encryptWithAESGCM(sk, prfKey);
429
452
 
@@ -436,6 +459,7 @@ export async function wrapSKWithPRF(credentialId, sk, rpId, prfInput) {
436
459
  prfSource: source,
437
460
  };
438
461
  } catch (error) {
462
+ if (error instanceof PrfUnavailableError) throw error;
439
463
  log.error('Failed to wrap secret key with PRF: %s', error.message);
440
464
  throw new KeystoreEncryptionError(
441
465
  `Failed to wrap secret key: ${error.message}`,
@@ -544,13 +568,14 @@ export async function unwrapSKWithPRF(
544
568
  })
545
569
  );
546
570
 
547
- const { seed, source } = getPrfSeed(assertion, credentialId);
571
+ const { seed, source } = getPrfSeedOrThrow(assertion, 'unwrapped');
548
572
  const prfKey = await deriveKeyFromPrfSeed(seed);
549
573
  const sk = await decryptWithAESGCM(wrappedSK, prfKey, wrappingIV);
550
574
 
551
575
  log('Secret key unwrapped with PRF (%s)', source);
552
576
  return sk;
553
577
  } catch (error) {
578
+ if (error instanceof PrfUnavailableError) throw error;
554
579
  log.error('Failed to unwrap secret key with PRF: %s', error.message);
555
580
  throw new KeystoreEncryptionError(
556
581
  `Failed to unwrap secret key: ${error.message}`,
@@ -4,3 +4,5 @@
4
4
  export * from './encryption.js';
5
5
  export { default } from './encryption.js';
6
6
  export * from './provider.js';
7
+ export * from './session-keystore.js';
8
+ export { PrfUnavailableError } from '../errors.js';