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