@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 +136 -0
- package/README.md +66 -21
- package/SECURITY.md +53 -16
- package/docs/API.md +1 -1
- package/package.json +1 -1
- package/src/errors.js +14 -0
- package/src/index.js +7 -0
- package/src/keystore/encryption.js +44 -19
- package/src/keystore/index.js +2 -0
- package/src/keystore/provider.js +88 -4
- package/src/keystore/session-keystore.js +79 -0
- package/src/standalone/index.js +1 -0
- package/src/standalone/webauthn/credential.js +29 -27
- package/src/standalone/worker/client.js +38 -0
- package/src/standalone/worker/ed25519-keystore.worker.js +65 -6
- package/src/varsig/assertion.js +8 -21
- package/src/varsig/credential.js +6 -0
- package/src/varsig/identity.js +9 -3
- package/src/webauthn/large-blob-metadata.js +9 -1
- package/src/webauthn/proof-verification.js +7 -2
- package/src/webauthn/provider.js +11 -0
- package/types/keystore.d.ts +31 -0
- package/types/standalone.d.ts +24 -2
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**:
|
|
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
|
-
**
|
|
31
|
+
**Which option, and what each one actually gives you:**
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
- `examples/ed25519-keystore-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
5
|
+
Security fixes are provided for the current `0.5.x` release line.
|
|
6
6
|
|
|
7
7
|
| Version | Supported |
|
|
8
8
|
| ------- | --------- |
|
|
9
|
-
| 0.
|
|
10
|
-
| < 0.
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
72
|
+
Two things decide whether that means anything at rest:
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
52
|
-
|
|
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
|
|
74
|
-
|
|
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
|
+
"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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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 } =
|
|
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 } =
|
|
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}`,
|