@le-space/orbitdb-identity-provider-webauthn-did 0.5.4 → 0.7.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 +96 -1
- package/README.md +70 -39
- package/package.json +1 -1
- package/src/index.js +17 -0
- package/src/keystore/derived-signing-key.js +9 -7
- package/src/webauthn/prf-input.js +46 -0
- package/src/webauthn/provider.js +46 -40
- package/src/webauthn/recovery.js +242 -0
- package/src/webauthn/restore.js +132 -0
- package/types/index.d.ts +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,101 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
**One breaking change, in the function 0.6.0 added.** `restoreIdentityFromAuthenticator()`
|
|
6
|
+
returned the credential id as bytes, under `credentialId` — the name the rest of this package
|
|
7
|
+
uses for the base64url text. An application that passed the bytes on as `rawCredentialId`, and
|
|
8
|
+
so gave the provider no text, got a third touch of the key and then an exception: `WebAuthn
|
|
9
|
+
signing error: Cannot read properties of undefined (reading 'substring')`. That happened on two
|
|
10
|
+
phones on 21 September, in funkpost's recovery page.
|
|
11
|
+
|
|
12
|
+
- `credentialId` is now the base64url **text** and `rawCredentialId` the **bytes**, as
|
|
13
|
+
`WebAuthnCredentialInfo` has always typed them.
|
|
14
|
+
- New: `credential`, the credential the provider takes as it is —
|
|
15
|
+
`OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential: restored.credential })`.
|
|
16
|
+
- The provider derives either form of the id from the other, so a credential that brings only
|
|
17
|
+
one of them signs instead of failing after the touch.
|
|
18
|
+
- `sign()` no longer builds a debug line from a field that may be missing.
|
|
19
|
+
|
|
20
|
+
**Migrating from 0.6.0:** read the bytes from `restored.rawCredentialId` instead of
|
|
21
|
+
`restored.credentialId`, or hand `restored.credential` to the provider.
|
|
22
|
+
|
|
23
|
+
**Tested now:** the step after the restore — giving the identity to OrbitDB and signing with it
|
|
24
|
+
— which no test reached before. Both new tests fail on 0.6.0, one of them with the exception
|
|
25
|
+
above, word for word.
|
|
26
|
+
|
|
27
|
+
## 0.6.0
|
|
28
|
+
|
|
29
|
+
An identity can be restored on a second device with **nothing stored**: two
|
|
30
|
+
touches of the same passkey give back the DID and the signing key, so
|
|
31
|
+
largeBlob and `localStorage` become a convenience rather than the only road.
|
|
32
|
+
Measured on two phones with one YubiKey before it was written (#61).
|
|
33
|
+
|
|
34
|
+
Nothing existing moves: a credential registered before this keeps the PRF
|
|
35
|
+
input it was made with, and the identity it derives is unchanged. Credentials
|
|
36
|
+
registered from here on are asked the input every device can compute, which is
|
|
37
|
+
what makes the restore work. Two behaviours do change — `getPrfOutput()` now
|
|
38
|
+
falls back to that derivable input instead of returning `null` when a
|
|
39
|
+
credential has none stored, and `createDID()` throws instead of inventing a
|
|
40
|
+
"base58-like" identifier when it cannot encode a key.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **An identity can be restored on a device that has stored nothing** (#61).
|
|
45
|
+
`restoreIdentityFromAuthenticator()` touches the same passkey twice: once
|
|
46
|
+
with user verification and the PRF input every device can compute, once for a
|
|
47
|
+
second signature. Two ECDSA signatures admit exactly one common public key —
|
|
48
|
+
which an assertion does not carry, and which is why the identity metadata has
|
|
49
|
+
had to live in largeBlob or `localStorage` until now. From it: the DID, and
|
|
50
|
+
the signing key derived from the PRF output with the DID mixed in. The pieces
|
|
51
|
+
are exported on their own as well: `prfInputForRelyingParty()`,
|
|
52
|
+
`recoverPublicKey()` and `recoverPublicKeyCandidates()`.
|
|
53
|
+
|
|
54
|
+
Measured on hardware before it was written — one YubiKey, a Galaxy Fold 5 and
|
|
55
|
+
a Galaxy A57, 2026-09-19: the same PRF value on both phones, the same DID
|
|
56
|
+
recovered from two signatures, the same derived signing key. What of that is
|
|
57
|
+
machine-checkable runs in `tests/webauthn-recovery.test.js`, against a
|
|
58
|
+
software authenticator with a real P-256 key.
|
|
59
|
+
|
|
60
|
+
Nothing stands in for a missing PRF: an authenticator without it gets a
|
|
61
|
+
refusal, because an identity derived from something else is a different
|
|
62
|
+
identity that looks like success.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- **The PRF input is now fixed per relying party** instead of drawn at random
|
|
67
|
+
per credential (#61). It was stored with the credential metadata, so the
|
|
68
|
+
derived signing key — and with it the identity — was reachable only from the
|
|
69
|
+
device that kept that metadata; a second device asking the same authenticator
|
|
70
|
+
a different question got a different answer. The input is not a secret, the
|
|
71
|
+
output is. Credentials registered earlier keep working: their stored input is
|
|
72
|
+
still used when there is one, and `getPrfOutput()` now falls back to the
|
|
73
|
+
derivable input instead of giving up when there is none.
|
|
74
|
+
- **`createDID` refuses a key it cannot encode.** Its `catch` built a
|
|
75
|
+
"base58-like" identifier out of the same coordinates: a string that looked
|
|
76
|
+
like a DID, was not `did:key`, and could not be reproduced by anything else
|
|
77
|
+
reading that key — including this library on another device. It throws now.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- CI now runs all 22 test files. Eight never ran there: `identity-forgery`,
|
|
82
|
+
`p256-signature-shapes`, `session-keystore`, `webauthn-debug-log`,
|
|
83
|
+
`webauthn-extension-support`, `webauthn-user-handle`,
|
|
84
|
+
`webauthn-varsig-challenge` and `worker-signer`. They were listed in
|
|
85
|
+
`playwright.node.config.js`, which only the local `preversion` and
|
|
86
|
+
`prepublishOnly` hooks ran, while `release.yml` publishes with
|
|
87
|
+
`--ignore-scripts` because CI is its gate — so no automated gate ran them
|
|
88
|
+
before a release. CI now runs that config in one step, before installing
|
|
89
|
+
browsers; `standalone-toolkit`, `webauthn-attestation-parsing` and
|
|
90
|
+
`webauthn-two-peer-replication` lose their separate steps, which booted a
|
|
91
|
+
demo server they do not use.
|
|
92
|
+
- `identity-forgery` did not load in a clean install. It imported `concat`
|
|
93
|
+
from `uint8arrays`, which this package does not declare; only a hoisted
|
|
94
|
+
`node_modules` (`node-linker=hoisted`) made it resolvable. It now takes
|
|
95
|
+
`concat` from `iso-base/utils`, a declared dependency.
|
|
96
|
+
- `scripts/check-ci-runs-every-test.mjs` fails CI when a file in `tests/` is
|
|
97
|
+
selected by no step. This is the second time suites went unrun: 0.4.0 found
|
|
98
|
+
five.
|
|
4
99
|
|
|
5
100
|
## 0.5.4
|
|
6
101
|
|
package/README.md
CHANGED
|
@@ -188,48 +188,71 @@ sequenceDiagram
|
|
|
188
188
|
participant App
|
|
189
189
|
participant WebAuthn
|
|
190
190
|
participant Auth as Authenticator
|
|
191
|
-
participant
|
|
192
|
-
participant
|
|
191
|
+
participant Prov as Keystore-DID provider
|
|
192
|
+
participant LS as localStorage
|
|
193
|
+
participant KS as OrbitDB keystore
|
|
193
194
|
participant DB as OrbitDB
|
|
194
195
|
|
|
195
196
|
User->>App: Create credential
|
|
196
|
-
App->>WebAuthn: create()
|
|
197
|
+
App->>WebAuthn: create() with PRF and largeBlob requested
|
|
197
198
|
WebAuthn->>Auth: Create passkey
|
|
198
199
|
Auth-->>WebAuthn: Attestation
|
|
199
200
|
WebAuthn-->>App: Credential
|
|
200
201
|
|
|
201
|
-
App->>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
alt prf
|
|
209
|
-
App->>WebAuthn: get() with PRF
|
|
202
|
+
App->>Prov: createIdentity({ useKeystoreDID, encryptKeystore })
|
|
203
|
+
alt encryptKeystore, first session
|
|
204
|
+
Prov->>Prov: generateSecretKey() gives sk
|
|
205
|
+
Prov->>Prov: generateKeyPair() gives a random Ed25519 key
|
|
206
|
+
Prov->>Prov: AES-GCM encrypt the private key with sk
|
|
207
|
+
alt prf (the default)
|
|
208
|
+
Prov->>WebAuthn: get() with PRF
|
|
210
209
|
WebAuthn->>Auth: User verification
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
210
|
+
alt PRF output
|
|
211
|
+
Auth-->>Prov: PRF output
|
|
212
|
+
Prov->>Prov: wrap sk with a key derived from it
|
|
213
|
+
Prov->>LS: sealed key, wrapped sk, PRF input
|
|
214
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
215
|
+
else no PRF
|
|
216
|
+
Auth-->>Prov: no PRF result
|
|
217
|
+
Note right of Prov: Nothing is sealed. encryptionState is { enabled false, reason prf-unavailable }
|
|
218
|
+
Prov->>KS: getKey(), or add a generated key
|
|
219
|
+
end
|
|
214
220
|
else largeBlob
|
|
215
|
-
|
|
221
|
+
Prov->>WebAuthn: get() with largeBlob write
|
|
216
222
|
WebAuthn->>Auth: User verification
|
|
217
|
-
|
|
218
|
-
|
|
223
|
+
alt written
|
|
224
|
+
Auth-->>Prov: written is true
|
|
225
|
+
Prov->>LS: sealed key, sk kept on the authenticator
|
|
226
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
227
|
+
else not written
|
|
228
|
+
Auth-->>Prov: written is false
|
|
229
|
+
Note right of Prov: Throws. Nothing is stored.
|
|
230
|
+
end
|
|
219
231
|
else hmac-secret
|
|
220
|
-
|
|
232
|
+
Prov->>WebAuthn: get() with hmac-secret
|
|
221
233
|
WebAuthn->>Auth: User verification
|
|
222
|
-
Auth-->>
|
|
223
|
-
|
|
224
|
-
|
|
234
|
+
Auth-->>Prov: HMAC output
|
|
235
|
+
Prov->>Prov: wrap sk with it
|
|
236
|
+
Prov->>LS: sealed key, wrapped sk
|
|
237
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
225
238
|
end
|
|
239
|
+
else encryptKeystore with prf, later session
|
|
240
|
+
Prov->>LS: load the sealed key
|
|
241
|
+
Prov->>WebAuthn: get() with PRF, the stored input
|
|
242
|
+
Auth-->>Prov: PRF output
|
|
243
|
+
Prov->>Prov: unwrap sk, then decrypt the key
|
|
244
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
245
|
+
else not encrypted
|
|
246
|
+
Prov->>KS: getKey(), or add a generated key
|
|
226
247
|
end
|
|
248
|
+
Prov-->>App: Identity, DID = did:key of the keystore key
|
|
227
249
|
|
|
250
|
+
User->>App: Add entry
|
|
228
251
|
App->>DB: db.put()
|
|
229
|
-
DB->>KS: sign entry with keystore key
|
|
252
|
+
DB->>KS: sign entry with the keystore key
|
|
230
253
|
KS-->>DB: Entry signature
|
|
231
254
|
|
|
232
|
-
Note over App,KS:
|
|
255
|
+
Note over App,KS: The sealed copy stays at rest. The unlocked key lives in the OrbitDB keystore, so create it with createSessionKeystore() to keep it in memory.
|
|
233
256
|
```
|
|
234
257
|
|
|
235
258
|
OrbitDB signs with whatever its keystore returns, and the default keystore
|
|
@@ -274,24 +297,28 @@ sequenceDiagram
|
|
|
274
297
|
participant DB as OrbitDB
|
|
275
298
|
|
|
276
299
|
User->>App: Create credential
|
|
277
|
-
App->>WebAuthn: create()
|
|
300
|
+
App->>WebAuthn: create() with largeBlob requested
|
|
278
301
|
WebAuthn->>Auth: Create passkey
|
|
279
302
|
Auth-->>WebAuthn: Attestation
|
|
280
303
|
WebAuthn-->>App: Credential
|
|
281
304
|
|
|
282
305
|
User->>App: Create varsig identity
|
|
283
|
-
App->>Var:
|
|
284
|
-
Var->>
|
|
306
|
+
App->>Var: createWebAuthnVarsigIdentity({ credential })
|
|
307
|
+
Var->>Var: id = did:key of the passkey's public key
|
|
308
|
+
Var->>WebAuthn: get() over the id
|
|
285
309
|
WebAuthn->>Auth: User verification
|
|
286
310
|
Auth-->>WebAuthn: Assertion
|
|
287
|
-
WebAuthn-->>Var: Assertion
|
|
288
|
-
Var->>
|
|
311
|
+
WebAuthn-->>Var: Assertion, becomes signatures.id
|
|
312
|
+
Var->>WebAuthn: get() over publicKey + idSignature
|
|
313
|
+
WebAuthn->>Auth: User verification
|
|
314
|
+
Auth-->>WebAuthn: Assertion
|
|
315
|
+
WebAuthn-->>Var: Assertion, becomes signatures.publicKey
|
|
289
316
|
Var-->>App: Identity
|
|
290
317
|
|
|
291
318
|
User->>App: Add entry
|
|
292
319
|
App->>DB: db.put()
|
|
293
|
-
DB->>Var:
|
|
294
|
-
Var->>WebAuthn: get()
|
|
320
|
+
DB->>Var: sign(entry)
|
|
321
|
+
Var->>WebAuthn: get() over the entry
|
|
295
322
|
WebAuthn->>Auth: User verification
|
|
296
323
|
Auth-->>WebAuthn: Assertion
|
|
297
324
|
WebAuthn-->>Var: Assertion
|
|
@@ -359,18 +386,22 @@ If you change anything that touches signing, identity documents or verification,
|
|
|
359
386
|
|
|
360
387
|
## Identity Recovery Summary
|
|
361
388
|
|
|
362
|
-
|
|
389
|
+
Since **0.6.0** an identity can be restored on a device that has stored nothing:
|
|
363
390
|
|
|
364
|
-
- Discoverable passkeys are the default.
|
|
365
|
-
-
|
|
366
|
-
-
|
|
367
|
-
-
|
|
368
|
-
|
|
369
|
-
|
|
391
|
+
- Discoverable passkeys are the default, so a device that knows no credential ID can still authenticate.
|
|
392
|
+
- A single assertion does not expose the credential's public key — but **two do**. An ECDSA signature admits exactly two public keys, and only the signer's is in both sets, so `recoverPublicKey()` returns it and the DID follows. Every candidate is checked against the signature it came from, so the recovery cannot invent a key.
|
|
393
|
+
- 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
|
+
- `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
|
+
|
|
396
|
+
Metadata persistence is therefore a **convenience, not a requirement**. The demos still try `largeBlob` first and fall back to browser storage, which saves a touch of the key; neither is needed to reconstruct the identity.
|
|
370
397
|
|
|
371
398
|
Practical implication:
|
|
372
399
|
|
|
373
|
-
-
|
|
400
|
+
- 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.
|
|
401
|
+
- 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
|
+
- Credentials registered before 0.6.0 keep the random PRF input stored with them, so their identities do not move.
|
|
403
|
+
|
|
404
|
+
Getting the _database_ back is a separate problem, and the two halves together are written up in [Getting a database back on a device that has nothing](https://github.com/NiKrause/orbitdb-storage-bridge/blob/main/docs/RECOVERY-ON-A-SECOND-DEVICE.md).
|
|
374
405
|
|
|
375
406
|
## Upstream Packages and Temporary Forks
|
|
376
407
|
|
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.7.0",
|
|
4
4
|
"description": "WebAuthn-based DID identity provider for OrbitDB for hardware-secured wallets and biometric Passkey authentication",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
package/src/index.js
CHANGED
|
@@ -297,6 +297,23 @@ export {
|
|
|
297
297
|
isSessionKeystore,
|
|
298
298
|
} from './keystore/session-keystore.js';
|
|
299
299
|
export { PrfUnavailableError } from './errors.js';
|
|
300
|
+
// Restoring on a device that has stored nothing (#61): the fixed PRF input,
|
|
301
|
+
// the public key recovered from two assertions, and the two together.
|
|
302
|
+
export {
|
|
303
|
+
PRF_INPUT_INFO,
|
|
304
|
+
prfInputForRelyingParty,
|
|
305
|
+
} from './webauthn/prf-input.js';
|
|
306
|
+
export {
|
|
307
|
+
recoverPublicKey,
|
|
308
|
+
recoverPublicKeyCandidates,
|
|
309
|
+
} from './webauthn/recovery.js';
|
|
310
|
+
export { restoreIdentityFromAuthenticator } from './webauthn/restore.js';
|
|
311
|
+
// The two halves of it, for a caller that already holds one: a PRF output, or
|
|
312
|
+
// a credential it wants the output for.
|
|
313
|
+
export {
|
|
314
|
+
deriveSigningKeyBytes,
|
|
315
|
+
getPrfOutput,
|
|
316
|
+
} from './keystore/derived-signing-key.js';
|
|
300
317
|
|
|
301
318
|
export default {
|
|
302
319
|
WebAuthnDIDProvider,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
|
|
36
36
|
import { CRYPTO_ALGORITHMS, KEY_TYPES } from '../constants.js';
|
|
37
37
|
import { buildCredentialRequestOptions } from '../webauthn/config.js';
|
|
38
|
+
import { prfInputForRelyingParty } from '../webauthn/prf-input.js';
|
|
38
39
|
|
|
39
40
|
const log = logger('orbitdb-identity-provider-webauthn-did:derived-key');
|
|
40
41
|
|
|
@@ -126,12 +127,13 @@ export async function deriveSigningKeyBytes(
|
|
|
126
127
|
* @returns {Promise<Uint8Array|null>} PRF output, or null.
|
|
127
128
|
*/
|
|
128
129
|
export async function getPrfOutput(credential, { rpId } = {}) {
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
130
|
+
// A credential registered before #61 carries the random input it was made
|
|
131
|
+
// with, and that input is the only one its authenticator answers the same
|
|
132
|
+
// way twice. Without one, fall back to the input every device can compute
|
|
133
|
+
// (#61): a credential registered since then was asked exactly that, so a
|
|
134
|
+
// second device needs nothing from the first.
|
|
135
|
+
const prfInput =
|
|
136
|
+
credential?.prfInput ?? (await prfInputForRelyingParty(rpId));
|
|
135
137
|
|
|
136
138
|
if (typeof navigator === 'undefined' || !navigator.credentials?.get) {
|
|
137
139
|
log('no WebAuthn available; cannot derive a key');
|
|
@@ -153,7 +155,7 @@ export async function getPrfOutput(credential, { rpId } = {}) {
|
|
|
153
155
|
credentialId: rawCredentialId,
|
|
154
156
|
rpId: rpId ?? globalThis.window?.location?.hostname,
|
|
155
157
|
userVerification: 'required',
|
|
156
|
-
extensions: { prf: { eval: { first:
|
|
158
|
+
extensions: { prf: { eval: { first: prfInput } } },
|
|
157
159
|
})
|
|
158
160
|
);
|
|
159
161
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module PrfInput
|
|
3
|
+
* @description
|
|
4
|
+
* The PRF input an authenticator is asked to evaluate.
|
|
5
|
+
*
|
|
6
|
+
* It was drawn at random per credential and stored with the credential's
|
|
7
|
+
* metadata, which makes the derived signing key — and with it the identity —
|
|
8
|
+
* reachable only from the device that kept that metadata. A second device
|
|
9
|
+
* holding the same passkey asks the same authenticator and gets a different
|
|
10
|
+
* answer, because it asks a different question.
|
|
11
|
+
*
|
|
12
|
+
* Fixing the input per relying party removes the question: both devices ask
|
|
13
|
+
* the same thing, and the authenticator answers the same thing. The input is
|
|
14
|
+
* not a secret — the *output* is, and it never leaves the authenticator's
|
|
15
|
+
* answer — so nothing is lost by making it derivable. See issue #61.
|
|
16
|
+
*/
|
|
17
|
+
import { CRYPTO_ALGORITHMS } from '../constants.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bumping this rotates every PRF output, and with it every derived signing
|
|
21
|
+
* key, so treat it as a breaking change.
|
|
22
|
+
*/
|
|
23
|
+
export const PRF_INPUT_INFO = 'orbitdb-identity-provider-webauthn-did:prf:v2';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The fixed PRF input for a relying party.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} rpId - Relying party id, e.g. `example.org`. The current
|
|
29
|
+
* host is used when it is omitted and there is one.
|
|
30
|
+
* @returns {Promise<Uint8Array>} 32 bytes, the same on every device.
|
|
31
|
+
*/
|
|
32
|
+
export async function prfInputForRelyingParty(rpId) {
|
|
33
|
+
const party =
|
|
34
|
+
rpId ??
|
|
35
|
+
(typeof globalThis.location === 'object'
|
|
36
|
+
? globalThis.location.hostname
|
|
37
|
+
: undefined);
|
|
38
|
+
if (typeof party !== 'string' || party.length === 0) {
|
|
39
|
+
throw new TypeError('prfInputForRelyingParty needs a relying party id');
|
|
40
|
+
}
|
|
41
|
+
const digest = await crypto.subtle.digest(
|
|
42
|
+
CRYPTO_ALGORITHMS.SHA_256,
|
|
43
|
+
new TextEncoder().encode(`${PRF_INPUT_INFO}:${party}`)
|
|
44
|
+
);
|
|
45
|
+
return new Uint8Array(digest);
|
|
46
|
+
}
|
package/src/webauthn/provider.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
KEYSTORE_ENCRYPTION_METHODS,
|
|
17
17
|
} from '../constants.js';
|
|
18
18
|
import {
|
|
19
|
+
ERROR_CODES,
|
|
19
20
|
WebAuthnIdentityError,
|
|
20
21
|
WebAuthnAuthenticationError,
|
|
21
22
|
WebAuthnCredentialError,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
buildCredentialRequestOptions,
|
|
27
28
|
} from './config.js';
|
|
28
29
|
import { logWebAuthnResponse } from './debug-log.js';
|
|
30
|
+
import { prfInputForRelyingParty } from './prf-input.js';
|
|
29
31
|
import {
|
|
30
32
|
p256PublicKeyBytes,
|
|
31
33
|
verifyWebAuthnProof,
|
|
@@ -44,9 +46,28 @@ export class WebAuthnDIDProvider {
|
|
|
44
46
|
* @param {Uint8Array} credentialInfo.rawCredentialId - Raw credential ID bytes.
|
|
45
47
|
*/
|
|
46
48
|
constructor(credentialInfo) {
|
|
47
|
-
|
|
49
|
+
// The id twice, as WebAuthnCredentialInfo types it: text for the signature
|
|
50
|
+
// envelope, bytes for the authenticator. Either gives the other, so a
|
|
51
|
+
// credential that brings only its bytes (as 0.6.0's restore handed them
|
|
52
|
+
// out, under the text's name) still signs, instead of getting through the
|
|
53
|
+
// touch and then failing.
|
|
54
|
+
const { credentialId, rawCredentialId } = credentialInfo;
|
|
55
|
+
this.rawCredentialId =
|
|
56
|
+
rawCredentialId ??
|
|
57
|
+
(typeof credentialId === 'string'
|
|
58
|
+
? new Uint8Array(
|
|
59
|
+
WebAuthnDIDProvider.base64urlToArrayBuffer(credentialId)
|
|
60
|
+
)
|
|
61
|
+
: credentialId
|
|
62
|
+
? new Uint8Array(credentialId)
|
|
63
|
+
: undefined);
|
|
64
|
+
this.credentialId =
|
|
65
|
+
typeof credentialId === 'string'
|
|
66
|
+
? credentialId
|
|
67
|
+
: this.rawCredentialId
|
|
68
|
+
? WebAuthnDIDProvider.arrayBufferToBase64url(this.rawCredentialId)
|
|
69
|
+
: undefined;
|
|
48
70
|
this.publicKey = credentialInfo.publicKey;
|
|
49
|
-
this.rawCredentialId = credentialInfo.rawCredentialId;
|
|
50
71
|
this.type = IDENTITY_TYPES.WEBAUTHN;
|
|
51
72
|
}
|
|
52
73
|
|
|
@@ -173,7 +194,8 @@ export class WebAuthnDIDProvider {
|
|
|
173
194
|
|
|
174
195
|
if (keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.PRF) {
|
|
175
196
|
const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
|
|
176
|
-
credentialOptions.publicKey
|
|
197
|
+
credentialOptions.publicKey,
|
|
198
|
+
await prfInputForRelyingParty(domain)
|
|
177
199
|
);
|
|
178
200
|
credentialOptions.publicKey = prfConfig.credentialOptions;
|
|
179
201
|
prfInput = prfConfig.prfInput;
|
|
@@ -199,14 +221,19 @@ export class WebAuthnDIDProvider {
|
|
|
199
221
|
};
|
|
200
222
|
}
|
|
201
223
|
|
|
202
|
-
// Request PRF even when the keystore is not encrypted
|
|
203
|
-
//
|
|
204
|
-
//
|
|
224
|
+
// Request PRF even when the keystore is not encrypted: it is what lets the
|
|
225
|
+
// OrbitDB signing key be derived reproducibly later, so a credential
|
|
226
|
+
// registered without it can never get a reproducible identity. The input
|
|
227
|
+
// is fixed per relying party rather than drawn at random (#61), so a
|
|
228
|
+
// second device asks the authenticator the same question and gets the same
|
|
229
|
+
// answer — without that, "the same passkey" still means a different key on
|
|
230
|
+
// every device.
|
|
205
231
|
// Requesting the extension is harmless where it is unsupported: the
|
|
206
232
|
// authenticator simply returns no PRF results.
|
|
207
233
|
if (!prfInput) {
|
|
208
234
|
const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
|
|
209
|
-
credentialOptions.publicKey
|
|
235
|
+
credentialOptions.publicKey,
|
|
236
|
+
await prfInputForRelyingParty(domain)
|
|
210
237
|
);
|
|
211
238
|
credentialOptions.publicKey = prfConfig.credentialOptions;
|
|
212
239
|
prfInput = prfConfig.prfInput;
|
|
@@ -581,38 +608,16 @@ export class WebAuthnDIDProvider {
|
|
|
581
608
|
const multikeyEncoded = base58btc.encode(multikey);
|
|
582
609
|
return `${DID_KEY_PREFIX}${multikeyEncoded}`;
|
|
583
610
|
} catch (error) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
611
|
+
// No fallback encoding. What stood here invented a "base58-like"
|
|
612
|
+
// identifier out of the same coordinates — a string that looks like a
|
|
613
|
+
// DID, is not `did:key`, and cannot be reproduced by anything else that
|
|
614
|
+
// reads this key, including this library on another device. An identity
|
|
615
|
+
// nobody else computes the same way is worse than no identity, and it
|
|
616
|
+
// fails silently, at registration, where nothing checks it again.
|
|
617
|
+
throw new WebAuthnIdentityError(
|
|
618
|
+
`could not encode the public key as a did:key: ${error.message}`,
|
|
619
|
+
{ code: ERROR_CODES.INVALID_INPUT, cause: error }
|
|
587
620
|
);
|
|
588
|
-
|
|
589
|
-
// Fallback: Simple DID creation without multiformats dependency
|
|
590
|
-
const { x, y } = credentialInfo.publicKey;
|
|
591
|
-
|
|
592
|
-
// Create a hash-based approach for consistency
|
|
593
|
-
const combined = new Uint8Array(x.length + y.length);
|
|
594
|
-
combined.set(x, 0);
|
|
595
|
-
combined.set(y, x.length);
|
|
596
|
-
|
|
597
|
-
// Simple base58-like encoding for fallback
|
|
598
|
-
const base58Chars =
|
|
599
|
-
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
600
|
-
let encoded = 'z'; // base58btc prefix
|
|
601
|
-
|
|
602
|
-
for (let i = 0; i < Math.min(combined.length, 32); i += 4) {
|
|
603
|
-
const chunk = combined.slice(i, i + 4);
|
|
604
|
-
let value = 0;
|
|
605
|
-
for (let j = 0; j < chunk.length; j++) {
|
|
606
|
-
value = value * 256 + chunk[j];
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
for (let k = 0; k < 6; k++) {
|
|
610
|
-
encoded += base58Chars[value % 58];
|
|
611
|
-
value = Math.floor(value / 58);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
return `${DID_KEY_PREFIX}${encoded}`;
|
|
616
621
|
}
|
|
617
622
|
}
|
|
618
623
|
|
|
@@ -726,8 +731,9 @@ export class WebAuthnDIDProvider {
|
|
|
726
731
|
};
|
|
727
732
|
|
|
728
733
|
webauthnLog('Proof created successfully: %o', {
|
|
729
|
-
|
|
730
|
-
|
|
734
|
+
// A debug line must not be what fails a signature.
|
|
735
|
+
credentialId: `${String(webauthnProof.credentialId).slice(0, 16)}...`,
|
|
736
|
+
dataHash: `${String(webauthnProof.dataHash).slice(0, 16)}...`,
|
|
731
737
|
});
|
|
732
738
|
|
|
733
739
|
// Return the proof as a base64url encoded string for OrbitDB
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Recovery
|
|
3
|
+
* @description
|
|
4
|
+
* Get the credential's public key back from assertions, so a device that has
|
|
5
|
+
* stored nothing can still know which identity it holds.
|
|
6
|
+
*
|
|
7
|
+
* A WebAuthn assertion does not carry the public key; only registration does.
|
|
8
|
+
* That is why the identity metadata has had to be kept somewhere — largeBlob,
|
|
9
|
+
* localStorage, a proof store — and why a fresh device with the same
|
|
10
|
+
* authenticator could not name the identity it was holding.
|
|
11
|
+
*
|
|
12
|
+
* It does not have to be kept. An ECDSA signature admits exactly two public
|
|
13
|
+
* keys, and both are computable from the signature itself; a second signature
|
|
14
|
+
* over a different challenge admits two more, and only the signer's key is in
|
|
15
|
+
* both sets. So two touches of the same passkey are enough to recover the key,
|
|
16
|
+
* and from it the DID. See issue #61.
|
|
17
|
+
*
|
|
18
|
+
* Every candidate this module returns has been checked against the signature
|
|
19
|
+
* it came from with `crypto.subtle.verify`. A mistake in the arithmetic below
|
|
20
|
+
* can therefore cost a candidate, never invent one: what comes out has
|
|
21
|
+
* verified, or it does not come out.
|
|
22
|
+
*/
|
|
23
|
+
import { derToRawSignature } from 'iso-webauthn-varsig';
|
|
24
|
+
|
|
25
|
+
import { ERROR_CODES, WebAuthnIdentityError } from '../errors.js';
|
|
26
|
+
import { CRYPTO_ALGORITHMS } from '../constants.js';
|
|
27
|
+
|
|
28
|
+
// NIST P-256 (secp256r1). a = p - 3.
|
|
29
|
+
const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
|
|
30
|
+
const N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
|
|
31
|
+
const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
|
|
32
|
+
const G = {
|
|
33
|
+
x: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n,
|
|
34
|
+
y: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n,
|
|
35
|
+
};
|
|
36
|
+
const P1363_SIGNATURE_LENGTH = 64;
|
|
37
|
+
const COORDINATE_BYTES = 32;
|
|
38
|
+
|
|
39
|
+
const mod = (value, modulus = P) => ((value % modulus) + modulus) % modulus;
|
|
40
|
+
|
|
41
|
+
/** Extended Euclid; `value` must not be a multiple of `modulus`. */
|
|
42
|
+
function inverse(value, modulus) {
|
|
43
|
+
let [old, current] = [mod(value, modulus), modulus];
|
|
44
|
+
let [oldCoefficient, coefficient] = [1n, 0n];
|
|
45
|
+
while (current !== 0n) {
|
|
46
|
+
const quotient = old / current;
|
|
47
|
+
[old, current] = [current, old - quotient * current];
|
|
48
|
+
[oldCoefficient, coefficient] = [
|
|
49
|
+
coefficient,
|
|
50
|
+
oldCoefficient - quotient * coefficient,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
if (old !== 1n) throw new Error('not invertible');
|
|
54
|
+
return mod(oldCoefficient, modulus);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function power(base, exponent, modulus) {
|
|
58
|
+
let result = 1n;
|
|
59
|
+
let factor = mod(base, modulus);
|
|
60
|
+
let left = exponent;
|
|
61
|
+
while (left > 0n) {
|
|
62
|
+
if (left & 1n) result = mod(result * factor, modulus);
|
|
63
|
+
factor = mod(factor * factor, modulus);
|
|
64
|
+
left >>= 1n;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** P ≡ 3 (mod 4), so a square root is one exponentiation. Null when there is none. */
|
|
70
|
+
function squareRoot(value) {
|
|
71
|
+
const root = power(value, (P + 1n) / 4n, P);
|
|
72
|
+
return mod(root * root, P) === mod(value, P) ? root : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Points are `{ x, y }`, or null for the point at infinity.
|
|
76
|
+
function add(left, right) {
|
|
77
|
+
if (left === null) return right;
|
|
78
|
+
if (right === null) return left;
|
|
79
|
+
if (left.x === right.x && mod(left.y + right.y) === 0n) return null;
|
|
80
|
+
const slope =
|
|
81
|
+
left.x === right.x && left.y === right.y
|
|
82
|
+
? mod((3n * left.x * left.x - 3n) * inverse(2n * left.y, P))
|
|
83
|
+
: mod((right.y - left.y) * inverse(right.x - left.x, P));
|
|
84
|
+
const x = mod(slope * slope - left.x - right.x);
|
|
85
|
+
return { x, y: mod(slope * (left.x - x) - left.y) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function multiply(point, scalar) {
|
|
89
|
+
let result = null;
|
|
90
|
+
let addend = point;
|
|
91
|
+
let left = mod(scalar, N);
|
|
92
|
+
while (left > 0n) {
|
|
93
|
+
if (left & 1n) result = add(result, addend);
|
|
94
|
+
addend = add(addend, addend);
|
|
95
|
+
left >>= 1n;
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const toBigInt = (bytes) =>
|
|
101
|
+
bytes.reduce((value, byte) => (value << 8n) + BigInt(byte), 0n);
|
|
102
|
+
|
|
103
|
+
function toBytes(value, length = COORDINATE_BYTES) {
|
|
104
|
+
const out = new Uint8Array(length);
|
|
105
|
+
let left = value;
|
|
106
|
+
for (let index = length - 1; index >= 0; index -= 1) {
|
|
107
|
+
out[index] = Number(left & 0xffn);
|
|
108
|
+
left >>= 8n;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const asBytes = (value) =>
|
|
114
|
+
value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
115
|
+
|
|
116
|
+
/** The bytes WebAuthn signs: authenticatorData ‖ SHA-256(clientDataJSON). */
|
|
117
|
+
async function signedBytesOf(authenticatorData, clientDataJSON) {
|
|
118
|
+
const hash = new Uint8Array(
|
|
119
|
+
await crypto.subtle.digest(CRYPTO_ALGORITHMS.SHA_256, clientDataJSON)
|
|
120
|
+
);
|
|
121
|
+
const out = new Uint8Array(authenticatorData.length + hash.length);
|
|
122
|
+
out.set(authenticatorData, 0);
|
|
123
|
+
out.set(hash, authenticatorData.length);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function verifies(point, signedBytes, rawSignature) {
|
|
128
|
+
const uncompressed = new Uint8Array(1 + COORDINATE_BYTES * 2);
|
|
129
|
+
uncompressed[0] = 0x04;
|
|
130
|
+
uncompressed.set(toBytes(point.x), 1);
|
|
131
|
+
uncompressed.set(toBytes(point.y), 1 + COORDINATE_BYTES);
|
|
132
|
+
try {
|
|
133
|
+
const key = await crypto.subtle.importKey(
|
|
134
|
+
'raw',
|
|
135
|
+
uncompressed,
|
|
136
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
137
|
+
false,
|
|
138
|
+
['verify']
|
|
139
|
+
);
|
|
140
|
+
return await crypto.subtle.verify(
|
|
141
|
+
{ name: 'ECDSA', hash: CRYPTO_ALGORITHMS.SHA_256 },
|
|
142
|
+
key,
|
|
143
|
+
rawSignature,
|
|
144
|
+
signedBytes
|
|
145
|
+
);
|
|
146
|
+
} catch {
|
|
147
|
+
return false; // not a point on the curve, or not a key at all
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An assertion, however the caller happens to hold it. */
|
|
152
|
+
function responseOf(assertion) {
|
|
153
|
+
const response = assertion?.response ?? assertion;
|
|
154
|
+
const { authenticatorData, clientDataJSON, signature } = response ?? {};
|
|
155
|
+
if (!authenticatorData || !clientDataJSON || !signature) {
|
|
156
|
+
throw new WebAuthnIdentityError(
|
|
157
|
+
'an assertion with authenticatorData, clientDataJSON and signature is required',
|
|
158
|
+
{ code: ERROR_CODES.INVALID_INPUT }
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
authenticatorData: asBytes(authenticatorData),
|
|
163
|
+
clientDataJSON: asBytes(clientDataJSON),
|
|
164
|
+
signature: asBytes(signature),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The public keys that could have produced this assertion — at most two, each
|
|
170
|
+
* checked against the signature.
|
|
171
|
+
*
|
|
172
|
+
* @param {PublicKeyCredential|{authenticatorData: BufferSource, clientDataJSON: BufferSource, signature: BufferSource}} assertion
|
|
173
|
+
* @returns {Promise<Array<{x: bigint, y: bigint}>>}
|
|
174
|
+
*/
|
|
175
|
+
export async function recoverPublicKeyCandidates(assertion) {
|
|
176
|
+
const { authenticatorData, clientDataJSON, signature } =
|
|
177
|
+
responseOf(assertion);
|
|
178
|
+
const raw =
|
|
179
|
+
signature.length === P1363_SIGNATURE_LENGTH
|
|
180
|
+
? signature
|
|
181
|
+
: derToRawSignature(signature);
|
|
182
|
+
const r = toBigInt(raw.slice(0, COORDINATE_BYTES));
|
|
183
|
+
const s = toBigInt(raw.slice(COORDINATE_BYTES));
|
|
184
|
+
if (r <= 0n || r >= N || s <= 0n || s >= N) {
|
|
185
|
+
throw new WebAuthnIdentityError(
|
|
186
|
+
'the signature is not an ES256 signature, so no key can be recovered from it',
|
|
187
|
+
{ code: ERROR_CODES.INVALID_INPUT }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const signedBytes = await signedBytesOf(authenticatorData, clientDataJSON);
|
|
192
|
+
const digest = new Uint8Array(
|
|
193
|
+
await crypto.subtle.digest(CRYPTO_ALGORITHMS.SHA_256, signedBytes)
|
|
194
|
+
);
|
|
195
|
+
const e = toBigInt(digest);
|
|
196
|
+
|
|
197
|
+
// R has x = r; both parities of y are candidates. Q = r⁻¹(sR − eG).
|
|
198
|
+
const y = squareRoot(mod(r * r * r - 3n * r + B));
|
|
199
|
+
if (y === null) return [];
|
|
200
|
+
const rInverse = inverse(r, N);
|
|
201
|
+
const candidates = [];
|
|
202
|
+
for (const candidateY of [y, mod(P - y)]) {
|
|
203
|
+
const point = multiply(
|
|
204
|
+
add(multiply({ x: r, y: candidateY }, s), multiply(G, N - mod(e, N))),
|
|
205
|
+
rInverse
|
|
206
|
+
);
|
|
207
|
+
if (point === null) continue;
|
|
208
|
+
if (await verifies(point, signedBytes, raw)) candidates.push(point);
|
|
209
|
+
}
|
|
210
|
+
return candidates;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The public key behind two assertions of the same credential.
|
|
215
|
+
*
|
|
216
|
+
* One assertion leaves two possibilities; a second one, over another
|
|
217
|
+
* challenge, leaves one. Both must come from the same passkey — pass the
|
|
218
|
+
* credential id of the first to the second `navigator.credentials.get()`.
|
|
219
|
+
*
|
|
220
|
+
* @param {*} first
|
|
221
|
+
* @param {*} second
|
|
222
|
+
* @returns {Promise<{x: Uint8Array, y: Uint8Array}>} coordinates, as
|
|
223
|
+
* `WebAuthnDIDProvider.createDID` takes them
|
|
224
|
+
*/
|
|
225
|
+
export async function recoverPublicKey(first, second) {
|
|
226
|
+
const [a, b] = await Promise.all([
|
|
227
|
+
recoverPublicKeyCandidates(first),
|
|
228
|
+
recoverPublicKeyCandidates(second),
|
|
229
|
+
]);
|
|
230
|
+
const shared = a.filter((point) =>
|
|
231
|
+
b.some((other) => other.x === point.x && other.y === point.y)
|
|
232
|
+
);
|
|
233
|
+
if (shared.length !== 1) {
|
|
234
|
+
throw new WebAuthnIdentityError(
|
|
235
|
+
shared.length === 0
|
|
236
|
+
? 'the two assertions do not share a public key — they are not the same credential'
|
|
237
|
+
: 'the two assertions did not settle on one public key',
|
|
238
|
+
{ code: ERROR_CODES.WEBAUTHN_VERIFICATION_FAILED }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return { x: toBytes(shared[0].x), y: toBytes(shared[0].y) };
|
|
242
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Restore
|
|
3
|
+
* @description
|
|
4
|
+
* Restore an identity on a device that has stored nothing (#61).
|
|
5
|
+
*
|
|
6
|
+
* Two touches of the same passkey are all it takes. The first is asked with
|
|
7
|
+
* user verification and with the PRF input every device can compute
|
|
8
|
+
* (`prf-input.js`), so the authenticator returns the same secret it returns on
|
|
9
|
+
* the device the identity was made on. The second signs another challenge, and
|
|
10
|
+
* the two signatures together give back the credential's public key
|
|
11
|
+
* (`recovery.js`) — which an assertion does not carry, and which nothing here
|
|
12
|
+
* had a way to learn without largeBlob or a copy in storage.
|
|
13
|
+
*
|
|
14
|
+
* From those two: the DID, and the signing key derived from the PRF output
|
|
15
|
+
* with the DID mixed in — the same pair the original device holds.
|
|
16
|
+
*
|
|
17
|
+
* Nothing stands in for a missing PRF. A device whose authenticator cannot
|
|
18
|
+
* evaluate it gets a refusal, because the alternative is an identity that
|
|
19
|
+
* silently differs from the one the database knows.
|
|
20
|
+
*/
|
|
21
|
+
import { logger } from '@libp2p/logger';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
ERROR_CODES,
|
|
25
|
+
PrfUnavailableError,
|
|
26
|
+
WebAuthnIdentityError,
|
|
27
|
+
} from '../errors.js';
|
|
28
|
+
import { deriveSigningKeyBytes } from '../keystore/derived-signing-key.js';
|
|
29
|
+
import { KEY_TYPES } from '../constants.js';
|
|
30
|
+
import { buildCredentialRequestOptions } from './config.js';
|
|
31
|
+
import { prfInputForRelyingParty } from './prf-input.js';
|
|
32
|
+
import { WebAuthnDIDProvider } from './provider.js';
|
|
33
|
+
import { recoverPublicKey } from './recovery.js';
|
|
34
|
+
|
|
35
|
+
const log = logger('orbitdb-identity-provider-webauthn-did:restore');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ask the authenticator in front of this device who it is.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} [options]
|
|
41
|
+
* @param {string} [options.rpId] - Relying party id; the current host by default.
|
|
42
|
+
* @param {number} [options.timeout=120000] - Per touch, in ms. Generous on
|
|
43
|
+
* purpose: an NFC key has to be found, held and read.
|
|
44
|
+
* @param {string} [options.signingKeyType='secp256k1'] - As the keystore wants it.
|
|
45
|
+
* @param {(step: {touch: number, of: number}) => void} [options.onTouch] -
|
|
46
|
+
* Called before each ceremony, so an application can say which touch this is.
|
|
47
|
+
* @returns {Promise<{did: string, publicKey: {x: Uint8Array, y: Uint8Array},
|
|
48
|
+
* credentialId: string, rawCredentialId: Uint8Array, signingKey: Uint8Array,
|
|
49
|
+
* prfInput: Uint8Array, credential: Object}>} `credential` is what
|
|
50
|
+
* `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential })` takes.
|
|
51
|
+
* `credentialId` is the base64url text, as everywhere else in this package,
|
|
52
|
+
* and `rawCredentialId` the bytes (before 0.7.0, `credentialId` was the bytes).
|
|
53
|
+
*/
|
|
54
|
+
export async function restoreIdentityFromAuthenticator({
|
|
55
|
+
rpId,
|
|
56
|
+
timeout = 120_000,
|
|
57
|
+
signingKeyType = KEY_TYPES.SECP256K1,
|
|
58
|
+
onTouch,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (typeof navigator === 'undefined' || !navigator.credentials?.get) {
|
|
61
|
+
throw new WebAuthnIdentityError('WebAuthn is not available here', {
|
|
62
|
+
code: ERROR_CODES.WEBAUTHN_NOT_SUPPORTED,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const party = rpId ?? globalThis.location?.hostname;
|
|
67
|
+
const prfInput = await prfInputForRelyingParty(party);
|
|
68
|
+
|
|
69
|
+
// Touch one: discoverable, so the device does not have to know which
|
|
70
|
+
// credential to ask for — it has nothing stored, that is the premise.
|
|
71
|
+
onTouch?.({ touch: 1, of: 2 });
|
|
72
|
+
const first = await navigator.credentials.get(
|
|
73
|
+
buildCredentialRequestOptions({
|
|
74
|
+
rpId: party,
|
|
75
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
76
|
+
userVerification: 'required',
|
|
77
|
+
timeout,
|
|
78
|
+
extensions: { prf: { eval: { first: prfInput } } },
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
if (!first) {
|
|
82
|
+
throw new WebAuthnIdentityError('no credential was offered', {
|
|
83
|
+
code: ERROR_CODES.WEBAUTHN_AUTHENTICATION_FAILED,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const prfOutput = first.getClientExtensionResults?.()?.prf?.results?.first;
|
|
88
|
+
if (!prfOutput) {
|
|
89
|
+
// No fallback: an identity derived from anything else is not this one.
|
|
90
|
+
throw new PrfUnavailableError(
|
|
91
|
+
'the authenticator returned no PRF output, so the signing key cannot be derived'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Touch two: the same credential, another challenge. Only the signature
|
|
96
|
+
// matters, so this asks for no more verification than the key insists on.
|
|
97
|
+
onTouch?.({ touch: 2, of: 2 });
|
|
98
|
+
const second = await navigator.credentials.get(
|
|
99
|
+
buildCredentialRequestOptions({
|
|
100
|
+
rpId: party,
|
|
101
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
102
|
+
userVerification: 'preferred',
|
|
103
|
+
timeout,
|
|
104
|
+
credentialId: first.rawId,
|
|
105
|
+
discoverableCredentials: false,
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const publicKey = await recoverPublicKey(first, second);
|
|
110
|
+
const did = await WebAuthnDIDProvider.createDID({ publicKey });
|
|
111
|
+
const signingKey = await deriveSigningKeyBytes(
|
|
112
|
+
new Uint8Array(prfOutput),
|
|
113
|
+
did,
|
|
114
|
+
signingKeyType
|
|
115
|
+
);
|
|
116
|
+
log('restored %s from the authenticator alone', did);
|
|
117
|
+
|
|
118
|
+
const rawCredentialId = new Uint8Array(first.rawId);
|
|
119
|
+
const credentialId =
|
|
120
|
+
WebAuthnDIDProvider.arrayBufferToBase64url(rawCredentialId);
|
|
121
|
+
return {
|
|
122
|
+
did,
|
|
123
|
+
publicKey,
|
|
124
|
+
credentialId,
|
|
125
|
+
rawCredentialId,
|
|
126
|
+
signingKey,
|
|
127
|
+
prfInput,
|
|
128
|
+
// Ready for the provider as it is, so nobody has to know which of the two
|
|
129
|
+
// ids it wants under which name.
|
|
130
|
+
credential: { credentialId, rawCredentialId, publicKey, prfInput },
|
|
131
|
+
};
|
|
132
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -430,6 +430,57 @@ export function createVerificationResult(
|
|
|
430
430
|
|
|
431
431
|
export { VerificationUtils };
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Restoring on a device that has stored nothing (#61).
|
|
435
|
+
*/
|
|
436
|
+
export interface RestoredIdentity {
|
|
437
|
+
did: string;
|
|
438
|
+
publicKey: { x: Uint8Array; y: Uint8Array };
|
|
439
|
+
/** The credential id as base64url text. Before 0.7.0 this was the bytes. */
|
|
440
|
+
credentialId: string;
|
|
441
|
+
/** The credential id as bytes. */
|
|
442
|
+
rawCredentialId: Uint8Array;
|
|
443
|
+
signingKey: Uint8Array;
|
|
444
|
+
prfInput: Uint8Array;
|
|
445
|
+
/** Ready for `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential })`. */
|
|
446
|
+
credential: WebAuthnCredentialInfo;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface RestoreIdentityOptions {
|
|
450
|
+
rpId?: string;
|
|
451
|
+
timeout?: number;
|
|
452
|
+
signingKeyType?: string;
|
|
453
|
+
onTouch?: (step: { touch: number; of: number }) => void;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export const PRF_INPUT_INFO: string;
|
|
457
|
+
|
|
458
|
+
export function prfInputForRelyingParty(rpId?: string): Promise<Uint8Array>;
|
|
459
|
+
|
|
460
|
+
export function recoverPublicKeyCandidates(
|
|
461
|
+
assertion: unknown
|
|
462
|
+
): Promise<Array<{ x: bigint; y: bigint }>>;
|
|
463
|
+
|
|
464
|
+
export function recoverPublicKey(
|
|
465
|
+
first: unknown,
|
|
466
|
+
second: unknown
|
|
467
|
+
): Promise<{ x: Uint8Array; y: Uint8Array }>;
|
|
468
|
+
|
|
469
|
+
export function restoreIdentityFromAuthenticator(
|
|
470
|
+
options?: RestoreIdentityOptions
|
|
471
|
+
): Promise<RestoredIdentity>;
|
|
472
|
+
|
|
473
|
+
export function deriveSigningKeyBytes(
|
|
474
|
+
seed: Uint8Array,
|
|
475
|
+
did: string,
|
|
476
|
+
keyType?: string
|
|
477
|
+
): Promise<Uint8Array>;
|
|
478
|
+
|
|
479
|
+
export function getPrfOutput(
|
|
480
|
+
credential: Record<string, unknown>,
|
|
481
|
+
options?: { rpId?: string }
|
|
482
|
+
): Promise<Uint8Array | null>;
|
|
483
|
+
|
|
433
484
|
declare const defaultExport: {
|
|
434
485
|
WebAuthnDIDProvider: typeof WebAuthnDIDProvider;
|
|
435
486
|
OrbitDBWebAuthnIdentityProvider: typeof OrbitDBWebAuthnIdentityProvider;
|