@le-space/orbitdb-identity-provider-webauthn-did 0.6.0 → 0.8.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,5 +1,69 @@
1
1
  # Changes
2
2
 
3
+ ## 0.8.0
4
+
5
+ Two functions for wallets, and nothing that changes for anyone else. They were written against
6
+ 0.5.4 on 16 September and vendored into simple-todo's `escrow01` as `0.5.5-p256.8366ed8`, but
7
+ never pushed or released until now — which is why 0.6.0 and 0.7.0 do not have them, and why #70
8
+ took their absence for a removal.
9
+
10
+ ### Added
11
+
12
+ - **Passkey primitives for wallets**, in `./standalone` (#60). A smart
13
+ account that verifies P-256 WebAuthn signatures on-chain — Uniswap's
14
+ Calibur, through webauthn-sol — can use the identity's passkey, without
15
+ the wallet duplicating key extraction and DER parsing or creating a
16
+ second passkey when it cannot recover the key.
17
+ - `getP256CredentialDescriptor(credential)` returns
18
+ `{ credentialId, rawCredentialId, x, y, rpId, userVerification }`, with
19
+ `x` and `y` as 32-byte big-endian `Uint8Array`s, from either credential
20
+ shape, a stored or JSON copy, or a P-256 `did:key` alone (a varsig DID
21
+ holds the key compressed). It returns `null` for RS256 and Ed25519 keys,
22
+ for anything unreadable, and for the placeholder `createCredential`
23
+ writes when it cannot read the public key — also once that has lost its
24
+ `synthetic` flag, which largeBlob metadata does not carry: the point has
25
+ to lie on the curve.
26
+ - `signP256Challenge(descriptor, challenge)` signs exactly 32 bytes, with
27
+ `allowCredentials` pinned to the descriptor whatever the discoverable
28
+ credential policy, and user verification required. It refuses an
29
+ assertion whose `rawId` differs and returns
30
+ `{ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }`:
31
+ `r` and `s` left-padded to 32 bytes, `s` normalised to low-s, and the
32
+ indices as the byte offsets of `"type":"webauthn.get"` and
33
+ `"challenge":"…"` that webauthn-sol reads. The signature is verified
34
+ against `x`/`y` before it is returned.
35
+
36
+ ### Changed
37
+
38
+ - `createCredential` returns `rpId` on both paths, and a stored varsig
39
+ (hardware signer) credential keeps it. An assertion has to name the rpId
40
+ the credential was registered under, which need not be the page's
41
+ hostname.
42
+
43
+ ## 0.7.0
44
+
45
+ **One breaking change, in the function 0.6.0 added.** `restoreIdentityFromAuthenticator()`
46
+ returned the credential id as bytes, under `credentialId` — the name the rest of this package
47
+ uses for the base64url text. An application that passed the bytes on as `rawCredentialId`, and
48
+ so gave the provider no text, got a third touch of the key and then an exception: `WebAuthn
49
+ signing error: Cannot read properties of undefined (reading 'substring')`. That happened on two
50
+ phones on 21 September, in funkpost's recovery page.
51
+
52
+ - `credentialId` is now the base64url **text** and `rawCredentialId` the **bytes**, as
53
+ `WebAuthnCredentialInfo` has always typed them.
54
+ - New: `credential`, the credential the provider takes as it is —
55
+ `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential: restored.credential })`.
56
+ - The provider derives either form of the id from the other, so a credential that brings only
57
+ one of them signs instead of failing after the touch.
58
+ - `sign()` no longer builds a debug line from a field that may be missing.
59
+
60
+ **Migrating from 0.6.0:** read the bytes from `restored.rawCredentialId` instead of
61
+ `restored.credentialId`, or hand `restored.credential` to the provider.
62
+
63
+ **Tested now:** the step after the restore — giving the identity to OrbitDB and signing with it
64
+ — which no test reached before. Both new tests fail on 0.6.0, one of them with the exception
65
+ above, word for word.
66
+
3
67
  ## 0.6.0
4
68
 
5
69
  An identity can be restored on a second device with **nothing stored**: two
package/README.md CHANGED
@@ -179,6 +179,37 @@ const ucanUcantoSigner = signer.toUcantoSigner({
179
179
 
180
180
  The verifier side and app protocol should define which domain label is required. IPFS deployment does not change this requirement.
181
181
 
182
+ ### Passkeys for smart accounts (P-256)
183
+
184
+ One passkey can be both the OrbitDB identity and the key of a smart account that verifies P-256 WebAuthn signatures on-chain, such as Uniswap's [Calibur](https://github.com/Uniswap/calibur) with Base's [webauthn-sol](https://github.com/base/webauthn-sol). The standalone export hands wallet code the key and the signature parts; turning them into a transaction (key hashes, ABI encoding, the account client) stays in the wallet.
185
+
186
+ ```javascript
187
+ import {
188
+ getP256CredentialDescriptor,
189
+ signP256Challenge,
190
+ } from '@le-space/orbitdb-identity-provider-webauthn-did/standalone';
191
+
192
+ // A credential from either createCredential, storage, largeBlob or signer.credential
193
+ const descriptor = getP256CredentialDescriptor(credential);
194
+ if (!descriptor) throw new Error('This passkey has no usable P-256 key');
195
+ // { credentialId, rawCredentialId, x, y, rpId, userVerification: 'required' }
196
+
197
+ // Exactly 32 bytes, e.g. a user operation hash
198
+ const { authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s } =
199
+ await signP256Challenge(descriptor, hash);
200
+ ```
201
+
202
+ - **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`.
203
+ - **`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.
204
+ - **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.
205
+ - **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.
206
+
207
+ | Signing mode | Descriptor |
208
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
209
+ | Default (passkey DID), hardware P-256 (varsig) | yes: the DID encodes the same key |
210
+ | Keystore or worker Ed25519 DID | yes, from the credential's `x`/`y`; the DID does not commit to the passkey, so link the wallet key to it yourself |
211
+ | Hardware Ed25519 (varsig) | `null`: there is no P-256 key |
212
+
182
213
  ### Keystore-based DID (WebAuthn + OrbitDB keystore)
183
214
 
184
215
  ```mermaid
@@ -386,18 +417,22 @@ If you change anything that touches signing, identity documents or verification,
386
417
 
387
418
  ## Identity Recovery Summary
388
419
 
389
- Current identity recovery behavior in this repo:
420
+ Since **0.6.0** an identity can be restored on a device that has stored nothing:
421
+
422
+ - Discoverable passkeys are the default, so a device that knows no credential ID can still authenticate.
423
+ - 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.
424
+ - The PRF input is fixed per relying party, so a second device asks the authenticator the same question and receives the same secret; the signing key is derived from it with the DID mixed in.
425
+ - `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 })`.
390
426
 
391
- - Discoverable passkeys are the default.
392
- - Discoverable authentication can recover the credential ID used for assertion.
393
- - Discoverable authentication does not reliably re-expose the credential public key.
394
- - Because of that, OrbitDB identity recovery requires metadata persistence.
395
- - The demos now try `largeBlob` first for identity metadata recovery.
396
- - If `largeBlob` is not supported or has no metadata, the demos fall back to local browser storage.
427
+ 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.
397
428
 
398
429
  Practical implication:
399
430
 
400
- - If you create a passkey on one browser profile and later open the app in a fresh profile, the passkey may still exist in the platform passkey manager, but the app can only reconstruct the OrbitDB identity if it can recover metadata from `largeBlob` or some other persisted mapping.
431
+ - 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.
432
+ - An authenticator without PRF gets a **refusal**, not a substitute. An identity derived from something else is a different identity that looks like success.
433
+ - Credentials registered before 0.6.0 keep the random PRF input stored with them, so their identities do not move.
434
+
435
+ 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).
401
436
 
402
437
  ## Upstream Packages and Temporary Forks
403
438
 
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`
@@ -348,6 +349,22 @@ const ucanSigner = signer.toUcantoSigner({
348
349
  - `clearWebAuthnCredentialSafe(key?)`
349
350
  - `extractPrfSeedFromCredential(credential)`
350
351
 
352
+ ### P-256 Wallet Primitives
353
+
354
+ For smart accounts that verify P-256 WebAuthn signatures (for example Calibur
355
+ with webauthn-sol). See the README section "Passkeys for smart accounts".
356
+
357
+ - `getP256CredentialDescriptor(credential)` →
358
+ `{ credentialId, rawCredentialId, x, y, rpId, userVerification: 'required' }`
359
+ or `null` (RS256, Ed25519, placeholder or unreadable key). `x` and `y` are
360
+ 32-byte big-endian `Uint8Array`s.
361
+ - `signP256Challenge(descriptor, challenge)` → `Promise` of
362
+ `{ authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s }`.
363
+ `challenge` must be exactly 32 bytes. `allowCredentials` is pinned to the
364
+ descriptor, user verification is required, an assertion from another
365
+ credential is refused. `r` and `s` are 32 bytes with `s` low; the indices
366
+ are UTF-8 byte offsets in `clientDataJSON`.
367
+
351
368
  ### Worker Keystore Client
352
369
 
353
370
  - `createWorkerKeystoreClient(options?)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
@@ -26,6 +26,11 @@ export {
26
26
  extractPrfSeedFromCredential,
27
27
  } from './webauthn/credential.js';
28
28
 
29
+ export {
30
+ getP256CredentialDescriptor,
31
+ signP256Challenge,
32
+ } from './webauthn/p256-wallet.js';
33
+
29
34
  export {
30
35
  createWorkerKeystoreClient,
31
36
  createWorkerSigner,
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Passkey primitives for wallets.
3
+ *
4
+ * A smart account that verifies P-256 WebAuthn signatures on-chain — Uniswap's
5
+ * Calibur, through Base's webauthn-sol — can use the same passkey as the
6
+ * OrbitDB identity. It needs the passkey's key as x/y, the credential to ask
7
+ * for, and an assertion over a 32-byte challenge taken apart into the fields
8
+ * such a verifier reads (webauthn-sol's `WebAuthnAuth`).
9
+ *
10
+ * What turns those into a transaction — key hashes, ABI encoding, the account
11
+ * client — moves funds and stays out of this package.
12
+ */
13
+ import { derToRawSignature, isDerSignature } from 'iso-webauthn-varsig';
14
+
15
+ import { WEBAUTHN_CLIENT_DATA_TYPES } from '../../constants.js';
16
+ import {
17
+ WebAuthnAuthenticationError,
18
+ WebAuthnIdentityError,
19
+ WebAuthnNotSupportedError,
20
+ WebAuthnVerificationError,
21
+ } from '../../errors.js';
22
+ import { buildCredentialRequestOptions } from '../../webauthn/config.js';
23
+ import { MULTICODEC, decodeDidKey } from '../../webauthn/proof-verification.js';
24
+
25
+ // secp256r1 (NIST P-256), SEC 2 §2.4.2: y² = x³ − 3x + b over GF(p), order n.
26
+ const P256_P =
27
+ 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
28
+ const P256_N =
29
+ 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
30
+ const P256_B =
31
+ 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
32
+ // webauthn-sol refuses any s above floor(n / 2), against malleability.
33
+ const P256_HALF_N = P256_N / 2n;
34
+
35
+ const COORDINATE_BYTES = 32;
36
+ const CHALLENGE_BYTES = 32;
37
+
38
+ const COSE_KTY_EC2 = 2;
39
+ const COSE_ALG_ES256 = -7;
40
+ const COSE_CRV_P256 = 1;
41
+
42
+ // rpIdHash (32) + flags (1) + signCount (4)
43
+ const AUTH_DATA_MIN_LENGTH = 37;
44
+ const FLAG_USER_PRESENT = 0x01;
45
+ const FLAG_USER_VERIFIED = 0x04;
46
+
47
+ const encoder = new TextEncoder();
48
+
49
+ /**
50
+ * Bytes from the shapes a byte field arrives in: a Uint8Array or other view,
51
+ * an ArrayBuffer, a number array, or the `{ "0": …, "1": … }` object that
52
+ * JSON makes of a Uint8Array. Always a copy. Anything else is null.
53
+ * @param {unknown} value
54
+ * @returns {Uint8Array|null}
55
+ */
56
+ function readBytes(value) {
57
+ if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
58
+ if (ArrayBuffer.isView(value)) {
59
+ return new Uint8Array(
60
+ value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)
61
+ );
62
+ }
63
+
64
+ let values;
65
+ if (Array.isArray(value)) {
66
+ values = value;
67
+ } else if (value && typeof value === 'object') {
68
+ const keys = Object.keys(value);
69
+ if (!keys.every((key, index) => key === String(index))) return null;
70
+ values = keys.map((key) => value[key]);
71
+ } else {
72
+ return null;
73
+ }
74
+
75
+ const isByte = (byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255;
76
+ return values.every(isByte) ? Uint8Array.from(values) : null;
77
+ }
78
+
79
+ /**
80
+ * A credential's byte field as storage hands it back: bytes in any shape
81
+ * `readBytes` takes, or base64url text (largeBlob and varsig metadata).
82
+ * @returns {Uint8Array|null}
83
+ */
84
+ function readField(value) {
85
+ return typeof value === 'string' ? base64urlToBytes(value) : readBytes(value);
86
+ }
87
+
88
+ function equalBytes(a, b) {
89
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
90
+ }
91
+
92
+ function concatBytes(...parts) {
93
+ const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
94
+ let offset = 0;
95
+ for (const part of parts) {
96
+ out.set(part, offset);
97
+ offset += part.length;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function indexOfBytes(haystack, needle) {
103
+ for (let i = 0; i + needle.length <= haystack.length; i += 1) {
104
+ let j = 0;
105
+ while (j < needle.length && haystack[i + j] === needle[j]) j += 1;
106
+ if (j === needle.length) return i;
107
+ }
108
+ return -1;
109
+ }
110
+
111
+ /** base64url without padding, as WebAuthn and webauthn-sol encode it. */
112
+ function bytesToBase64url(bytes) {
113
+ let binary = '';
114
+ for (const byte of bytes) binary += String.fromCharCode(byte);
115
+ return btoa(binary)
116
+ .replace(/\+/g, '-')
117
+ .replace(/\//g, '_')
118
+ .replace(/=+$/, '');
119
+ }
120
+
121
+ /** @returns {Uint8Array|null} */
122
+ function base64urlToBytes(value) {
123
+ const unpadded = value.replace(/=+$/, '');
124
+ if (!/^[A-Za-z0-9_-]*$/.test(unpadded) || unpadded.length % 4 === 1) {
125
+ return null;
126
+ }
127
+ const base64 = unpadded.replace(/-/g, '+').replace(/_/g, '/');
128
+ const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
129
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
130
+ }
131
+
132
+ function bytesToBigInt(bytes) {
133
+ let value = 0n;
134
+ for (const byte of bytes) value = (value << 8n) | BigInt(byte);
135
+ return value;
136
+ }
137
+
138
+ function bigIntToBytes(value, length = COORDINATE_BYTES) {
139
+ const out = new Uint8Array(length);
140
+ for (let i = length - 1; i >= 0; i -= 1) {
141
+ out[i] = Number(value & 0xffn);
142
+ value >>= 8n;
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function mod(value, modulus) {
148
+ const result = value % modulus;
149
+ return result < 0n ? result + modulus : result;
150
+ }
151
+
152
+ function modPow(base, exponent, modulus) {
153
+ let result = 1n;
154
+ base = mod(base, modulus);
155
+ while (exponent > 0n) {
156
+ if (exponent & 1n) result = (result * base) % modulus;
157
+ base = (base * base) % modulus;
158
+ exponent >>= 1n;
159
+ }
160
+ return result;
161
+ }
162
+
163
+ /** x³ − 3x + b (mod p): what y² has to be for (x, y) to lie on the curve. */
164
+ function curveYSquared(x) {
165
+ return mod(x * x * x - 3n * x + P256_B, P256_P);
166
+ }
167
+
168
+ function isOnCurve(x, y) {
169
+ const xValue = bytesToBigInt(x);
170
+ const yValue = bytesToBigInt(y);
171
+ return (
172
+ xValue < P256_P &&
173
+ yValue < P256_P &&
174
+ mod(yValue * yValue, P256_P) === curveYSquared(xValue)
175
+ );
176
+ }
177
+
178
+ /**
179
+ * x/y from a SEC1 point: 65 bytes uncompressed, or 33 compressed. A varsig
180
+ * `did:key` carries the compressed form, so y is recomputed: p ≡ 3 (mod 4),
181
+ * so a square root of y² is (y²)^((p+1)/4), and the prefix picks its parity.
182
+ * @returns {{x: Uint8Array, y: Uint8Array}|null}
183
+ */
184
+ function pointFromSec1(bytes) {
185
+ if (bytes.length === 65 && bytes[0] === 0x04) {
186
+ return { x: bytes.slice(1, 33), y: bytes.slice(33) };
187
+ }
188
+ if (bytes.length !== 33 || (bytes[0] !== 0x02 && bytes[0] !== 0x03)) {
189
+ return null;
190
+ }
191
+
192
+ const x = bytesToBigInt(bytes.subarray(1));
193
+ if (x >= P256_P) return null;
194
+ const ySquared = curveYSquared(x);
195
+ let y = modPow(ySquared, (P256_P + 1n) / 4n, P256_P);
196
+ if (mod(y * y, P256_P) !== ySquared) return null;
197
+ if ((y & 1n) !== BigInt(bytes[0] & 1)) {
198
+ if (y === 0n) return null;
199
+ y = P256_P - y;
200
+ }
201
+ return { x: bytes.slice(1), y: bigIntToBytes(y) };
202
+ }
203
+
204
+ /** Absent and null both count as "not stated". */
205
+ function statesOtherThan(value, expected) {
206
+ return value !== undefined && value !== null && value !== expected;
207
+ }
208
+
209
+ /**
210
+ * The credential's public key, if it is P-256.
211
+ * @returns {{x: Uint8Array, y: Uint8Array}|null|undefined} undefined when
212
+ * the credential carries no key, null when it carries an unusable one.
213
+ */
214
+ function readCredentialPublicKey(credential) {
215
+ const { publicKey } = credential;
216
+ if (publicKey === undefined || publicKey === null) return undefined;
217
+
218
+ // Default path: COSE-style { algorithm, keyType, curve, x, y }.
219
+ if (typeof publicKey === 'object' && ('x' in publicKey || 'y' in publicKey)) {
220
+ // The stand-in createCredential writes when it could not read the key.
221
+ if (publicKey.synthetic) return null;
222
+ if (
223
+ statesOtherThan(publicKey.algorithm, COSE_ALG_ES256) ||
224
+ statesOtherThan(publicKey.keyType, COSE_KTY_EC2) ||
225
+ statesOtherThan(publicKey.curve, COSE_CRV_P256)
226
+ ) {
227
+ return null;
228
+ }
229
+ const x = readField(publicKey.x);
230
+ const y = readField(publicKey.y);
231
+ if (x?.length !== COORDINATE_BYTES || y?.length !== COORDINATE_BYTES) {
232
+ return null;
233
+ }
234
+ return { x, y };
235
+ }
236
+
237
+ // Varsig path: SEC1 bytes (32 bytes would be Ed25519, and is refused here).
238
+ const bytes = readField(publicKey);
239
+ return bytes ? pointFromSec1(bytes) : null;
240
+ }
241
+
242
+ /**
243
+ * The key a P-256 `did:key` encodes.
244
+ * @returns {{x: Uint8Array, y: Uint8Array}|null|undefined} undefined when
245
+ * the DID is absent or does not name a P-256 key (a keystore DID does not
246
+ * commit to the passkey), null when it names one that cannot be read.
247
+ */
248
+ function readDidPublicKey(did) {
249
+ if (typeof did !== 'string') return undefined;
250
+ let decoded;
251
+ try {
252
+ decoded = decodeDidKey(did);
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ if (decoded.codec !== MULTICODEC.P256_PUB) return undefined;
257
+ return pointFromSec1(decoded.keyBytes);
258
+ }
259
+
260
+ /**
261
+ * Raw credential id from `rawCredentialId` and/or `credentialId` (bytes, or
262
+ * base64url). Where both are given they must name the same credential.
263
+ * @returns {Uint8Array|null}
264
+ */
265
+ function readCredentialId(credential) {
266
+ const candidates = [credential.rawCredentialId, credential.credentialId]
267
+ .filter((value) => value !== undefined && value !== null)
268
+ .map(readField);
269
+ if (candidates.length === 0 || candidates.some((id) => !id?.length)) {
270
+ return null;
271
+ }
272
+ const [first, ...rest] = candidates;
273
+ return rest.every((id) => equalBytes(id, first)) ? first : null;
274
+ }
275
+
276
+ /**
277
+ * Describe a passkey's P-256 key the way wallet code needs it.
278
+ *
279
+ * Accepts a credential from either `createCredential` path, a copy restored
280
+ * from localStorage, JSON or largeBlob, or a signer's `credential`. The key
281
+ * comes from `publicKey` — `{ x, y }` or SEC1 bytes — or, failing that, from a
282
+ * P-256 `did:key`; where both are present they must agree.
283
+ *
284
+ * Returns null, rather than something that cannot sign, for: RS256 or Ed25519
285
+ * keys; the placeholder `createCredential` writes when it cannot read the key
286
+ * (`synthetic: true`), and one that lost that flag, since its point is not on
287
+ * the curve; and anything unreadable — no credential id, ids that disagree, a
288
+ * malformed key, or no rpId.
289
+ *
290
+ * `rpId` is the one `createCredential` registered under; credentials from
291
+ * before it was recorded fall back to `window.location.hostname`, the default
292
+ * it registers under.
293
+ *
294
+ * @param {unknown} credential
295
+ * @returns {{credentialId: string, rawCredentialId: Uint8Array, x: Uint8Array, y: Uint8Array, rpId: string, userVerification: 'required'}|null}
296
+ * `x` and `y` are 32-byte big-endian coordinates; `credentialId` is the
297
+ * unpadded base64url of `rawCredentialId`.
298
+ */
299
+ export function getP256CredentialDescriptor(credential) {
300
+ if (!credential || typeof credential !== 'object') return null;
301
+
302
+ if (statesOtherThan(credential.algorithm, 'P-256')) return null;
303
+ const { cose } = credential;
304
+ if (
305
+ cose &&
306
+ typeof cose === 'object' &&
307
+ (statesOtherThan(cose.kty, COSE_KTY_EC2) ||
308
+ statesOtherThan(cose.alg, COSE_ALG_ES256) ||
309
+ statesOtherThan(cose.crv, COSE_CRV_P256))
310
+ ) {
311
+ return null;
312
+ }
313
+
314
+ const rawCredentialId = readCredentialId(credential);
315
+ if (!rawCredentialId) return null;
316
+
317
+ const fromKey = readCredentialPublicKey(credential);
318
+ const fromDid = readDidPublicKey(credential.did);
319
+ if (fromKey === null || fromDid === null) return null;
320
+ const point = fromKey ?? fromDid;
321
+ if (!point) return null;
322
+ if (
323
+ fromKey &&
324
+ fromDid &&
325
+ !(equalBytes(fromKey.x, fromDid.x) && equalBytes(fromKey.y, fromDid.y))
326
+ ) {
327
+ return null;
328
+ }
329
+ if (!isOnCurve(point.x, point.y)) return null;
330
+
331
+ const rpId =
332
+ typeof credential.rpId === 'string' && credential.rpId
333
+ ? credential.rpId
334
+ : globalThis.window?.location?.hostname;
335
+ if (typeof rpId !== 'string' || !rpId) return null;
336
+
337
+ return {
338
+ credentialId: bytesToBase64url(rawCredentialId),
339
+ rawCredentialId,
340
+ x: point.x,
341
+ y: point.y,
342
+ rpId,
343
+ userVerification: 'required',
344
+ };
345
+ }
346
+
347
+ function readDescriptor(descriptor) {
348
+ const invalid = () =>
349
+ new WebAuthnIdentityError(
350
+ 'signP256Challenge needs a descriptor from getP256CredentialDescriptor()'
351
+ );
352
+ if (!descriptor || typeof descriptor !== 'object') throw invalid();
353
+
354
+ const rawCredentialId = readBytes(descriptor.rawCredentialId);
355
+ const x = readBytes(descriptor.x);
356
+ const y = readBytes(descriptor.y);
357
+ const { rpId, credentialId } = descriptor;
358
+ if (
359
+ !rawCredentialId?.length ||
360
+ x?.length !== COORDINATE_BYTES ||
361
+ y?.length !== COORDINATE_BYTES ||
362
+ typeof rpId !== 'string' ||
363
+ !rpId ||
364
+ (typeof credentialId === 'string' &&
365
+ credentialId.replace(/=+$/, '') !== bytesToBase64url(rawCredentialId)) ||
366
+ !isOnCurve(x, y)
367
+ ) {
368
+ throw invalid();
369
+ }
370
+ return { rawCredentialId, x, y, rpId };
371
+ }
372
+
373
+ /**
374
+ * r and s from the ASN.1 DER an authenticator returns for ES256 (WebAuthn L2
375
+ * §6.5.5), left-padded to 32 bytes, with s normalised to low-s.
376
+ *
377
+ * DER integers are minimal, so r or s is 31 bytes about once in 256 and 33
378
+ * (a sign byte) about half the time; `derToRawSignature` handles both (#58).
379
+ */
380
+ function readEcdsaSignature(signature) {
381
+ if (!isDerSignature(signature)) {
382
+ throw new WebAuthnVerificationError(
383
+ 'The assertion signature is not an ASN.1 DER ECDSA signature'
384
+ );
385
+ }
386
+ let raw;
387
+ try {
388
+ raw = derToRawSignature(signature);
389
+ } catch (error) {
390
+ throw new WebAuthnVerificationError(error.message, { cause: error });
391
+ }
392
+
393
+ const r = bytesToBigInt(raw.subarray(0, COORDINATE_BYTES));
394
+ let s = bytesToBigInt(raw.subarray(COORDINATE_BYTES));
395
+ if (r === 0n || r >= P256_N || s === 0n || s >= P256_N) {
396
+ throw new WebAuthnVerificationError('Signature r or s is out of range');
397
+ }
398
+ // (r, n − s) verifies wherever (r, s) does; webauthn-sol takes only the low
399
+ // one, and the authenticator returns either.
400
+ if (s > P256_HALF_N) s = P256_N - s;
401
+
402
+ return { r: raw.slice(0, COORDINATE_BYTES), s: bigIntToBytes(s) };
403
+ }
404
+
405
+ async function verifiesWithKey({ x, y, r, s, authenticatorData, clientData }) {
406
+ try {
407
+ const key = await crypto.subtle.importKey(
408
+ 'raw',
409
+ concatBytes(new Uint8Array([0x04]), x, y),
410
+ { name: 'ECDSA', namedCurve: 'P-256' },
411
+ false,
412
+ ['verify']
413
+ );
414
+ const clientDataHash = new Uint8Array(
415
+ await crypto.subtle.digest('SHA-256', clientData)
416
+ );
417
+ return await crypto.subtle.verify(
418
+ { name: 'ECDSA', hash: 'SHA-256' },
419
+ key,
420
+ concatBytes(r, s),
421
+ concatBytes(authenticatorData, clientDataHash)
422
+ );
423
+ } catch {
424
+ return false;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Sign a raw 32-byte challenge — a user operation hash, say — with the passkey
430
+ * a descriptor names, and return the parts a P-256 WebAuthn verifier reads.
431
+ *
432
+ * The challenge is used as the WebAuthn challenge itself, as Calibur passes
433
+ * its hash to webauthn-sol. The request pins `allowCredentials` to the
434
+ * descriptor's credential — whatever `configureWebAuthn` says about
435
+ * discoverable credentials — uses its `rpId` and requires user verification.
436
+ *
437
+ * Refused with a thrown error: a challenge that is not exactly 32 bytes (before
438
+ * any prompt), an assertion from another credential (`rawId` differs), and
439
+ * anything a verifier would refuse — client data that is not `webauthn.get`
440
+ * over this challenge, the UP or UV flag unset, a signature that is not DER or
441
+ * does not verify against the descriptor's x/y.
442
+ *
443
+ * @param {{rawCredentialId: Uint8Array, x: Uint8Array, y: Uint8Array, rpId: string}} descriptor
444
+ * from `getP256CredentialDescriptor`
445
+ * @param {Uint8Array|ArrayBuffer} challenge exactly 32 bytes
446
+ * @returns {Promise<{authenticatorData: Uint8Array, clientDataJSON: string, challengeIndex: number, typeIndex: number, r: Uint8Array, s: Uint8Array}>}
447
+ * `challengeIndex` and `typeIndex` are UTF-8 byte offsets in
448
+ * `clientDataJSON` of `"challenge":"<base64url(challenge), unpadded>"` and
449
+ * `"type":"webauthn.get"`; `r` and `s` are 32 bytes big-endian, `s` low.
450
+ */
451
+ export async function signP256Challenge(descriptor, challenge) {
452
+ const { rawCredentialId, x, y, rpId } = readDescriptor(descriptor);
453
+
454
+ const challengeBytes =
455
+ challenge instanceof ArrayBuffer || ArrayBuffer.isView(challenge)
456
+ ? readBytes(challenge)
457
+ : null;
458
+ if (challengeBytes?.length !== CHALLENGE_BYTES) {
459
+ throw new WebAuthnIdentityError(
460
+ `signP256Challenge needs a challenge of exactly ${CHALLENGE_BYTES} bytes, got ${
461
+ challengeBytes ? `${challengeBytes.length} bytes` : typeof challenge
462
+ }`
463
+ );
464
+ }
465
+
466
+ if (typeof globalThis.navigator?.credentials?.get !== 'function') {
467
+ throw new WebAuthnNotSupportedError();
468
+ }
469
+
470
+ let assertion;
471
+ try {
472
+ assertion = await navigator.credentials.get(
473
+ buildCredentialRequestOptions({
474
+ challenge: challengeBytes,
475
+ rpId,
476
+ credentialId: rawCredentialId,
477
+ userVerification: 'required',
478
+ // The signature is only any use from this credential, so the browser
479
+ // must not offer a different passkey.
480
+ discoverableCredentials: false,
481
+ })
482
+ );
483
+ } catch (error) {
484
+ throw new WebAuthnAuthenticationError(
485
+ error?.name === 'NotAllowedError'
486
+ ? 'Passkey authentication was cancelled or not allowed'
487
+ : `Passkey authentication failed: ${error?.message}`,
488
+ { cause: error }
489
+ );
490
+ }
491
+ if (!assertion) {
492
+ throw new WebAuthnAuthenticationError('Passkey authentication failed');
493
+ }
494
+
495
+ const rawId = readBytes(assertion.rawId);
496
+ if (!rawId || !equalBytes(rawId, rawCredentialId)) {
497
+ throw new WebAuthnAuthenticationError(
498
+ 'The assertion came from a different credential than the descriptor names'
499
+ );
500
+ }
501
+
502
+ const response = assertion.response ?? {};
503
+ const authenticatorData = readBytes(response.authenticatorData);
504
+ const clientData = readBytes(response.clientDataJSON);
505
+ const signature = readBytes(response.signature);
506
+ if (!authenticatorData || !clientData || !signature) {
507
+ throw new WebAuthnVerificationError(
508
+ 'The assertion lacks authenticatorData, clientDataJSON or signature'
509
+ );
510
+ }
511
+
512
+ if (authenticatorData.length < AUTH_DATA_MIN_LENGTH) {
513
+ throw new WebAuthnVerificationError('authenticatorData is too short');
514
+ }
515
+ const flags = authenticatorData[32];
516
+ if (!(flags & FLAG_USER_PRESENT) || !(flags & FLAG_USER_VERIFIED)) {
517
+ throw new WebAuthnVerificationError(
518
+ 'The authenticator did not report user presence and verification'
519
+ );
520
+ }
521
+
522
+ // Fatal and BOM-preserving, so the string encodes back to exactly the bytes
523
+ // that were signed.
524
+ let clientDataJSON;
525
+ let parsed;
526
+ try {
527
+ clientDataJSON = new TextDecoder('utf-8', {
528
+ fatal: true,
529
+ ignoreBOM: true,
530
+ }).decode(clientData);
531
+ parsed = JSON.parse(clientDataJSON);
532
+ } catch (error) {
533
+ throw new WebAuthnVerificationError('clientDataJSON is not valid JSON', {
534
+ cause: error,
535
+ });
536
+ }
537
+
538
+ const encodedChallenge = bytesToBase64url(challengeBytes);
539
+ if (
540
+ parsed?.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET ||
541
+ parsed.challenge !== encodedChallenge
542
+ ) {
543
+ throw new WebAuthnVerificationError(
544
+ 'clientDataJSON is not a webauthn.get over this challenge'
545
+ );
546
+ }
547
+
548
+ // Located as bytes: a verifier slices the UTF-8 encoding at these offsets.
549
+ const typeIndex = indexOfBytes(
550
+ clientData,
551
+ encoder.encode(`"type":"${WEBAUTHN_CLIENT_DATA_TYPES.GET}"`)
552
+ );
553
+ const challengeIndex = indexOfBytes(
554
+ clientData,
555
+ encoder.encode(`"challenge":"${encodedChallenge}"`)
556
+ );
557
+ if (typeIndex < 0 || challengeIndex < 0) {
558
+ throw new WebAuthnVerificationError(
559
+ 'clientDataJSON does not spell out its type and challenge verbatim'
560
+ );
561
+ }
562
+
563
+ const { r, s } = readEcdsaSignature(signature);
564
+ if (!(await verifiesWithKey({ x, y, r, s, authenticatorData, clientData }))) {
565
+ throw new WebAuthnVerificationError(
566
+ "The signature does not verify against the descriptor's public key"
567
+ );
568
+ }
569
+
570
+ return { authenticatorData, clientDataJSON, challengeIndex, typeIndex, r, s };
571
+ }
@@ -140,6 +140,8 @@ async function createWebAuthnVarsigCredential(options = {}) {
140
140
  did,
141
141
  algorithm,
142
142
  cose: { kty, alg, crv },
143
+ // The relying party the credential is scoped to; see createCredential.
144
+ rpId: domain || window.location.hostname,
143
145
  };
144
146
  }
145
147
 
@@ -19,6 +19,7 @@ export function storeWebAuthnVarsigCredential(
19
19
  did: credential.did,
20
20
  algorithm: credential.algorithm,
21
21
  cose: credential.cose || null,
22
+ ...(credential.rpId ? { rpId: credential.rpId } : {}),
22
23
  };
23
24
  localStorage.setItem(key, JSON.stringify(payload));
24
25
  }
@@ -40,6 +41,8 @@ export function loadWebAuthnVarsigCredential(
40
41
  did: parsed.did,
41
42
  algorithm: parsed.algorithm,
42
43
  cose: parsed.cose || null,
44
+ // Absent from credentials stored before rpId was recorded.
45
+ ...(parsed.rpId ? { rpId: parsed.rpId } : {}),
43
46
  };
44
47
  }
45
48
 
@@ -46,9 +46,28 @@ export class WebAuthnDIDProvider {
46
46
  * @param {Uint8Array} credentialInfo.rawCredentialId - Raw credential ID bytes.
47
47
  */
48
48
  constructor(credentialInfo) {
49
- this.credentialId = credentialInfo.credentialId;
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;
50
70
  this.publicKey = credentialInfo.publicKey;
51
- this.rawCredentialId = credentialInfo.rawCredentialId;
52
71
  this.type = IDENTITY_TYPES.WEBAUTHN;
53
72
  }
54
73
 
@@ -282,6 +301,9 @@ export class WebAuthnDIDProvider {
282
301
  ),
283
302
  rawCredentialId: new Uint8Array(credential.rawId),
284
303
  publicKey,
304
+ // The relying party the credential is scoped to. An assertion has to
305
+ // name the same one, and `domain` need not be this page's hostname.
306
+ rpId: domain || window.location.hostname,
285
307
  userId,
286
308
  displayName,
287
309
  // Nothing in this package looks a credential up by handle — recovery
@@ -712,8 +734,9 @@ export class WebAuthnDIDProvider {
712
734
  };
713
735
 
714
736
  webauthnLog('Proof created successfully: %o', {
715
- credentialId: webauthnProof.credentialId.substring(0, 16) + '...',
716
- dataHash: webauthnProof.dataHash.substring(0, 16) + '...',
737
+ // A debug line must not be what fails a signature.
738
+ credentialId: `${String(webauthnProof.credentialId).slice(0, 16)}...`,
739
+ dataHash: `${String(webauthnProof.dataHash).slice(0, 16)}...`,
717
740
  });
718
741
 
719
742
  // Return the proof as a base64url encoded string for OrbitDB
@@ -45,7 +45,11 @@ const log = logger('orbitdb-identity-provider-webauthn-did:restore');
45
45
  * @param {(step: {touch: number, of: number}) => void} [options.onTouch] -
46
46
  * Called before each ceremony, so an application can say which touch this is.
47
47
  * @returns {Promise<{did: string, publicKey: {x: Uint8Array, y: Uint8Array},
48
- * credentialId: Uint8Array, signingKey: Uint8Array, prfInput: 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).
49
53
  */
50
54
  export async function restoreIdentityFromAuthenticator({
51
55
  rpId,
@@ -111,11 +115,18 @@ export async function restoreIdentityFromAuthenticator({
111
115
  );
112
116
  log('restored %s from the authenticator alone', did);
113
117
 
118
+ const rawCredentialId = new Uint8Array(first.rawId);
119
+ const credentialId =
120
+ WebAuthnDIDProvider.arrayBufferToBase64url(rawCredentialId);
114
121
  return {
115
122
  did,
116
123
  publicKey,
117
- credentialId: new Uint8Array(first.rawId),
124
+ credentialId,
125
+ rawCredentialId,
118
126
  signingKey,
119
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 },
120
131
  };
121
132
  }
package/types/index.d.ts CHANGED
@@ -89,6 +89,11 @@ export interface WebAuthnCredentialInfo {
89
89
  credentialId: string;
90
90
  rawCredentialId: Uint8Array;
91
91
  publicKey: WebAuthnPublicKey | Uint8Array;
92
+ /**
93
+ * Relying party id the credential is registered under. Returned by
94
+ * `createCredential`; absent from credentials stored before it was.
95
+ */
96
+ rpId?: string;
92
97
  userId?: string;
93
98
  displayName?: string;
94
99
  /** Random 64-byte WebAuthn user handle, base64url. Not derived from userId. */
@@ -436,9 +441,14 @@ export { VerificationUtils };
436
441
  export interface RestoredIdentity {
437
442
  did: string;
438
443
  publicKey: { x: Uint8Array; y: Uint8Array };
439
- credentialId: Uint8Array;
444
+ /** The credential id as base64url text. Before 0.7.0 this was the bytes. */
445
+ credentialId: string;
446
+ /** The credential id as bytes. */
447
+ rawCredentialId: Uint8Array;
440
448
  signingKey: Uint8Array;
441
449
  prfInput: Uint8Array;
450
+ /** Ready for `OrbitDBWebAuthnIdentityProviderFunction({ webauthnCredential })`. */
451
+ credential: WebAuthnCredentialInfo;
442
452
  }
443
453
 
444
454
  export interface RestoreIdentityOptions {
@@ -18,6 +18,8 @@ export interface WebAuthnVarsigCredential {
18
18
  publicKey: Uint8Array;
19
19
  algorithm: WebAuthnAlgorithm;
20
20
  cose?: unknown;
21
+ /** Relying party id the credential is registered under. */
22
+ rpId?: string;
21
23
  [key: string]: unknown;
22
24
  }
23
25
 
@@ -134,6 +136,56 @@ export function extractPrfSeedFromCredential(
134
136
  { seed: Uint8Array; source: 'prf' } | { seed: null; source: 'none' }
135
137
  >;
136
138
 
139
+ /** A passkey's P-256 key, as wallet code needs it. */
140
+ export interface P256CredentialDescriptor {
141
+ /** Unpadded base64url of `rawCredentialId`. */
142
+ credentialId: string;
143
+ rawCredentialId: Uint8Array;
144
+ /** Public key x coordinate: 32 bytes, big-endian. */
145
+ x: Uint8Array;
146
+ /** Public key y coordinate: 32 bytes, big-endian. */
147
+ y: Uint8Array;
148
+ rpId: string;
149
+ /** `signP256Challenge` always requires user verification. */
150
+ userVerification: 'required';
151
+ }
152
+
153
+ /**
154
+ * An assertion over a 32-byte challenge, in the fields of webauthn-sol's
155
+ * `WebAuthnAuth` (the verifier Uniswap's Calibur uses).
156
+ */
157
+ export interface P256ChallengeSignature {
158
+ authenticatorData: Uint8Array;
159
+ clientDataJSON: string;
160
+ /** UTF-8 byte offset of `"challenge":"<base64url(challenge)>"` in `clientDataJSON`. */
161
+ challengeIndex: number;
162
+ /** UTF-8 byte offset of `"type":"webauthn.get"` in `clientDataJSON`. */
163
+ typeIndex: number;
164
+ /** 32 bytes, big-endian. */
165
+ r: Uint8Array;
166
+ /** 32 bytes, big-endian, normalised to low-s (s ≤ n/2). */
167
+ s: Uint8Array;
168
+ }
169
+
170
+ /**
171
+ * Describe a credential's P-256 key, from either `createCredential` path or a
172
+ * stored copy. `null` for RS256 or Ed25519 keys, the placeholder key written
173
+ * when registration could not read the public key, and anything unreadable.
174
+ */
175
+ export function getP256CredentialDescriptor(
176
+ credential: unknown
177
+ ): P256CredentialDescriptor | null;
178
+
179
+ /**
180
+ * Sign exactly 32 bytes with the descriptor's passkey (`allowCredentials`
181
+ * pinned, user verification required). Rejects an assertion from another
182
+ * credential, and anything that would not verify against `x`/`y`.
183
+ */
184
+ export function signP256Challenge(
185
+ descriptor: P256CredentialDescriptor,
186
+ challenge: Uint8Array | ArrayBuffer
187
+ ): Promise<P256ChallengeSignature>;
188
+
137
189
  export interface WorkerKeystoreClient {
138
190
  initWithPrfSeed(prfSeed: Uint8Array): Promise<void>;
139
191
  /** Derive the Ed25519 signer from the PRF seed inside the worker; only the public half comes back. */