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