@le-space/orbitdb-identity-provider-webauthn-did 0.5.4 → 0.6.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 CHANGED
@@ -1,6 +1,77 @@
1
1
  # Changes
2
2
 
3
- ## Unreleased
3
+ ## 0.6.0
4
+
5
+ An identity can be restored on a second device with **nothing stored**: two
6
+ touches of the same passkey give back the DID and the signing key, so
7
+ largeBlob and `localStorage` become a convenience rather than the only road.
8
+ Measured on two phones with one YubiKey before it was written (#61).
9
+
10
+ Nothing existing moves: a credential registered before this keeps the PRF
11
+ input it was made with, and the identity it derives is unchanged. Credentials
12
+ registered from here on are asked the input every device can compute, which is
13
+ what makes the restore work. Two behaviours do change — `getPrfOutput()` now
14
+ falls back to that derivable input instead of returning `null` when a
15
+ credential has none stored, and `createDID()` throws instead of inventing a
16
+ "base58-like" identifier when it cannot encode a key.
17
+
18
+ ### Added
19
+
20
+ - **An identity can be restored on a device that has stored nothing** (#61).
21
+ `restoreIdentityFromAuthenticator()` touches the same passkey twice: once
22
+ with user verification and the PRF input every device can compute, once for a
23
+ second signature. Two ECDSA signatures admit exactly one common public key —
24
+ which an assertion does not carry, and which is why the identity metadata has
25
+ had to live in largeBlob or `localStorage` until now. From it: the DID, and
26
+ the signing key derived from the PRF output with the DID mixed in. The pieces
27
+ are exported on their own as well: `prfInputForRelyingParty()`,
28
+ `recoverPublicKey()` and `recoverPublicKeyCandidates()`.
29
+
30
+ Measured on hardware before it was written — one YubiKey, a Galaxy Fold 5 and
31
+ a Galaxy A57, 2026-09-19: the same PRF value on both phones, the same DID
32
+ recovered from two signatures, the same derived signing key. What of that is
33
+ machine-checkable runs in `tests/webauthn-recovery.test.js`, against a
34
+ software authenticator with a real P-256 key.
35
+
36
+ Nothing stands in for a missing PRF: an authenticator without it gets a
37
+ refusal, because an identity derived from something else is a different
38
+ identity that looks like success.
39
+
40
+ ### Changed
41
+
42
+ - **The PRF input is now fixed per relying party** instead of drawn at random
43
+ per credential (#61). It was stored with the credential metadata, so the
44
+ derived signing key — and with it the identity — was reachable only from the
45
+ device that kept that metadata; a second device asking the same authenticator
46
+ a different question got a different answer. The input is not a secret, the
47
+ output is. Credentials registered earlier keep working: their stored input is
48
+ still used when there is one, and `getPrfOutput()` now falls back to the
49
+ derivable input instead of giving up when there is none.
50
+ - **`createDID` refuses a key it cannot encode.** Its `catch` built a
51
+ "base58-like" identifier out of the same coordinates: a string that looked
52
+ like a DID, was not `did:key`, and could not be reproduced by anything else
53
+ reading that key — including this library on another device. It throws now.
54
+
55
+ ### Changed
56
+
57
+ - CI now runs all 22 test files. Eight never ran there: `identity-forgery`,
58
+ `p256-signature-shapes`, `session-keystore`, `webauthn-debug-log`,
59
+ `webauthn-extension-support`, `webauthn-user-handle`,
60
+ `webauthn-varsig-challenge` and `worker-signer`. They were listed in
61
+ `playwright.node.config.js`, which only the local `preversion` and
62
+ `prepublishOnly` hooks ran, while `release.yml` publishes with
63
+ `--ignore-scripts` because CI is its gate — so no automated gate ran them
64
+ before a release. CI now runs that config in one step, before installing
65
+ browsers; `standalone-toolkit`, `webauthn-attestation-parsing` and
66
+ `webauthn-two-peer-replication` lose their separate steps, which booted a
67
+ demo server they do not use.
68
+ - `identity-forgery` did not load in a clean install. It imported `concat`
69
+ from `uint8arrays`, which this package does not declare; only a hoisted
70
+ `node_modules` (`node-linker=hoisted`) made it resolvable. It now takes
71
+ `concat` from `iso-base/utils`, a declared dependency.
72
+ - `scripts/check-ci-runs-every-test.mjs` fails CI when a file in `tests/` is
73
+ selected by no step. This is the second time suites went unrun: 0.4.0 found
74
+ five.
4
75
 
5
76
  ## 0.5.4
6
77
 
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 KS as OrbitDB Keystore
192
- participant Enc as KeystoreEncryption
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->>KS: getKey() or add generated Ed25519 key
202
- KS-->>App: Keystore keypair
203
-
204
- opt encryptKeystore=true
205
- App->>Enc: generateSecretKey()
206
- Enc-->>App: sk
207
- App->>Enc: encrypt keystore private key (AES-GCM)
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
- Auth-->>WebAuthn: PRF output
212
- WebAuthn-->>App: PRF bytes
213
- App->>Enc: wrap sk with PRF
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
- App->>WebAuthn: get() with largeBlob write
221
+ Prov->>WebAuthn: get() with largeBlob write
216
222
  WebAuthn->>Auth: User verification
217
- Auth-->>WebAuthn: Store sk in largeBlob
218
- WebAuthn-->>App: largeBlob stored
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
- App->>WebAuthn: get() with hmac-secret
232
+ Prov->>WebAuthn: get() with hmac-secret
221
233
  WebAuthn->>Auth: User verification
222
- Auth-->>WebAuthn: HMAC output
223
- WebAuthn-->>App: HMAC bytes
224
- App->>Enc: wrap sk with HMAC
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: With `encryptKeystore=true` the sealed copy is at rest; the unlocked key lives in the OrbitDB keystore — make that `createSessionKeystore()` so it stays in memory.
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: createIdentity()
284
- Var->>WebAuthn: get()
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->>Var: encode varsig envelope
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: signIdentity(payload)
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.5.4",
3
+ "version": "0.6.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
- // Without the original input the output would differ per call, which is
130
- // worse than not deriving at all.
131
- if (!credential?.prfInput) {
132
- log('credential has no stored prfInput; cannot derive a stable key');
133
- return null;
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: credential.prfInput } } },
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
+ }
@@ -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,
@@ -173,7 +175,8 @@ export class WebAuthnDIDProvider {
173
175
 
174
176
  if (keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.PRF) {
175
177
  const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
176
- credentialOptions.publicKey
178
+ credentialOptions.publicKey,
179
+ await prfInputForRelyingParty(domain)
177
180
  );
178
181
  credentialOptions.publicKey = prfConfig.credentialOptions;
179
182
  prfInput = prfConfig.prfInput;
@@ -199,14 +202,19 @@ export class WebAuthnDIDProvider {
199
202
  };
200
203
  }
