Skip to content

Commit c2f353a

Browse files
authored
feat(tempo.ts/wagmi): webAuthn connector tweaks (#108)
1 parent 33df7e5 commit c2f353a

File tree

5 files changed

+124
-38
lines changed

5 files changed

+124
-38
lines changed

.changeset/crisp-parts-feel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tempo.ts": minor
3+
---
4+
5+
**Breaking (`tempo.ts/wagmi`):** Changed default `expiry` of `Connector.webAuthn` connector access keys to one day (previously unlimited).

.changeset/sour-chicken-tie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tempo.ts": minor
3+
---
4+
5+
`tempo.ts/wagmi`: Added ability to pass `expiry` and `limits` to `webAuthn#grantAccessKey`.

.changeset/sparkly-ducks-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tempo.ts": minor
3+
---
4+
5+
**Breaking (`tempo.ts/wagmi`):** Removed `"lax"` option from `Connector.webAuthn#grantAccessKey` connector. The "lax" behavior is now the default. To opt-in to "strict mode", set `strict: true` in the `grantAccessKey` options.

src/ox/KeyAuthorization.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import * as Rlp from 'ox/Rlp'
66
import type { Compute } from '../internal/types.js'
77
import * as SignatureEnvelope from './SignatureEnvelope.js'
88

9-
const defaultExpiry = 0xffffffffffff
10-
119
/**
1210
* Key authorization for provisioning access keys.
1311
*
@@ -43,8 +41,8 @@ export type Rpc = Omit<
4341
'address' | 'signature' | 'type'
4442
> & {
4543
keyId: Address.Address
46-
signature: SignatureEnvelope.SignatureEnvelopeRpc
4744
keyType: SignatureEnvelope.Type
45+
signature: SignatureEnvelope.SignatureEnvelopeRpc
4846
}
4947

5048
/** Signed representation of a Key Authorization. */
@@ -451,16 +449,16 @@ export function toRpc(authorization: Signed): Rpc {
451449
const {
452450
address,
453451
chainId = 0n,
454-
expiry = defaultExpiry,
455-
limits = [],
452+
expiry,
453+
limits,
456454
type,
457455
signature,
458456
} = authorization
459457

460458
return {
461459
chainId: chainId === 0n ? '0x' : Hex.fromNumber(chainId),
462-
expiry: Hex.fromNumber(expiry),
463-
limits: limits.map(({ token, limit }) => ({
460+
expiry: typeof expiry === 'number' ? Hex.fromNumber(expiry) : undefined,
461+
limits: limits?.map(({ token, limit }) => ({
464462
token,
465463
limit: Hex.fromNumber(limit),
466464
})),

src/wagmi/Connector.ts

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,23 @@ export function webAuthn(options: webAuthn.Parameters) {
209209
let account: Account.RootAccount | undefined
210210
let accessKey: Account.AccessKeyAccount | undefined
211211

212+
const defaultAccessKeyOptions = {
213+
expiry: Math.floor(
214+
(Date.now() + 24 * 60 * 60 * 1000) / 1000, // one day
215+
),
216+
strict: false,
217+
}
218+
const accessKeyOptions = (() => {
219+
if (typeof options.grantAccessKey === 'object')
220+
return { ...defaultAccessKeyOptions, ...options.grantAccessKey }
221+
if (options.grantAccessKey === true) return defaultAccessKeyOptions
222+
return undefined
223+
})()
224+
212225
const idbStorage = Storage.idb<{
213-
[key: `accessKey:${string}`]: WebCryptoP256.createKeyPair.ReturnType
226+
[key: `accessKey:${string}`]: WebCryptoP256.createKeyPair.ReturnType & {
227+
keyAuthorization: KeyAuthorization.KeyAuthorization
228+
}
214229
}>()
215230

216231
type Properties = {
@@ -251,10 +266,18 @@ export function webAuthn(options: webAuthn.Parameters) {
251266
account = Account.fromWebAuthnP256(credential)
252267
},
253268
async connect(parameters = {}) {
254-
const { grantAccessKey = false } = options
255269
const capabilities =
256270
'capabilities' in parameters ? (parameters.capabilities ?? {}) : {}
257271

272+
if (
273+
accessKeyOptions?.strict &&
274+
accessKeyOptions.expiry &&
275+
accessKeyOptions.expiry < Date.now() / 1000
276+
)
277+
throw new Error(
278+
`\`grantAccessKey.expiry = ${accessKeyOptions.expiry}\` is in the past (${new Date(accessKeyOptions.expiry * 1000).toLocaleString()}). Please provide a valid expiry.`,
279+
)
280+
258281
// We are going to need to find:
259282
// - a WebAuthn `credential` to instantiate an account
260283
// - optionally, a `keyPair` to use as the access key for the account
@@ -286,7 +309,7 @@ export function webAuthn(options: webAuthn.Parameters) {
286309

287310
// Get key pair (access key) to use for the account.
288311
const keyPair = await (async () => {
289-
if (!grantAccessKey) return undefined
312+
if (!accessKeyOptions) return undefined
290313
return await WebCryptoP256.createKeyPair()
291314
})()
292315

@@ -303,15 +326,15 @@ export function webAuthn(options: webAuthn.Parameters) {
303326
if (credential) {
304327
// Get key pair (access key) to use for the account.
305328
const keyPair = await (async () => {
306-
if (!grantAccessKey) return undefined
329+
if (!accessKeyOptions) return undefined
307330
const address = Address.fromPublicKey(
308331
PublicKey.fromHex(credential.publicKey),
309332
)
310333
return await idbStorage.getItem(`accessKey:${address}`)
311334
})()
312335

313-
// If the access key policy is lax, return the credential and key pair (if exists).
314-
if (grantAccessKey === 'lax') return { credential, keyPair }
336+
// If the access key provisioning is not in strict mode, return the credential and key pair (if exists).
337+
if (!accessKeyOptions?.strict) return { credential, keyPair }
315338

316339
// If a key pair is found, return the credential and key pair.
317340
if (keyPair) return { credential, keyPair }
@@ -328,21 +351,25 @@ export function webAuthn(options: webAuthn.Parameters) {
328351
{
329352
// Get key pair (access key) to use for the account.
330353
const keyPair = await (async () => {
331-
if (!grantAccessKey) return undefined
354+
if (!accessKeyOptions) return undefined
332355
return await WebCryptoP256.createKeyPair()
333356
})()
334357

335358
// If we are provisioning an access key, we will need to sign a key authorization.
336359
// We will need the hash (digest) to sign, and the address of the access key to construct the key authorization.
337-
const { accessKeyAddress, hash } = await (async () => {
360+
const { hash, keyAuthorization_unsigned } = await (async () => {
338361
if (!keyPair)
339362
return { accessKeyAddress: undefined, hash: undefined }
340363
const accessKeyAddress = Address.fromPublicKey(keyPair.publicKey)
341-
const hash = KeyAuthorization.getSignPayload({
364+
const keyAuthorization_unsigned = KeyAuthorization.from({
365+
...accessKeyOptions,
342366
address: accessKeyAddress,
343367
type: 'p256',
344368
})
345-
return { accessKeyAddress, hash, keyPair }
369+
const hash = KeyAuthorization.getSignPayload(
370+
keyAuthorization_unsigned,
371+
)
372+
return { keyAuthorization_unsigned, hash }
346373
})()
347374

348375
// If no active credential, we will attempt to load the last active credential from storage.
@@ -363,16 +390,15 @@ export function webAuthn(options: webAuthn.Parameters) {
363390
rpId: options.getOptions?.rpId ?? options.rpId,
364391
})
365392

366-
const keyAuthorization = accessKeyAddress
393+
const keyAuthorization = keyAuthorization_unsigned
367394
? KeyAuthorization.from({
368-
address: accessKeyAddress,
395+
...keyAuthorization_unsigned,
369396
signature: SignatureEnvelope.from({
370397
metadata: credential.metadata,
371398
signature: credential.signature,
372399
publicKey: PublicKey.fromHex(credential.publicKey),
373400
type: 'webAuthn',
374401
}),
375-
type: 'p256',
376402
})
377403
: undefined
378404

@@ -399,19 +425,42 @@ export function webAuthn(options: webAuthn.Parameters) {
399425
storage: Storage.from(config.storage as never),
400426
})
401427

428+
// If we are reconnecting, check if the access key is expired.
429+
if (parameters.isReconnecting) {
430+
if (
431+
'keyAuthorization' in keyPair &&
432+
keyPair.keyAuthorization.expiry &&
433+
keyPair.keyAuthorization.expiry < Date.now() / 1000
434+
) {
435+
// remove any pending key authorizations from storage.
436+
await account?.storage.removeItem('pendingKeyAuthorization')
437+
438+
const message = `Access key expired (on ${new Date(keyPair.keyAuthorization.expiry * 1000).toLocaleString()}).`
439+
accessKey = undefined
440+
441+
// if in strict mode, disconnect and throw an error.
442+
if (accessKeyOptions?.strict) {
443+
await this.disconnect()
444+
throw new Error(message)
445+
}
446+
// otherwise, fall back to the root account.
447+
console.warn(`${message} Falling back to passkey.`)
448+
}
449+
}
402450
// If we are not reconnecting, orchestrate the provisioning of the access key.
403-
if (!parameters.isReconnecting) {
451+
else {
404452
const keyAuth =
405-
keyAuthorization ?? (await account.signKeyAuthorization(accessKey))
453+
keyAuthorization ??
454+
(await account.signKeyAuthorization(accessKey, accessKeyOptions))
406455

407456
await account.storage.setItem('pendingKeyAuthorization', keyAuth)
408457
await idbStorage.setItem(
409458
`accessKey:${account.address.toLowerCase()}`,
410-
keyPair,
459+
{ ...keyPair, keyAuthorization: keyAuth },
411460
)
412461
}
413-
// If we are granting an access key, throw an error if the access key is not provisioned.
414-
} else if (grantAccessKey === true) {
462+
// If we are granting an access key and it is in strict mode, throw an error if the access key is not provisioned.
463+
} else if (accessKeyOptions?.strict) {
415464
await config.storage?.removeItem('webAuthn.activeCredential')
416465
throw new Error('access key not found')
417466
}
@@ -430,6 +479,7 @@ export function webAuthn(options: webAuthn.Parameters) {
430479
},
431480
async disconnect() {
432481
await config.storage?.removeItem('webAuthn.activeCredential')
482+
config.emitter.emit('disconnect')
433483
account = undefined
434484
},
435485
async getAccounts() {
@@ -476,8 +526,36 @@ export function webAuthn(options: webAuthn.Parameters) {
476526
const transport = transports[chain.id]
477527
if (!transport) throw new ChainNotConfiguredError()
478528

529+
const targetAccount = await (async () => {
530+
if (!accessKey) return account
531+
532+
const item = await idbStorage.getItem(
533+
`accessKey:${accessKey.address.toLowerCase()}`,
534+
)
535+
if (
536+
item?.keyAuthorization.expiry &&
537+
item.keyAuthorization.expiry < Date.now() / 1000
538+
) {
539+
// remove any pending key authorizations from storage.
540+
await account?.storage.removeItem('pendingKeyAuthorization')
541+
542+
const message = `Access key expired (on ${new Date(item.keyAuthorization.expiry * 1000).toLocaleString()}).`
543+
544+
// if in strict mode, disconnect and throw an error.
545+
if (accessKeyOptions?.strict) {
546+
await this.disconnect()
547+
throw new Error(message)
548+
}
549+
550+
// otherwise, fall back to the root account.
551+
console.warn(`${message} Falling back to passkey.`)
552+
return account
553+
}
554+
return accessKey
555+
})()
556+
479557
return createClient({
480-
account: accessKey ?? account,
558+
account: targetAccount,
481559
chain: chain as Chain,
482560
transport: walletNamespaceCompat(transport),
483561
})
@@ -503,19 +581,14 @@ export namespace webAuthn {
503581
| Pick<WebAuthnP256.getCredential.Parameters, 'getFn' | 'rpId'>
504582
| undefined
505583
/**
506-
* Whether or not to grant an access key upon connection.
507-
*
508-
* - `true`: The account MUST have an access key provisioned.
509-
* On failure, the connection will fail.
510-
* - `"lax"`: The account MAY have an access key provisioned.
511-
* On failure, the connection will succeed, but the access key will not be provisioned
512-
* and must be provisioned manually if the user wants to enforce access keys.
513-
* - `false`: The account WILL NOT have an access key provisioned. The access key must be
514-
* provisioned manually if the user wants to enforce access keys.
515-
*
516-
* @default false
584+
* Whether or not to grant an access key upon connection, and optionally, expiry + limits to assign to the key.
517585
*/
518-
grantAccessKey?: boolean | 'lax'
586+
grantAccessKey?:
587+
| boolean
588+
| (Pick<KeyAuthorization.KeyAuthorization, 'expiry' | 'limits'> & {
589+
/** Whether or not to throw an error and disconnect if the access key is not provisioned or is expired. */
590+
strict?: boolean | undefined
591+
})
519592
/** Public key manager. */
520593
keyManager: KeyManager.KeyManager
521594
/** The RP ID to use for WebAuthn. */

0 commit comments

Comments
 (0)