From 8ef830351c217fc02ac3a1433b5c83b9a82e7493 Mon Sep 17 00:00:00 2001 From: Big Boss Date: Fri, 28 Jun 2024 15:36:31 -0500 Subject: [PATCH] handle non resident keys (#498) * add byteaToBase64 * handle non-resident keys, enforce new keys are residents --- .../components/checkout-confirm-button.tsx | 6 +- .../account/settings/backup/confirm.tsx | 6 +- .../account/settings/backup/index.tsx | 6 +- .../passkey/RecoverWithPasskey.tsx | 4 +- packages/app/features/send/confirm/index.tsx | 277 ------------------ packages/app/features/send/confirm/screen.tsx | 5 + packages/app/utils/byteaToBase64.test.ts | 20 ++ packages/app/utils/byteaToBase64.ts | 13 + packages/app/utils/passkeys.test.ts | 2 + packages/app/utils/passkeys.ts | 4 +- .../utils/useUserOpTransferMutation.test.ts | 2 +- .../app/utils/useUserOpTransferMutation.ts | 33 ++- packages/app/utils/userop.ts | 30 +- .../src/ExpoPasskeysModule.web.ts | 21 +- packages/daimo-expo-passkeys/src/index.ts | 8 +- packages/daimo-expo-passkeys/src/utils.ts | 10 +- packages/shovel/etc/config.json | 1 - .../test/__snapshots__/index.test.ts.snap | 3 +- 18 files changed, 147 insertions(+), 304 deletions(-) delete mode 100644 packages/app/features/send/confirm/index.tsx create mode 100644 packages/app/utils/byteaToBase64.test.ts create mode 100644 packages/app/utils/byteaToBase64.ts diff --git a/packages/app/features/account/sendtag/checkout/components/checkout-confirm-button.tsx b/packages/app/features/account/sendtag/checkout/components/checkout-confirm-button.tsx index 8bb1616e0..197d51f51 100644 --- a/packages/app/features/account/sendtag/checkout/components/checkout-confirm-button.tsx +++ b/packages/app/features/account/sendtag/checkout/components/checkout-confirm-button.tsx @@ -48,6 +48,10 @@ export function ConfirmButton({ const media = useMedia() const { updateProfile } = useUser() const { data: sendAccount } = useSendAccount() + const webauthnCreds = + sendAccount?.send_account_credentials + .filter((c) => !!c.webauthn_credentials) + .map((c) => c.webauthn_credentials as NonNullable) ?? [] //Connect const chainId = baseMainnetClient.chain.id const pendingTags = usePendingTags() @@ -193,7 +197,7 @@ export function ConfirmButton({ try { throwIf(userOpError) assert(!!userOp, 'User op is required') - await sendUserOp({ userOp }) + await sendUserOp({ userOp, webauthnCreds }) } catch (e) { setError(e.message.split('.').at(0)) } diff --git a/packages/app/features/account/settings/backup/confirm.tsx b/packages/app/features/account/settings/backup/confirm.tsx index 3bc25cd2c..50de179d5 100644 --- a/packages/app/features/account/settings/backup/confirm.tsx +++ b/packages/app/features/account/settings/backup/confirm.tsx @@ -104,6 +104,10 @@ const AddSignerButton = ({ webauthnCred }: { webauthnCred: Tables<'webauthn_cred const keySlot = sendAccount?.send_account_credentials?.find( (c) => c.webauthn_credentials?.raw_credential_id === webauthnCred.raw_credential_id )?.key_slot + const webauthnCreds = + sendAccount?.send_account_credentials + .filter((c) => !!c.webauthn_credentials && c.key_slot !== keySlot) + .map((c) => c.webauthn_credentials as NonNullable) ?? [] const router = useRouter() const form = useForm() const { @@ -194,7 +198,7 @@ const AddSignerButton = ({ webauthnCred }: { webauthnCred: Tables<'webauthn_cred assert(!!userOp, 'User op is required') const { receipt: { transactionHash }, - } = await sendUserOp({ userOp }) + } = await sendUserOp({ userOp, webauthnCreds }) console.log('sent user op', transactionHash) toast.show('Success!') router.replace('/account/settings/backup') diff --git a/packages/app/features/account/settings/backup/index.tsx b/packages/app/features/account/settings/backup/index.tsx index 00560331a..4dd8be3ee 100644 --- a/packages/app/features/account/settings/backup/index.tsx +++ b/packages/app/features/account/settings/backup/index.tsx @@ -344,6 +344,10 @@ const RemovePasskeyConfirmation = ({ error: sendAccountError, isLoading: isLoadingSendAccount, } = useSendAccount() + const webauthnCreds = + sendAccount?.send_account_credentials + .filter((c) => !!c.webauthn_credentials) + .map((c) => c.webauthn_credentials as NonNullable) ?? [] const { data: usdcBal, error: usdcBalError, @@ -440,7 +444,7 @@ const RemovePasskeyConfirmation = ({ assert((usdcBal?.value ?? 0n) > 0n, 'No USDC balance to pay for gas fees') assert(!!userOp, 'User op is required') - await sendUserOp({ userOp }) + await sendUserOp({ userOp, webauthnCreds }) } const { error } = await supabase diff --git a/packages/app/features/auth/account-recovery/passkey/RecoverWithPasskey.tsx b/packages/app/features/auth/account-recovery/passkey/RecoverWithPasskey.tsx index a733a6633..4abfe60a9 100644 --- a/packages/app/features/auth/account-recovery/passkey/RecoverWithPasskey.tsx +++ b/packages/app/features/auth/account-recovery/passkey/RecoverWithPasskey.tsx @@ -17,8 +17,10 @@ interface Props { export default function RecoverWithPasskey(props: Props) { const onPress = async () => { + const rawIdsB64: { id: string; userHandle: string }[] = [] // this user is anon so only resident keys are allowed const { encodedWebAuthnSig, accountName, keySlot } = await signChallenge( - props.challengeData.challenge as `0x${string}` + props.challengeData.challenge as `0x${string}`, + rawIdsB64 ) // SendVerifier.verifySignature expects the first byte to be the passkey keySlot, followed by the signature diff --git a/packages/app/features/send/confirm/index.tsx b/packages/app/features/send/confirm/index.tsx deleted file mode 100644 index e6171e50e..000000000 --- a/packages/app/features/send/confirm/index.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { - useToastController, - Paragraph, - Container, - Spinner, - XStack, - YStack, - Stack, - Button, - Label, - Avatar, -} from '@my/ui' - -import { useSendAccounts } from 'app/utils/send-accounts' -import { useAccountNonce } from 'app/utils/userop' -import { assert } from 'app/utils/assert' - -import { baseMainnet } from '@my/wagmi' -import { useBalance } from 'wagmi' -// @ts-expect-error some work to do here -import { useSendParams } from 'app/routers/params' -import { useForm } from 'react-hook-form' -import { formFields } from 'app/utils/SchemaForm' -import { useState } from 'react' -import { z } from 'zod' -import { useProfileLookup } from 'app/utils/useProfileLookup' -import { type Hex, parseUnits, isAddress } from 'viem' -import { - useGenerateTransferUserOp, - useUserOpGasEstimate, - useUserOpTransferMutation, -} from 'app/utils/useUserOpTransferMutation' -import { useLink } from 'solito/link' -import { useRouter } from 'solito/router' -import { coins } from 'app/data/coins' -import { IconAccount } from 'app/components/icons' -import { IconCoin } from 'app/components/icons/IconCoin' - -type ProfileProp = NonNullable['data']> - -const SendConfirmSchema = z.object({ - amount: formFields.number, - token: formFields.select, - recipient: formFields.text, -}) - -export function SendConfirmScreen() { - const { - params: { recipient, sendToken: tokenParam, amount: amountParam }, - } = useSendParams() - const { data: profile, isLoading, error } = useProfileLookup('tag', recipient) - const router = useRouter() - - if (isLoading) return - if (error) throw new Error(error.message) - if (!profile) { - router.replace({ - pathname: '/send', - query: { recipient, sendToken: tokenParam, amount: amountParam }, - }) - return null - } - - return -} - -export function SendConfirm({ profile }: { profile: ProfileProp }) { - const toast = useToastController() - const form = useForm>() - const { data: sendAccounts } = useSendAccounts() - const sendAccount = sendAccounts?.[0] - const [sentUserOpTxHash, setSentUserOpTxHash] = useState() - const { - params: { sendToken: tokenParam, amount: amountParam, recipient }, - } = useSendParams() - - const router = useRouter() - const { - data: balance, - isPending: balanceIsPending, - error: balanceError, - refetch: balanceRefetch, - } = useBalance({ - address: sendAccount?.address, - token: tokenParam === 'eth' ? undefined : tokenParam, - query: { enabled: !!sendAccount }, - chainId: baseMainnet.id, - }) - - const amount = parseUnits((amountParam ?? '0').toString(), balance?.decimals ?? 0) - const { data: nonce, error: nonceError } = useAccountNonce({ sender: sendAccount?.address }) - const { data: userOp } = useGenerateTransferUserOp({ - sender: sendAccount?.address, - // @ts-expect-error some work to do here - to: profile?.address, - token: tokenParam === 'eth' ? undefined : tokenParam, - amount: BigInt(amount), - nonce: nonce ?? 0n, - }) - - const { data: gasEstimate } = useUserOpGasEstimate({ userOp }) - const { mutateAsync: sendUserOp } = useUserOpTransferMutation() - - const sentTxLink = useLink({ - href: `${baseMainnet.blockExplorers.default.url}/tx/${sentUserOpTxHash}`, - }) - - console.log('gasEstimate', gasEstimate) - console.log('userOp', userOp) - - // need balance to check if user has enough to send - - const canSubmit = - Number(amountParam) > 0 && - coins.some((coin) => coin.token === tokenParam) && - (balance?.value ?? BigInt(0) >= amount) - - async function onSubmit() { - try { - assert(!!userOp, 'User op is required') - assert(!!balance, 'Balance is not available') - assert(nonceError === null, `Failed to get nonce: ${nonceError}`) - assert(nonce !== undefined, 'Nonce is not available') - - assert(balance.value >= amount, 'Insufficient balance') - const sender = sendAccount?.address as `0x${string}` - assert(isAddress(sender), 'No sender address') - - const receipt = await sendUserOp({ - userOp, - }) - assert(receipt.success, 'Failed to send user op') - setSentUserOpTxHash(receipt.receipt.transactionHash) - toast.show(`Sent user op ${receipt.receipt.transactionHash}!`) - balanceRefetch() - } catch (e) { - console.error(e) - toast.show('Failed to send user op') - form.setError('amount', { type: 'custom', message: `${e}` }) - } - } - - return ( - - - - - - - - router.push({ - pathname: '/send', - query: { recipient, sendToken: tokenParam, amount: amountParam }, - }) - } - > - edit - - - - - - - - - - - - {profile?.name} - - - @{profile?.tag} - - - - - - - - - - router.push({ - pathname: '/send', - query: { recipient, sendToken: tokenParam, amount: amountParam }, - }) - } - > - edit - - - - - {amountParam} - - {(() => { - const coin = coins.find((coin) => coin.token === tokenParam) - if (coin) { - return - } - return null - })()} - - - - {/* TODO add this back when backend is ready - - - setParams({ note: text }, { webBehavior: 'replace' })} - fontSize={20} - fontWeight="400" - lineHeight={1} - color="$color12" - borderColor="transparent" - outlineColor="transparent" - $theme-light={{ bc: '$gray3Light' }} - br={'$3'} - bc="$metalTouch" - hoverStyle={{ - borderColor: 'transparent', - outlineColor: 'transparent', - }} - focusStyle={{ - borderColor: 'transparent', - outlineColor: 'transparent', - }} - fontFamily="$mono" - /> - */} - - - - - - ) -} diff --git a/packages/app/features/send/confirm/screen.tsx b/packages/app/features/send/confirm/screen.tsx index 3d5235e9f..ce5100098 100644 --- a/packages/app/features/send/confirm/screen.tsx +++ b/packages/app/features/send/confirm/screen.tsx @@ -65,6 +65,10 @@ export function SendConfirmScreen() { export function SendConfirm({ profile }: { profile: ProfileProp }) { const { data: sendAccount } = useSendAccount() + const webauthnCreds = + sendAccount?.send_account_credentials + .filter((c) => !!c.webauthn_credentials) + .map((c) => c.webauthn_credentials as NonNullable) ?? [] const [sentTxHash, setSentTxHash] = useState() const [queryParams] = useSendScreenParams() const { sendToken, recipient, idType } = queryParams @@ -134,6 +138,7 @@ export function SendConfirm({ profile }: { profile: ProfileProp }) { const receipt = await sendUserOp({ userOp, + webauthnCreds, }) assert(receipt.success, 'Failed to send user op') setSentTxHash(receipt.receipt.transactionHash) diff --git a/packages/app/utils/byteaToBase64.test.ts b/packages/app/utils/byteaToBase64.test.ts new file mode 100644 index 000000000..37c749c71 --- /dev/null +++ b/packages/app/utils/byteaToBase64.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@jest/globals' + +import { byteaToBase64 } from './byteaToBase64' + +describe('test byteaToBase64', () => { + it('test byteaToBase64', () => { + expect(byteaToBase64('\\x')).toBe('') // empty string + expect(byteaToBase64('\\x1234')).toBe('EjQ=') // single character + expect(byteaToBase64('\\x12345678')).toBe('EjRWeA==') // two characters + expect(byteaToBase64('\\x1234567890')).toBe('EjRWeJA=') // three characters + }) + it('fails on invalid input', () => { + // @ts-expect-error Testing with null or empty string + expect(() => byteaToBase64('invalid-string')).toThrow('Hex string must start with \\x') + // @ts-expect-error Testing with null or empty string + expect(() => byteaToBase64('0x12345678901234567890123456789012345678901234')).toThrow( + 'Hex string must start with \\x' + ) + }) +}) diff --git a/packages/app/utils/byteaToBase64.ts b/packages/app/utils/byteaToBase64.ts new file mode 100644 index 000000000..64898290d --- /dev/null +++ b/packages/app/utils/byteaToBase64.ts @@ -0,0 +1,13 @@ +import { base64 } from '@scure/base' +import { assert } from './assert' +import { byteaToBytes } from './byteaToBytes' + +/** + * Converts a Postgres bytea string to a base64 string + * @param str + * @returns base64 string + */ +export function byteaToBase64(str: `\\x${string}`): string { + assert(str.startsWith('\\x'), 'Hex string must start with \\x') + return base64.encode(byteaToBytes(str)) +} diff --git a/packages/app/utils/passkeys.test.ts b/packages/app/utils/passkeys.test.ts index cebfa204a..8f9bb77f8 100644 --- a/packages/app/utils/passkeys.test.ts +++ b/packages/app/utils/passkeys.test.ts @@ -90,6 +90,7 @@ const expectedParsedAssertions = [ const mockAssertions: [SignResult, (typeof expectedParsedAssertions)[number]][] = [ [ { + id: '1', passkeyName: 'sendappuser.1', rawClientDataJSONB64: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWVc1dmRHaGxjaUJqYUdGc2JHVnVaMlUiLCJvcmlnaW4iOiJodHRwczovL3NlbmRhcHAubG9jYWxob3N0In0=', @@ -221,6 +222,7 @@ const createResult = { } const signResult = { + id: '1', passkeyName: 'test33.128', rawAuthenticatorDataB64: 'izwk775fdfZnAPIbCJXH2QqOQs1ZeTxiGxM/cExRMsQdAAAAAA==', rawClientDataJSONB64: diff --git a/packages/app/utils/passkeys.ts b/packages/app/utils/passkeys.ts index 04b774c8a..bde770f9e 100644 --- a/packages/app/utils/passkeys.ts +++ b/packages/app/utils/passkeys.ts @@ -117,7 +117,9 @@ export function parseSignResponse(result: SignResult) { const derSig = base64.decode(result.signatureB64) const rawAuthenticatorData = base64.decode(result.rawAuthenticatorDataB64) const passkeyName = result.passkeyName - const [accountName, keySlotStr] = passkeyName.split('.') // Assumes account name does not have periods (.) in it. + // not ideal to handle null case but this is due to a few send accounts being opened with non-resident passkeys (which have no userHandle) + // still assert that the passkey name is valid since it's required for the user to be able to sign user ops + const [accountName, keySlotStr] = passkeyName?.split('.') ?? [] // Assumes account name does not have periods (.) in it. assert(!!accountName && !!keySlotStr, 'Invalid passkey name') const keySlot = Number.parseInt(keySlotStr, 10) const clientDataJSON = Buffer.from(base64.decode(result.rawClientDataJSONB64)).toString('utf-8') diff --git a/packages/app/utils/useUserOpTransferMutation.test.ts b/packages/app/utils/useUserOpTransferMutation.test.ts index 7d2c6090f..46829ffeb 100644 --- a/packages/app/utils/useUserOpTransferMutation.test.ts +++ b/packages/app/utils/useUserOpTransferMutation.test.ts @@ -109,7 +109,7 @@ describe('useUserOpTransferMutation', () => { expect(result.current).toBeDefined() await act(async () => { - await result.current.mutateAsync({ userOp, validUntil: 0 }) + await result.current.mutateAsync({ userOp, validUntil: 0, webauthnCreds: [] }) jest.runAllTimers() }) expect(signUserOp).toHaveBeenCalledTimes(1) diff --git a/packages/app/utils/useUserOpTransferMutation.ts b/packages/app/utils/useUserOpTransferMutation.ts index 406001ee3..fbdf403b2 100644 --- a/packages/app/utils/useUserOpTransferMutation.ts +++ b/packages/app/utils/useUserOpTransferMutation.ts @@ -7,21 +7,17 @@ import { tokenPaymasterAddress, } from '@my/wagmi' import { useMutation, useQuery, type UseQueryResult } from '@tanstack/react-query' -import { - getRequiredPrefund, - getUserOperationHash, - type GetUserOperationReceiptReturnType, - type UserOperation, -} from 'permissionless' +import { getRequiredPrefund, getUserOperationHash, type UserOperation } from 'permissionless' import { encodeFunctionData, erc20Abi, - isAddress, - type Hex, formatUnits, + isAddress, type CallExecutionError, + type Hex, } from 'viem' import { assert } from './assert' +import { byteaToBase64 } from './byteaToBase64' import { signUserOp } from './userop' // default user op with preset gas values that work @@ -46,9 +42,22 @@ export const defaultUserOp: Pick< } export type UseUserOpTransferMutationArgs = { + /** + * The user operation to send. + */ userOp: UserOperation<'v0.7'> + /** + * The valid until epoch timestamp for the user op. + */ validUntil?: number + /** + * The signature version of the user op. + */ version?: number + /** + * The list of send account credentials to use for signing the user op. + */ + webauthnCreds: { raw_credential_id: `\\x${string}`; name: string }[] } /** @@ -67,7 +76,8 @@ export async function sendUserOpTransfer({ userOp, version, validUntil, -}: UseUserOpTransferMutationArgs): Promise { + webauthnCreds, +}: UseUserOpTransferMutationArgs) { const chainId = baseMainnetClient.chain.id const entryPoint = entryPointAddress[chainId] const userOpHash = getUserOperationHash({ @@ -94,6 +104,11 @@ export async function sendUserOpTransfer({ userOpHash, version, validUntil, + allowedCredentials: + webauthnCreds?.map((c) => ({ + id: byteaToBase64(c.raw_credential_id), + userHandle: c.name, + })) ?? [], }) const hash = await baseMainnetBundlerClient.sendUserOperation({ diff --git a/packages/app/utils/userop.ts b/packages/app/utils/userop.ts index 76a52c73c..5ec6636bc 100644 --- a/packages/app/utils/userop.ts +++ b/packages/app/utils/userop.ts @@ -126,14 +126,25 @@ export function generateChallenge({ /** * Signs a challenge using the user's passkey and returns the signature in a format that matches the ABI of a signature * struct for the SendVerifier contract. + * @param challenge - The challenge to sign encoded as a 0x-prefixed hex string. + * @param rawIdsB64 - The list of raw ids to use for signing. Required for Android and Chrome. + * @returns The signature in a format that matches the ABI of a signature struct for the SendVerifier contract. */ -export async function signChallenge(challenge: Hex) { +export async function signChallenge( + challenge: Hex, + allowedCredentials: { id: string; userHandle: string }[] +) { const challengeBytes = hexToBytes(challenge) const challengeB64 = Buffer.from(challengeBytes).toString('base64') const sign = await signWithPasskey({ domain: window.location.hostname, challengeB64, + rawIdsB64: allowedCredentials.map(({ id }) => id), // pass the raw ids to the authenticator }) + // handle if a non-resident passkey is used so no userHandle is returned + sign.passkeyName = + sign.passkeyName ?? allowedCredentials.find(({ id }) => id === sign.id)?.userHandle ?? '' + assert(!!sign.passkeyName, 'No passkey name found') const signResult = parseSignResponse(sign) const clientDataJSON = signResult.clientDataJSON const authenticatorData = bytesToHex(signResult.rawAuthenticatorData) @@ -157,6 +168,18 @@ export async function signChallenge(challenge: Hex) { [webauthnSig] ) assert(isHex(encodedWebAuthnSig), 'Invalid encodedWebAuthnSig') + + // @todo: verify signature with user's identifier to ensure it's the correct passkey + // const encodedWebAuthnSigBytes = hexToBytes(encodedWebAuthnSig) + // const newEncodedWebAuthnSigBytes = new Uint8Array(encodedWebAuthnSigBytes.length + 1) + // newEncodedWebAuthnSigBytes[0] = keySlot + // newEncodedWebAuthnSigBytes.set(encodedWebAuthnSigBytes, 1) + // const verified = await verifySignature(challenge, bytesToHex(newEncodedWebAuthnSigBytes), [ + // '0x5BCEE51E9210DAF159CC89BCFDA7FF0AE8AF0881A67D91082503BA90106878D0', + // '0x02CC25B94834CD8214E579356848281F286DD9AED9E5E4D7DD58353990ADD661', + // ]) + // console.log('verified', verified) + return { keySlot: signResult.keySlot, accountName: signResult.accountName, @@ -171,13 +194,16 @@ export async function signUserOp({ userOpHash, version, validUntil, + allowedCredentials, }: { userOpHash: Hex version?: number validUntil?: number + allowedCredentials?: { id: string; userHandle: string }[] }) { version = version ?? USEROP_VERSION validUntil = validUntil ?? Math.floor((Date.now() + 1000 * 120) / 1000) // default 120 seconds (2 minutes) + allowedCredentials = allowedCredentials ?? [] assert(version === USEROP_VERSION, 'version must be 1') assert(typeof validUntil === 'number', 'validUntil must be a number') assert( @@ -189,7 +215,7 @@ export async function signUserOp({ version, validUntil, }) - const { encodedWebAuthnSig, keySlot } = await signChallenge(challenge) + const { encodedWebAuthnSig, keySlot } = await signChallenge(challenge, allowedCredentials) const signature = concat([ versionBytes, validUntilBytes, diff --git a/packages/daimo-expo-passkeys/src/ExpoPasskeysModule.web.ts b/packages/daimo-expo-passkeys/src/ExpoPasskeysModule.web.ts index 53b35ee48..58772fc8f 100644 --- a/packages/daimo-expo-passkeys/src/ExpoPasskeysModule.web.ts +++ b/packages/daimo-expo-passkeys/src/ExpoPasskeysModule.web.ts @@ -1,6 +1,6 @@ /// -import { base64 } from '@scure/base' +import { base64, base64urlnopad } from '@scure/base' /** * Check if WebAuthn is available. @@ -48,8 +48,8 @@ const ExpoPasskeysModuleWeb = { }, ], authenticatorSelection: { - authenticatorAttachment: 'platform', userVerification: 'required', + requireResidentKey: true, }, } as PublicKeyCredentialCreationOptions @@ -68,9 +68,11 @@ const ExpoPasskeysModuleWeb = { async signWithPasskey( domain: string, - challengeBase64: string + challengeBase64: string, + rawIdsB64: string[] ): Promise<{ - passkeyName: string + id: string + passkeyName: string | null signature: string rawAuthenticatorDataB64: string rawClientDataJSONB64: string @@ -83,6 +85,11 @@ const ExpoPasskeysModuleWeb = { const publicKeyCredentialRequestOptions = { challenge, rpId: domain, + userVerification: 'required', + allowCredentials: rawIdsB64.map((rawIdB64) => ({ + id: base64.decode(rawIdB64), + type: 'public-key', + })), } as PublicKeyCredentialRequestOptions const assertion = (await navigator.credentials.get({ @@ -98,9 +105,13 @@ const ExpoPasskeysModuleWeb = { new Uint8Array(assertion.response.authenticatorData) ) const rawClientDataJSONB64 = base64.encode(new Uint8Array(assertion.response.clientDataJSON)) - const passkeyName = decoder.decode(assertion.response.userHandle as ArrayBuffer) + const passkeyName = assertion.response.userHandle + ? decoder.decode(assertion.response.userHandle as ArrayBuffer) + : null + const id = base64.encode(base64urlnopad.decode(assertion.id)) return { + id, passkeyName, signature, rawAuthenticatorDataB64, diff --git a/packages/daimo-expo-passkeys/src/index.ts b/packages/daimo-expo-passkeys/src/index.ts index ae6d1df68..97878a9ea 100644 --- a/packages/daimo-expo-passkeys/src/index.ts +++ b/packages/daimo-expo-passkeys/src/index.ts @@ -90,7 +90,9 @@ export async function signWithPasskey(request: SignRequest): Promise case 'ios': { const ret = await ExpoPasskeysModule.signWithPasskey(request.domain, request.challengeB64) const userIDstr = new TextDecoder('utf-8').decode(base64.decode(ret.userID)) + // @todo: add support for rawIdsB64 and retuning the id return { + id: '', passkeyName: userIDstr, rawClientDataJSONB64: ret.rawClientDataJSON, rawAuthenticatorDataB64: ret.rawAuthenticatorData, @@ -103,7 +105,9 @@ export async function signWithPasskey(request: SignRequest): Promise const userIDstr = new TextDecoder('utf-8').decode( base64.decode(toBase64(ret.response.userHandle)) ) + // @todo: add support for rawIdsB64 and retuning the id return { + id: '', passkeyName: userIDstr, rawClientDataJSONB64: toBase64(ret.response.clientDataJSON), rawAuthenticatorDataB64: toBase64(ret.response.authenticatorData), @@ -113,9 +117,11 @@ export async function signWithPasskey(request: SignRequest): Promise case 'web': { const ret = await (ExpoPasskeysModule as typeof ExpoPasskeysModuleWeb).signWithPasskey( request.domain, - request.challengeB64 + request.challengeB64, + request.rawIdsB64 ) return { + id: ret.id, passkeyName: ret.passkeyName, rawClientDataJSONB64: ret.rawClientDataJSONB64, rawAuthenticatorDataB64: ret.rawAuthenticatorDataB64, diff --git a/packages/daimo-expo-passkeys/src/utils.ts b/packages/daimo-expo-passkeys/src/utils.ts index d0a12c867..2a941528f 100644 --- a/packages/daimo-expo-passkeys/src/utils.ts +++ b/packages/daimo-expo-passkeys/src/utils.ts @@ -14,10 +14,18 @@ export type CreateResult = { export type SignRequest = { domain: string challengeB64: string + /** + * The list of raw ids to use for signing. Required for Android and Chrome. + */ + rawIdsB64: string[] } export type SignResult = { - passkeyName: string + /** + * The base64 encoded raw id of the credential. + */ + id: string + passkeyName: string | null rawClientDataJSONB64: string rawAuthenticatorDataB64: string signatureB64: string diff --git a/packages/shovel/etc/config.json b/packages/shovel/etc/config.json index 88624da4a..be5e057f5 100644 --- a/packages/shovel/etc/config.json +++ b/packages/shovel/etc/config.json @@ -42,7 +42,6 @@ "filter_op": "contains", "filter_arg": [ "0x008c9561857b6555584d20aC55110335759Aa2c2", - "0x1E6a7C7aA90Ea75d9aD9e02AEA71e3acbe05F8F5", "0xe4C5EF95e8cDA5DB09393a08E30645F883e187B8" ] } diff --git a/packages/shovel/test/__snapshots__/index.test.ts.snap b/packages/shovel/test/__snapshots__/index.test.ts.snap index b4b53aacc..6ae3eb741 100644 --- a/packages/shovel/test/__snapshots__/index.test.ts.snap +++ b/packages/shovel/test/__snapshots__/index.test.ts.snap @@ -23,8 +23,7 @@ exports[`shovel config 1`] = ` { "column": "log_addr", "filter_arg": [ - "0x1E6a7C7aA90Ea75d9aD9e02AEA71e3acbe05F8F5", - "0x95DaEEEF8Ac6f28648559aDBEdbcAC00ef4d1745", + "0x008c9561857b6555584d20aC55110335759Aa2c2", "0xe4C5EF95e8cDA5DB09393a08E30645F883e187B8", ], "filter_op": "contains",