201
204
 
202
- // Request PRF even when the keystore is not encrypted. The stored input is
203
- // what lets the OrbitDB signing key be derived reproducibly later, so a
204
- // credential registered without it can never get a reproducible identity.
205
+ // Request PRF even when the keystore is not encrypted: it is what lets the
206
+ // OrbitDB signing key be derived reproducibly later, so a credential
207
+ // registered without it can never get a reproducible identity. The input
208
+ // is fixed per relying party rather than drawn at random (#61), so a
209
+ // second device asks the authenticator the same question and gets the same
210
+ // answer — without that, "the same passkey" still means a different key on
211
+ // every device.
205
212
  // Requesting the extension is harmless where it is unsupported: the
206
213
  // authenticator simply returns no PRF results.
207
214
  if (!prfInput) {
208
215
  const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
209
- credentialOptions.publicKey
216
+ credentialOptions.publicKey,
217
+ await prfInputForRelyingParty(domain)
210
218
  );
211
219
  credentialOptions.publicKey = prfConfig.credentialOptions;
212
220
  prfInput = prfConfig.prfInput;
@@ -581,38 +589,16 @@ export class WebAuthnDIDProvider {
581
589
  const multikeyEncoded = base58btc.encode(multikey);
582
590
  return `${DID_KEY_PREFIX}${multikeyEncoded}`;
583
591
  } catch (error) {
584
- console.warn(
585
- 'Failed to create DID with multiformats, using fallback:',
586
- error
592
+ // No fallback encoding. What stood here invented a "base58-like"
593
+ // identifier out of the same coordinates — a string that looks like a
594
+ // DID, is not `did:key`, and cannot be reproduced by anything else that
595
+ // reads this key, including this library on another device. An identity
596
+ // nobody else computes the same way is worse than no identity, and it
597
+ // fails silently, at registration, where nothing checks it again.
598
+ throw new WebAuthnIdentityError(
599
+ `could not encode the public key as a did:key: ${error.message}`,
600
+ { code: ERROR_CODES.INVALID_INPUT, cause: error }
587
601
  );
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
602
  }
617
603
  }
618
604
 
