@le-space/orbitdb-identity-provider-webauthn-did 0.7.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,45 @@
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
+
3
43
  ## 0.7.0
4
44
 
5
45
  **One breaking change, in the function 0.6.0 added.** `restoreIdentityFromAuthenticator()`
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
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.7.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
 
@@ -301,6 +301,9 @@ export class WebAuthnDIDProvider {
301
301
  ),
302
302
  rawCredentialId: new Uint8Array(credential.rawId),
303
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,
304
307
  userId,
305
308
  displayName,
306
309
  // Nothing in this package looks a credential up by handle — recovery
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. */
@@ -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. */