@@ -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,121 @@
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: Uint8Array, signingKey: Uint8Array, prfInput: Uint8Array}>}
49
+ */
50
+ export async function restoreIdentityFromAuthenticator({
51
+ rpId,
52
+ timeout = 120_000,
53
+ signingKeyType = KEY_TYPES.SECP256K1,
54
+ onTouch,
55
+ } = {}) {
56
+ if (typeof navigator === 'undefined' || !navigator.credentials?.get) {
57
+ throw new WebAuthnIdentityError('WebAuthn is not available here', {
58
+ code: ERROR_CODES.WEBAUTHN_NOT_SUPPORTED,
59
+ });
60
+ }
61
+
62
+ const party = rpId ?? globalThis.location?.hostname;
63
+ const prfInput = await prfInputForRelyingParty(party);
64
+
65
+ // Touch one: discoverable, so the device does not have to know which
66
+ // credential to ask for — it has nothing stored, that is the premise.
67
+ onTouch?.({ touch: 1, of: 2 });
68
+ const first = await navigator.credentials.get(
69
+ buildCredentialRequestOptions({
70
+ rpId: party,
71
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
72
+ userVerification: 'required',
73
+ timeout,
74
+ extensions: { prf: { eval: { first: prfInput } } },
75
+ })
76
+ );
77
+ if (!first) {
78
+ throw new WebAuthnIdentityError('no credential was offered', {
79
+ code: ERROR_CODES.WEBAUTHN_AUTHENTICATION_FAILED,
80
+ });
81
+ }
82
+
83
+ const prfOutput = first.getClientExtensionResults?.()?.prf?.results?.first;
84
+ if (!prfOutput) {
85
+ // No fallback: an identity derived from anything else is not this one.
86
+ throw new PrfUnavailableError(
87
+ 'the authenticator returned no PRF output, so the signing key cannot be derived'
88
+ );
89
+ }
90
+
91
+ // Touch two: the same credential, another challenge. Only the signature
92
+ // matters, so this asks for no more verification than the key insists on.
93
+ onTouch?.({ touch: 2, of: 2 });
94
+ const second = await navigator.credentials.get(
95
+ buildCredentialRequestOptions({
96
+ rpId: party,
97
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
98
+ userVerification: 'preferred',
99
+ timeout,
100
+ credentialId: first.rawId,
101
+ discoverableCredentials: false,
102
+ })
103
+ );
104
+
105
+ const publicKey = await recoverPublicKey(first, second);
106
+ const did = await WebAuthnDIDProvider.createDID({ publicKey });
107
+ const signingKey = await deriveSigningKeyBytes(
108
+ new Uint8Array(prfOutput),
109
+ did,
110
+ signingKeyType
111
+ );
112
+ log('restored %s from the authenticator alone', did);
113
+
114
+ return {
115
+ did,
116
+ publicKey,
117
+ credentialId: new Uint8Array(first.rawId),
118
+ signingKey,
119
+ prfInput,
120
+ };
121
+ }
package/types/index.d.ts CHANGED
@@ -430,6 +430,52 @@ 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
+ credentialId: Uint8Array;
440
+ signingKey: Uint8Array;
441
+ prfInput: Uint8Array;
442
+ }
443
+
444
+ export interface RestoreIdentityOptions {
445
+ rpId?: string;
446
+ timeout?: number;
447
+ signingKeyType?: string;
448
+ onTouch?: (step: { touch: number; of: number }) => void;
449
+ }
450
+
451
+ export const PRF_INPUT_INFO: string;
452
+
453
+ export function prfInputForRelyingParty(rpId?: string): Promise<Uint8Array>;
454
+
455
+ export function recoverPublicKeyCandidates(
456
+ assertion: unknown
457
+ ): Promise<Array<{ x: bigint; y: bigint }>>;
458
+
459
+ export function recoverPublicKey(
460
+ first: unknown,
461
+ second: unknown
462
+ ): Promise<{ x: Uint8Array; y: Uint8Array }>;
463
+
464
+ export function restoreIdentityFromAuthenticator(
465
+ options?: RestoreIdentityOptions
466
+ ): Promise<RestoredIdentity>;
467
+
468
+ export function deriveSigningKeyBytes(
469
+ seed: Uint8Array,
470
+ did: string,
471
+ keyType?: string
472
+ ): Promise<Uint8Array>;
473
+
474
+ export function getPrfOutput(
475
+ credential: Record<string, unknown>,
476
+ options?: { rpId?: string }
477
+ ): Promise<Uint8Array | null>;
478
+
433
479
  declare const defaultExport: {
434
480
  WebAuthnDIDProvider: typeof WebAuthnDIDProvider;
435
481
  OrbitDBWebAuthnIdentityProvider: typeof OrbitDBWebAuthnIdentityProvider;