From a1d9904ced14bf1f69f778ea744ff02bb18e08b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Tue, 15 Oct 2024 13:06:07 +0200 Subject: [PATCH 01/17] Working version --- packages/protocol-kit/src/types/passkeys.ts | 1 + .../src/utils/passkeys/PasskeyClient.ts | 40 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/protocol-kit/src/types/passkeys.ts b/packages/protocol-kit/src/types/passkeys.ts index 912c73e5c..e42e373d8 100644 --- a/packages/protocol-kit/src/types/passkeys.ts +++ b/packages/protocol-kit/src/types/passkeys.ts @@ -7,4 +7,5 @@ export type PasskeyArgType = { rawId: string // required to sign data coordinates: PasskeyCoordinates // required to sign data customVerifierAddress?: string // optional + getFn?: (options?: CredentialRequestOptions) => Promise } diff --git a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts index 3307cfd6a..ffd5f851c 100644 --- a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts +++ b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts @@ -31,8 +31,15 @@ import isSharedSigner from './isSharedSigner' export const PASSKEY_CLIENT_KEY = 'passkeyWallet' export const PASSKEY_CLIENT_NAME = 'Passkey Wallet Client' -const sign = async (passkeyRawId: Uint8Array, data: Uint8Array): Promise => { - const assertion = (await navigator.credentials.get({ +const sign = async ( + passkeyRawId: Uint8Array, + data: Uint8Array, + getFn?: (options?: CredentialRequestOptions) => Promise +): Promise => { + // Avoid loosing the context for navigator.credentials.get function that leads to an error + const getCredentials = getFn || navigator.credentials.get.bind(navigator.credentials) + + const assertion = (await getCredentials({ publicKey: { challenge: data, allowCredentials: [{ type: 'public-key', id: passkeyRawId }], @@ -45,7 +52,9 @@ const sign = async (passkeyRawId: Uint8Array, data: Uint8Array): Promise => } const { authenticatorData, signature, clientDataJSON } = assertion.response - + console.log('sign (authenticatorData)', authenticatorData) + console.log('sign (signature)', signature) + console.log('sign (clientDataJSON)', clientDataJSON) return encodeAbiParameters(parseAbiParameters('bytes, bytes, uint256[2]'), [ toHex(new Uint8Array(authenticatorData)), extractClientDataFields(clientDataJSON), @@ -104,10 +113,14 @@ export const createPasskeyClient = async ( .extend(() => ({ signMessage({ message }: { message: SignableMessage }) { if (typeof message === 'string') { - return sign(passkeyRawId, toBytes(message)) + return sign(passkeyRawId, toBytes(message), passkey.getFn) } - return sign(passkeyRawId, isHex(message.raw) ? toBytes(message.raw) : message.raw) + return sign( + passkeyRawId, + isHex(message.raw) ? toBytes(message.raw) : message.raw, + passkey.getFn + ) }, signTransaction, signTypedData, @@ -145,6 +158,19 @@ export const createPasskeyClient = async ( })) as PasskeyClient } +function decodeClientDataJSON(clientDataJSON: ArrayBuffer): string { + const uint8Array = new Uint8Array(clientDataJSON) + + let result = '' + for (let i = 0; i < uint8Array.length; i++) { + result += String.fromCharCode(uint8Array[i]) + } + + console.log('decodeClientDataJSON', clientDataJSON, result) + + return result +} + /** * Compute the additional client data JSON fields. This is the fields other than `type` and * `challenge` (including `origin` and any other additional client data fields that may be @@ -157,7 +183,9 @@ export const createPasskeyClient = async ( * @throws {Error} Throws an error if the client data JSON does not contain the expected 'challenge' field pattern. */ function extractClientDataFields(clientDataJSON: ArrayBuffer): Hex { - const decodedClientDataJSON = new TextDecoder('utf-8').decode(clientDataJSON) + const decodedClientDataJSON = decodeClientDataJSON(clientDataJSON) + console.log('extractClientDataFields (decodedClientDataJSON)', decodedClientDataJSON) + const match = decodedClientDataJSON.match( /^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/ ) From 87d149cac548eb4bb4fd3397e81c77779d02c32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 31 Oct 2024 13:27:09 +0100 Subject: [PATCH 02/17] Remove console.logs --- packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts index ffd5f851c..f2c4f7ab6 100644 --- a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts +++ b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts @@ -52,9 +52,7 @@ const sign = async ( } const { authenticatorData, signature, clientDataJSON } = assertion.response - console.log('sign (authenticatorData)', authenticatorData) - console.log('sign (signature)', signature) - console.log('sign (clientDataJSON)', clientDataJSON) + return encodeAbiParameters(parseAbiParameters('bytes, bytes, uint256[2]'), [ toHex(new Uint8Array(authenticatorData)), extractClientDataFields(clientDataJSON), @@ -166,8 +164,6 @@ function decodeClientDataJSON(clientDataJSON: ArrayBuffer): string { result += String.fromCharCode(uint8Array[i]) } - console.log('decodeClientDataJSON', clientDataJSON, result) - return result } @@ -184,7 +180,6 @@ function decodeClientDataJSON(clientDataJSON: ArrayBuffer): string { */ function extractClientDataFields(clientDataJSON: ArrayBuffer): Hex { const decodedClientDataJSON = decodeClientDataJSON(clientDataJSON) - console.log('extractClientDataFields (decodedClientDataJSON)', decodedClientDataJSON) const match = decodedClientDataJSON.match( /^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/ From 7cd103f7e4941269e4be5ac5cf7f25fa07f531ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 14 Nov 2024 16:30:25 +0100 Subject: [PATCH 03/17] Add web working version --- packages/protocol-kit/package.json | 2 + packages/protocol-kit/src/Safe.ts | 12 +- packages/protocol-kit/src/index.ts | 2 - .../src/utils/passkeys/extractPasskeyData.ts | 111 +++++++++++++----- .../protocol-kit/tests/e2e/utils/passkeys.ts | 2 +- packages/sdk-starter-kit/package.json | 2 +- yarn.lock | 57 ++++++++- 7 files changed, 152 insertions(+), 36 deletions(-) diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index 7c26a1ea3..1c57b4541 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -66,7 +66,9 @@ "web3": "^4.12.1" }, "dependencies": { + "@noble/curves": "^1.6.0", "@noble/hashes": "^1.3.3", + "@peculiar/asn1-schema": "^2.3.13", "@safe-global/safe-deployments": "^1.37.14", "@safe-global/safe-modules-deployments": "^2.2.4", "@safe-global/types-kit": "^1.0.0", diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index 3b9dd9859..ee63baeb0 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -42,7 +42,8 @@ import { SigningMethodType, SwapOwnerTxParams, SafeModulesPaginated, - RemovePasskeyOwnerTxParams + RemovePasskeyOwnerTxParams, + PasskeyCoordinates } from './types' import { EthSafeSignature, @@ -59,7 +60,8 @@ import { generateSignature, preimageSafeMessageHash, preimageSafeTransactionHash, - adjustVInSignature + adjustVInSignature, + extractPasskeyData } from './utils' import EthSafeTransaction from './utils/transactions/SafeTransaction' import { SafeTransactionOptionalProps } from './utils/transactions/types' @@ -1698,6 +1700,12 @@ class Safe { }): ContractInfo | undefined => { return getContractInfo(contractAddress) } + + static createPasskeySigner = async ( + credentials: Credential + ): Promise<{ rawId: string; coordinates: PasskeyCoordinates }> => { + return extractPasskeyData(credentials) + } } export default Safe diff --git a/packages/protocol-kit/src/index.ts b/packages/protocol-kit/src/index.ts index cd12b85cf..9c502e2e0 100644 --- a/packages/protocol-kit/src/index.ts +++ b/packages/protocol-kit/src/index.ts @@ -35,7 +35,6 @@ import { estimateTxGas, estimateSafeTxGas, estimateSafeDeploymentGas, - extractPasskeyCoordinates, extractPasskeyData, validateEthereumAddress, validateEip3770Address @@ -74,7 +73,6 @@ export { estimateSafeTxGas, estimateSafeDeploymentGas, extractPasskeyData, - extractPasskeyCoordinates, ContractManager, CreateCallBaseContract, createERC20TokenTransferTransaction, diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 5760fd4b6..46fbb8645 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,6 +1,26 @@ -import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' +import { p256 } from '@noble/curves/p256' +import { AsnProp, AsnPropTypes, AsnType, AsnTypeTypes, AsnParser } from '@peculiar/asn1-schema' import { Buffer } from 'buffer' -import { PasskeyCoordinates, PasskeyArgType } from '@safe-global/protocol-kit/types' +import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' +import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' + +@AsnType({ type: AsnTypeTypes.Sequence }) +class AlgorithmIdentifier { + @AsnProp({ type: AsnPropTypes.ObjectIdentifier }) + public id: string = '' + + @AsnProp({ type: AsnPropTypes.ObjectIdentifier, optional: true }) + public curve: string = '' +} + +@AsnType({ type: AsnTypeTypes.Sequence }) +class ECPublicKey { + @AsnProp({ type: AlgorithmIdentifier }) + public algorithm = new AlgorithmIdentifier() + + @AsnProp({ type: AsnPropTypes.BitString }) + public publicKey: ArrayBuffer = new ArrayBuffer(0) +} /** * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. @@ -13,13 +33,7 @@ export async function extractPasskeyData(passkeyCredential: Credential): Promise const passkey = passkeyCredential as PublicKeyCredential const attestationResponse = passkey.response as AuthenticatorAttestationResponse - const publicKey = attestationResponse.getPublicKey() - - if (!publicKey) { - throw new Error('Failed to generate passkey Coordinates. getPublicKey() failed') - } - - const coordinates = await extractPasskeyCoordinates(publicKey) + const coordinates = decodePublicKey(attestationResponse) const rawId = Buffer.from(passkey.rawId).toString('hex') return { @@ -28,35 +42,74 @@ export async function extractPasskeyData(passkeyCredential: Credential): Promise } } +function isBase64String(str: string): boolean { + const base64Regex = /^(?:[A-Za-z0-9+\/]{4})*?(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ + return base64Regex.test(str) +} + +function decodeBase64(base64: string): Uint8Array { + const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/')) + return Uint8Array.from(binaryString, (c) => c.charCodeAt(0)) +} + /** - * Extracts and returns coordinates from a given passkey public key. + * Decodes the x and y coordinates of the public key from a created public key credential response. + * Inspired from . * - * @param {ArrayBuffer} publicKey - The public key of the passkey from which coordinates will be extracted. - * @returns {Promise} A promise that resolves to an object containing the coordinates derived from the public key of the passkey. - * @throws {Error} Throws an error if the coordinates could not be extracted via `crypto.subtle.exportKey()` + * @param {Pick} response + * @returns {PasskeyCoordinates} Object containing the coordinates derived from the public key of the passkey. + * @throws {Error} Throws an error if the coordinates could not be extracted via `p256.ProjectivePoint.fromHex` */ -export async function extractPasskeyCoordinates( - publicKey: ArrayBuffer -): Promise { - const algorithm = { - name: 'ECDSA', - namedCurve: 'P-256', - hash: { name: 'SHA-256' } +export function decodePublicKey(response: AuthenticatorAttestationResponse): PasskeyCoordinates { + const publicKey = response.getPublicKey() + + if (!publicKey) { + throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') } - const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) + console.log('Public Key:', publicKey) - const { x, y } = await crypto.subtle.exportKey('jwk', key) + try { + let publicKeyUint8Array: Uint8Array - const isValidCoordinates = !!x && !!y + if (typeof publicKey === 'string') { + console.log('Public Key is Base64') + publicKeyUint8Array = decodeBase64(publicKey) + } else if (publicKey instanceof ArrayBuffer) { + console.log('Public Key is ArrayBuffer') + publicKeyUint8Array = new Uint8Array(publicKey) + } else { + throw new Error('Unsupported public key format.') + } - if (!isValidCoordinates) { - throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') - } + console.log('Decoded Public Key Uint8Array:', publicKeyUint8Array) - return { - x: '0x' + Buffer.from(x, 'base64').toString('hex'), - y: '0x' + Buffer.from(y, 'base64').toString('hex') + if (publicKeyUint8Array.length === 0) { + throw new Error('Decoded public key is empty.') + } + + // Parse the DER-encoded public key using the ASN.1 schema + const decodedKey = AsnParser.parse(publicKeyUint8Array.buffer, ECPublicKey) + + // Extract the actual public key bytes + const keyData = new Uint8Array(decodedKey.publicKey) + + // Parse the public key bytes into a point on the curve + const point = p256.ProjectivePoint.fromHex(keyData) + + console.log('Elliptic Curve Point:', point) + + // Extract x and y coordinates + const x = point.x.toString(16).padStart(64, '0') + const y = point.y.toString(16).padStart(64, '0') + + return { + x: '0x' + x, + y: '0x' + y + } + } catch (error) { + console.error('Error decoding public key:', error) + throw error } } diff --git a/packages/protocol-kit/tests/e2e/utils/passkeys.ts b/packages/protocol-kit/tests/e2e/utils/passkeys.ts index 2053af959..676524a73 100644 --- a/packages/protocol-kit/tests/e2e/utils/passkeys.ts +++ b/packages/protocol-kit/tests/e2e/utils/passkeys.ts @@ -1,4 +1,4 @@ -import { PasskeyArgType, PasskeyClient, extractPasskeyCoordinates } from '@safe-global/protocol-kit' +import { PasskeyArgType, PasskeyClient } from '@safe-global/protocol-kit' import { WebAuthnCredentials } from './webauthnShim' import { WalletClient, keccak256, toBytes, Transport, Chain, Account } from 'viem' import { asHex } from '@safe-global/protocol-kit/utils/types' diff --git a/packages/sdk-starter-kit/package.json b/packages/sdk-starter-kit/package.json index e9443b1b5..7e1e72aac 100644 --- a/packages/sdk-starter-kit/package.json +++ b/packages/sdk-starter-kit/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@safe-global/api-kit": "^2.5.4", - "@safe-global/protocol-kit": "^5.0.4", + "@safe-global/protocol-kit": "file:.yalc/@safe-global/protocol-kit", "@safe-global/relay-kit": "^3.2.4", "@safe-global/types-kit": "^1.0.0", "viem": "^2.21.8" diff --git a/yarn.lock b/yarn.lock index 904bf6e2a..b8212855c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1136,6 +1136,13 @@ dependencies: "@noble/hashes" "1.4.0" +"@noble/curves@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.6.0.tgz#be5296ebcd5a1730fccea4786d420f87abfeb40b" + integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== + dependencies: + "@noble/hashes" "1.5.0" + "@noble/curves@~1.4.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.4.2.tgz#40309198c76ed71bc6dbf7ba24e81ceb4d0d1fe9" @@ -1168,7 +1175,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== -"@noble/hashes@^1.3.3", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": +"@noble/hashes@1.5.0", "@noble/hashes@^1.3.3", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== @@ -1715,6 +1722,15 @@ resolved "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-2.5.1.tgz" integrity sha512-qIy6tLx8rtybEsIOAlrM4J/85s2q2nPkDqj/Rx46VakBZ0LwtFhXIVub96LXHczQX0vaqmAueDqNPXtbSXSaYQ== +"@peculiar/asn1-schema@^2.3.13": + version "2.3.13" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz#ec8509cdcbc0da3abe73fd7e690556b57a61b8f4" + integrity sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g== + dependencies: + asn1js "^3.0.5" + pvtsutils "^1.3.5" + tslib "^2.6.2" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1725,6 +1741,19 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@safe-global/protocol-kit@file:packages/sdk-starter-kit/.yalc/@safe-global/protocol-kit": + version "5.0.4" + dependencies: + "@noble/curves" "^1.6.0" + "@noble/hashes" "^1.3.3" + "@peculiar/asn1-schema" "^2.3.13" + "@safe-global/safe-deployments" "^1.37.14" + "@safe-global/safe-modules-deployments" "^2.2.4" + "@safe-global/types-kit" "^1.0.0" + abitype "^1.0.2" + semver "^7.6.3" + viem "^2.21.8" + "@safe-global/safe-contracts-v1.4.1@npm:@safe-global/safe-contracts@1.4.1": version "1.4.1" resolved "https://registry.npmjs.org/@safe-global/safe-contracts/-/safe-contracts-1.4.1.tgz" @@ -2544,6 +2573,15 @@ arrify@^2.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +asn1js@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== + dependencies: + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" + assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz" @@ -7263,6 +7301,18 @@ pure-rand@^6.0.0: resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.1.tgz" integrity sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg== +pvtsutils@^1.3.2, pvtsutils@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.5.tgz#b8705b437b7b134cd7fd858f025a23456f1ce910" + integrity sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA== + dependencies: + tslib "^2.6.1" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + qs@^6.9.4: version "6.11.1" resolved "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz" @@ -8220,6 +8270,11 @@ tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.6.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsort@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/tsort/-/tsort-0.0.1.tgz" From 9779f9e9f812b356578ca1e16b7e05127baf65a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Fri, 15 Nov 2024 10:52:35 +0100 Subject: [PATCH 04/17] Working version --- .../src/utils/passkeys/extractPasskeyData.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 46fbb8645..03008b77a 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -42,14 +42,30 @@ export async function extractPasskeyData(passkeyCredential: Credential): Promise } } -function isBase64String(str: string): boolean { - const base64Regex = /^(?:[A-Za-z0-9+\/]{4})*?(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ - return base64Regex.test(str) +// function decodeBase64(base64: string): Uint8Array { +// const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/') +// const binaryString = Buffer.from(base64Fixed, 'base64') +// return new Uint8Array(binaryString) +// } + +function base64ToUint8Array(base64: string): Uint8Array { + const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/') + const binaryString = atob(base64Fixed) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes } -function decodeBase64(base64: string): Uint8Array { - const binaryString = atob(base64.replace(/-/g, '+').replace(/_/g, '/')) - return Uint8Array.from(binaryString, (c) => c.charCodeAt(0)) +function ensureCorrectFormat(publicKey: Uint8Array): Uint8Array { + if (publicKey.length === 64) { + const uncompressedKey = new Uint8Array(65) + uncompressedKey[0] = 0x04 + uncompressedKey.set(publicKey, 1) + return uncompressedKey + } + return publicKey } /** @@ -74,10 +90,15 @@ export function decodePublicKey(response: AuthenticatorAttestationResponse): Pas if (typeof publicKey === 'string') { console.log('Public Key is Base64') - publicKeyUint8Array = decodeBase64(publicKey) + publicKeyUint8Array = base64ToUint8Array(publicKey) } else if (publicKey instanceof ArrayBuffer) { console.log('Public Key is ArrayBuffer') publicKeyUint8Array = new Uint8Array(publicKey) + // Parse the DER-encoded public key using the ASN.1 schema + const decodedKey = AsnParser.parse(publicKeyUint8Array.buffer, ECPublicKey) + + // Extract the actual public key bytes + publicKeyUint8Array = new Uint8Array(decodedKey.publicKey) } else { throw new Error('Unsupported public key format.') } @@ -88,14 +109,10 @@ export function decodePublicKey(response: AuthenticatorAttestationResponse): Pas throw new Error('Decoded public key is empty.') } - // Parse the DER-encoded public key using the ASN.1 schema - const decodedKey = AsnParser.parse(publicKeyUint8Array.buffer, ECPublicKey) - - // Extract the actual public key bytes - const keyData = new Uint8Array(decodedKey.publicKey) + const formattedKey = ensureCorrectFormat(publicKeyUint8Array) // Parse the public key bytes into a point on the curve - const point = p256.ProjectivePoint.fromHex(keyData) + const point = p256.ProjectivePoint.fromHex(formattedKey) console.log('Elliptic Curve Point:', point) From 37115b40c211103094e1252ae00ae5438b32d0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Fri, 15 Nov 2024 14:30:31 +0100 Subject: [PATCH 05/17] Remove asn and keep web decoding as it was --- packages/protocol-kit/src/Safe.ts | 11 +- .../src/utils/passkeys/extractPasskeyData.ts | 102 ++++++++---------- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index ee63baeb0..77836b642 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -43,7 +43,7 @@ import { SwapOwnerTxParams, SafeModulesPaginated, RemovePasskeyOwnerTxParams, - PasskeyCoordinates + PasskeyArgType } from './types' import { EthSafeSignature, @@ -1701,9 +1701,12 @@ class Safe { return getContractInfo(contractAddress) } - static createPasskeySigner = async ( - credentials: Credential - ): Promise<{ rawId: string; coordinates: PasskeyCoordinates }> => { + /** + * This method creates a signer to be used with the init method + * @param {Credential} credentials - The credentials to be used to create the signer. Can be generated in the web with navigator.credentials.create + * @returns {PasskeyArgType} - The signer to be used with the init method + */ + static createPasskeySigner = async (credentials: Credential): Promise => { return extractPasskeyData(credentials) } } diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 03008b77a..4411bb30a 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,40 +1,23 @@ import { p256 } from '@noble/curves/p256' -import { AsnProp, AsnPropTypes, AsnType, AsnTypeTypes, AsnParser } from '@peculiar/asn1-schema' import { Buffer } from 'buffer' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' -@AsnType({ type: AsnTypeTypes.Sequence }) -class AlgorithmIdentifier { - @AsnProp({ type: AsnPropTypes.ObjectIdentifier }) - public id: string = '' - - @AsnProp({ type: AsnPropTypes.ObjectIdentifier, optional: true }) - public curve: string = '' -} - -@AsnType({ type: AsnTypeTypes.Sequence }) -class ECPublicKey { - @AsnProp({ type: AlgorithmIdentifier }) - public algorithm = new AlgorithmIdentifier() - - @AsnProp({ type: AsnPropTypes.BitString }) - public publicKey: ArrayBuffer = new ArrayBuffer(0) -} - /** * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. * - * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` using correct parameters. + * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. * @returns {Promise} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey. + * This is the important information in the Safe account context and should be stored securely as it is used to verify the passkey and to instantiate the SDK + * as a signer (`Safe.init()) * @throws {Error} Throws an error if the coordinates could not be extracted */ export async function extractPasskeyData(passkeyCredential: Credential): Promise { const passkey = passkeyCredential as PublicKeyCredential const attestationResponse = passkey.response as AuthenticatorAttestationResponse - const coordinates = decodePublicKey(attestationResponse) const rawId = Buffer.from(passkey.rawId).toString('hex') + const coordinates = await decodePublicKey(attestationResponse) return { rawId, @@ -42,12 +25,6 @@ export async function extractPasskeyData(passkeyCredential: Credential): Promise } } -// function decodeBase64(base64: string): Uint8Array { -// const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/') -// const binaryString = Buffer.from(base64Fixed, 'base64') -// return new Uint8Array(binaryString) -// } - function base64ToUint8Array(base64: string): Uint8Array { const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/') const binaryString = atob(base64Fixed) @@ -70,59 +47,72 @@ function ensureCorrectFormat(publicKey: Uint8Array): Uint8Array { /** * Decodes the x and y coordinates of the public key from a created public key credential response. - * Inspired from . * * @param {Pick} response * @returns {PasskeyCoordinates} Object containing the coordinates derived from the public key of the passkey. * @throws {Error} Throws an error if the coordinates could not be extracted via `p256.ProjectivePoint.fromHex` */ -export function decodePublicKey(response: AuthenticatorAttestationResponse): PasskeyCoordinates { +export async function decodePublicKey( + response: AuthenticatorAttestationResponse +): Promise { const publicKey = response.getPublicKey() if (!publicKey) { throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') } - console.log('Public Key:', publicKey) - try { let publicKeyUint8Array: Uint8Array if (typeof publicKey === 'string') { - console.log('Public Key is Base64') + // Public key is base64 encoded + // React Native platform uses base64 encoded strings publicKeyUint8Array = base64ToUint8Array(publicKey) - } else if (publicKey instanceof ArrayBuffer) { - console.log('Public Key is ArrayBuffer') - publicKeyUint8Array = new Uint8Array(publicKey) - // Parse the DER-encoded public key using the ASN.1 schema - const decodedKey = AsnParser.parse(publicKeyUint8Array.buffer, ECPublicKey) - // Extract the actual public key bytes - publicKeyUint8Array = new Uint8Array(decodedKey.publicKey) - } else { - throw new Error('Unsupported public key format.') - } + if (publicKeyUint8Array.length === 0) { + throw new Error('Decoded public key is empty.') + } - console.log('Decoded Public Key Uint8Array:', publicKeyUint8Array) + const formattedKey = ensureCorrectFormat(publicKeyUint8Array) - if (publicKeyUint8Array.length === 0) { - throw new Error('Decoded public key is empty.') - } + // Parse the public key bytes into a point on the curve + const point = p256.ProjectivePoint.fromHex(formattedKey) - const formattedKey = ensureCorrectFormat(publicKeyUint8Array) + console.log('Elliptic Curve Point:', point) - // Parse the public key bytes into a point on the curve - const point = p256.ProjectivePoint.fromHex(formattedKey) + // Extract x and y coordinates + const x = point.x.toString(16).padStart(64, '0') + const y = point.y.toString(16).padStart(64, '0') + + return { + x: '0x' + x, + y: '0x' + y + } + } else if (publicKey instanceof ArrayBuffer) { + // Public key is an ArrayBuffer + // Web platform uses ArrayBuffer + const algorithm = { + name: 'ECDSA', + namedCurve: 'P-256', + hash: { name: 'SHA-256' } + } - console.log('Elliptic Curve Point:', point) + const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) - // Extract x and y coordinates - const x = point.x.toString(16).padStart(64, '0') - const y = point.y.toString(16).padStart(64, '0') + const { x, y } = await crypto.subtle.exportKey('jwk', key) - return { - x: '0x' + x, - y: '0x' + y + const isValidCoordinates = !!x && !!y + + if (!isValidCoordinates) { + throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') + } + + return { + x: '0x' + Buffer.from(x, 'base64').toString('hex'), + y: '0x' + Buffer.from(y, 'base64').toString('hex') + } + } else { + throw new Error('Unsupported public key format.') } } catch (error) { console.error('Error decoding public key:', error) From 56d5955afad15d8604b5002d46d17901e5e3d78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Mon, 18 Nov 2024 12:32:41 +0100 Subject: [PATCH 06/17] redo code --- .../src/utils/passkeys/extractPasskeyData.ts | 87 ++++++++++--------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 4411bb30a..ea6e43b9c 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -45,6 +45,48 @@ function ensureCorrectFormat(publicKey: Uint8Array): Uint8Array { return publicKey } +function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { + const publicKeyUint8Array = base64ToUint8Array(publicKey) + + if (publicKeyUint8Array.length === 0) { + throw new Error('Decoded public key is empty.') + } + + const formattedKey = ensureCorrectFormat(publicKeyUint8Array) + const point = p256.ProjectivePoint.fromHex(formattedKey) + + const x = point.x.toString(16).padStart(64, '0') + const y = point.y.toString(16).padStart(64, '0') + + return { + x: '0x' + x, + y: '0x' + y + } +} + +async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { + const algorithm = { + name: 'ECDSA', + namedCurve: 'P-256', + hash: { name: 'SHA-256' } + } + + const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) + + const { x, y } = await crypto.subtle.exportKey('jwk', key) + + const isValidCoordinates = !!x && !!y + + if (!isValidCoordinates) { + throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') + } + + return { + x: '0x' + Buffer.from(x, 'base64').toString('hex'), + y: '0x' + Buffer.from(y, 'base64').toString('hex') + } +} + /** * Decodes the x and y coordinates of the public key from a created public key credential response. * @@ -62,55 +104,14 @@ export async function decodePublicKey( } try { - let publicKeyUint8Array: Uint8Array - if (typeof publicKey === 'string') { // Public key is base64 encoded // React Native platform uses base64 encoded strings - publicKeyUint8Array = base64ToUint8Array(publicKey) - - if (publicKeyUint8Array.length === 0) { - throw new Error('Decoded public key is empty.') - } - - const formattedKey = ensureCorrectFormat(publicKeyUint8Array) - - // Parse the public key bytes into a point on the curve - const point = p256.ProjectivePoint.fromHex(formattedKey) - - console.log('Elliptic Curve Point:', point) - - // Extract x and y coordinates - const x = point.x.toString(16).padStart(64, '0') - const y = point.y.toString(16).padStart(64, '0') - - return { - x: '0x' + x, - y: '0x' + y - } + return decodePublicKeyForReactNative(publicKey) } else if (publicKey instanceof ArrayBuffer) { // Public key is an ArrayBuffer // Web platform uses ArrayBuffer - const algorithm = { - name: 'ECDSA', - namedCurve: 'P-256', - hash: { name: 'SHA-256' } - } - - const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) - - const { x, y } = await crypto.subtle.exportKey('jwk', key) - - const isValidCoordinates = !!x && !!y - - if (!isValidCoordinates) { - throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') - } - - return { - x: '0x' + Buffer.from(x, 'base64').toString('hex'), - y: '0x' + Buffer.from(y, 'base64').toString('hex') - } + return await decodePublicKeyForWeb(publicKey) } else { throw new Error('Unsupported public key format.') } From e813a3f95fbb5d89c171134e5518ded16a35884e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Mon, 18 Nov 2024 13:03:18 +0100 Subject: [PATCH 07/17] Remove libs --- packages/protocol-kit/package.json | 2 - .../src/utils/passkeys/extractPasskeyData.ts | 135 ++++++++++++------ 2 files changed, 93 insertions(+), 44 deletions(-) diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index 1c57b4541..b06b67970 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -67,8 +67,6 @@ }, "dependencies": { "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.3.3", - "@peculiar/asn1-schema": "^2.3.13", "@safe-global/safe-deployments": "^1.37.14", "@safe-global/safe-modules-deployments": "^2.2.4", "@safe-global/types-kit": "^1.0.0", diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index ea6e43b9c..eda194008 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,50 +1,57 @@ import { p256 } from '@noble/curves/p256' -import { Buffer } from 'buffer' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' /** - * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. + * Converts a Base64 URL-encoded string to a Uint8Array. * - * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. - * @returns {Promise} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey. - * This is the important information in the Safe account context and should be stored securely as it is used to verify the passkey and to instantiate the SDK - * as a signer (`Safe.init()) - * @throws {Error} Throws an error if the coordinates could not be extracted + * This function handles Base64 URL variants by replacing URL-safe characters + * with standard Base64 characters, decodes the Base64 string into a binary string, + * and then converts it into a Uint8Array. + * + * @param {string} base64 - The Base64 URL-encoded string to convert. + * @returns {Uint8Array} The resulting Uint8Array from the decoded Base64 string. */ -export async function extractPasskeyData(passkeyCredential: Credential): Promise { - const passkey = passkeyCredential as PublicKeyCredential - const attestationResponse = passkey.response as AuthenticatorAttestationResponse - - const rawId = Buffer.from(passkey.rawId).toString('hex') - const coordinates = await decodePublicKey(attestationResponse) - - return { - rawId, - coordinates - } -} - function base64ToUint8Array(base64: string): Uint8Array { const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/') - const binaryString = atob(base64Fixed) - const bytes = new Uint8Array(binaryString.length) - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i) - } - return bytes + const binaryBuffer = Buffer.from(base64Fixed, 'base64') + + return new Uint8Array(binaryBuffer) } +/** + * Ensures the elliptic curve public key is in the correct uncompressed format. + * + * Elliptic curve operations often require the public key to be in an uncompressed format, + * which starts with a `0x04` byte, followed by the x and y coordinates. This function + * checks the key length and prepends `0x04` if necessary. + * + * @param {Uint8Array} publicKey - The public key to format. + * @returns {Uint8Array} The formatted public key in uncompressed format. + */ function ensureCorrectFormat(publicKey: Uint8Array): Uint8Array { if (publicKey.length === 64) { const uncompressedKey = new Uint8Array(65) uncompressedKey[0] = 0x04 uncompressedKey.set(publicKey, 1) + return uncompressedKey } + return publicKey } +/** + * Decodes a Base64-encoded ECDSA public key for React Native and extracts the x and y coordinates. + * + * This function decodes a Base64-encoded public key, ensures it is in the correct uncompressed format, + * and extracts the x and y coordinates using the `@noble/curves` library. The coordinates are returned + * as hexadecimal strings prefixed with '0x'. + * + * @param {string} publicKey - The Base64-encoded public key to decode. + * @returns {PasskeyCoordinates} An object containing the x and y coordinates of the public key. + * @throws {Error} Throws an error if the key coordinates cannot be extracted. + */ function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { const publicKeyUint8Array = base64ToUint8Array(publicKey) @@ -53,6 +60,7 @@ function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { } const formattedKey = ensureCorrectFormat(publicKeyUint8Array) + const point = p256.ProjectivePoint.fromHex(formattedKey) const x = point.x.toString(16).padStart(64, '0') @@ -64,6 +72,18 @@ function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { } } +/** + * Decodes an ECDSA public key for the web platform and extracts the x and y coordinates. + * + * This function uses the Web Crypto API to import a public key in SPKI format and then + * exports it to a JWK format to retrieve the x and y coordinates. The coordinates are + * returned as hexadecimal strings prefixed with '0x'. + * + * @param {ArrayBuffer} publicKey - The public key in SPKI format to decode. + * @returns {Promise} A promise that resolves to an object containing + * the x and y coordinates of the public key. + * @throws {Error} Throws an error if the key coordinates cannot be extracted. + */ async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { const algorithm = { name: 'ECDSA', @@ -94,7 +114,7 @@ async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { const publicKey = response.getPublicKey() @@ -103,24 +123,55 @@ export async function decodePublicKey( throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') } - try { - if (typeof publicKey === 'string') { - // Public key is base64 encoded - // React Native platform uses base64 encoded strings - return decodePublicKeyForReactNative(publicKey) - } else if (publicKey instanceof ArrayBuffer) { - // Public key is an ArrayBuffer - // Web platform uses ArrayBuffer - return await decodePublicKeyForWeb(publicKey) - } else { - throw new Error('Unsupported public key format.') - } - } catch (error) { - console.error('Error decoding public key:', error) - throw error + if (typeof publicKey === 'string') { + // Public key is base64 encoded + // - React Native platform uses base64 encoded strings + return decodePublicKeyForReactNative(publicKey) + } + + if (publicKey instanceof ArrayBuffer) { + // Public key is an ArrayBuffer + // - Web platform uses ArrayBuffer + return await decodePublicKeyForWeb(publicKey) } + + throw new Error('Unsupported public key format.') } +/** + * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. + * + * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. + * @returns {Promise} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey. + * This is the important information in the Safe account context and should be stored securely as it is used to verify the passkey and to instantiate the SDK + * as a signer (`Safe.init()) + * @throws {Error} Throws an error if the coordinates could not be extracted + */ +export async function extractPasskeyData(passkeyCredential: Credential): Promise { + const passkey = passkeyCredential as PublicKeyCredential + const attestationResponse = passkey.response as AuthenticatorAttestationResponse + + const rawId = Buffer.from(passkey.rawId).toString('hex') + const coordinates = await decodePublicKey(attestationResponse) + + return { + rawId, + coordinates + } +} + +/** + * Retrieves the default FCLP256 Verifier address for a given blockchain network. + * + * This function fetches the deployment information for the FCLP256 Verifier and + * returns the verifier address associated with the specified chain ID. It ensures + * that the correct version and release status are used. + * + * @param {string} chainId - The ID of the blockchain network to retrieve the verifier address for. + * @returns {string} The FCLP256 Verifier address for the specified chain ID. + * @throws {Error} Throws an error if the deployment information or address cannot be found. + */ + export function getDefaultFCLP256VerifierAddress(chainId: string): string { const FCLP256VerifierDeployment = getFCLP256VerifierDeployment({ version: '0.2.1', From 3a5b1f8347ccbc44ae7f0bbee81489d91d43ed8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Mon, 18 Nov 2024 15:59:23 +0100 Subject: [PATCH 08/17] Revert buffer deletion --- packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index eda194008..52477eaa5 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'buffer' import { p256 } from '@noble/curves/p256' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' From 2643fae0345a04469b2d4c768b19c264bd0594fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Mon, 18 Nov 2024 18:16:05 +0100 Subject: [PATCH 09/17] Working version Android and iOS --- packages/protocol-kit/package.json | 1 + .../src/utils/passkeys/extractPasskeyData.ts | 63 ++++++++++++++----- yarn.lock | 4 +- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index b06b67970..d82c56962 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -67,6 +67,7 @@ }, "dependencies": { "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13", "@safe-global/safe-deployments": "^1.37.14", "@safe-global/safe-modules-deployments": "^2.2.4", "@safe-global/types-kit": "^1.0.0", diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 52477eaa5..7a5629281 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -2,6 +2,25 @@ import { Buffer } from 'buffer' import { p256 } from '@noble/curves/p256' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' +import { AsnParser, AsnProp, AsnPropTypes, AsnType, AsnTypeTypes } from '@peculiar/asn1-schema' + +@AsnType({ type: AsnTypeTypes.Sequence }) +class AlgorithmIdentifier { + @AsnProp({ type: AsnPropTypes.ObjectIdentifier }) + public id: string = '' + + @AsnProp({ type: AsnPropTypes.ObjectIdentifier, optional: true }) + public curve: string = '' +} + +@AsnType({ type: AsnTypeTypes.Sequence }) +class ECPublicKey { + @AsnProp({ type: AlgorithmIdentifier }) + public algorithm = new AlgorithmIdentifier() + + @AsnProp({ type: AsnPropTypes.BitString }) + public publicKey: ArrayBuffer = new ArrayBuffer(0) +} /** * Converts a Base64 URL-encoded string to a Uint8Array. @@ -21,48 +40,58 @@ function base64ToUint8Array(base64: string): Uint8Array { } /** - * Ensures the elliptic curve public key is in the correct uncompressed format. + * Formats the public key to ensure it is in the correct uncompressed format. * - * Elliptic curve operations often require the public key to be in an uncompressed format, - * which starts with a `0x04` byte, followed by the x and y coordinates. This function - * checks the key length and prepends `0x04` if necessary. + * If the public key is in ASN.1 DER format, it extracts the key. If the key is 64 bytes, + * it prepends 0x04 to indicate it's uncompressed. This is necessary for compatibility + * with elliptic curve operations. * - * @param {Uint8Array} publicKey - The public key to format. + * @param {Uint8Array} publicKeyBytes - The public key bytes to format. * @returns {Uint8Array} The formatted public key in uncompressed format. */ -function ensureCorrectFormat(publicKey: Uint8Array): Uint8Array { - if (publicKey.length === 64) { +function formatPublicKey(publicKeyBytes: Uint8Array): Uint8Array { + // ASN.1 DER encoding of an EC public key + // Android Keystore returns the public key in ASN.1 DER format + // https://developer.android.com/privacy-and-security/keystore#ImportingEncryptedKeys + if (publicKeyBytes.length > 65) { + const decodedPublicKey = AsnParser.parse(publicKeyBytes.buffer, ECPublicKey) + + return new Uint8Array(decodedPublicKey.publicKey) + } + + // Missing prefix + if (publicKeyBytes.length === 64) { const uncompressedKey = new Uint8Array(65) uncompressedKey[0] = 0x04 - uncompressedKey.set(publicKey, 1) + uncompressedKey.set(publicKeyBytes, 1) return uncompressedKey } - return publicKey + return publicKeyBytes } /** * Decodes a Base64-encoded ECDSA public key for React Native and extracts the x and y coordinates. * - * This function decodes a Base64-encoded public key, ensures it is in the correct uncompressed format, - * and extracts the x and y coordinates using the `@noble/curves` library. The coordinates are returned - * as hexadecimal strings prefixed with '0x'. + * This function decodes a Base64-encoded public key, formats it to ensure compatibility with + * elliptic curve operations, and extracts the x and y coordinates using the `@noble/curves` library. + * The coordinates are returned as hexadecimal strings prefixed with '0x'. * * @param {string} publicKey - The Base64-encoded public key to decode. * @returns {PasskeyCoordinates} An object containing the x and y coordinates of the public key. * @throws {Error} Throws an error if the key coordinates cannot be extracted. */ function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { - const publicKeyUint8Array = base64ToUint8Array(publicKey) - - if (publicKeyUint8Array.length === 0) { + const publicKeyBytes = base64ToUint8Array(publicKey) + console.log('publicKey', publicKey, publicKeyBytes) + if (publicKeyBytes.length === 0) { throw new Error('Decoded public key is empty.') } - const formattedKey = ensureCorrectFormat(publicKeyUint8Array) + const formattedPublicKeyBytes = formatPublicKey(publicKeyBytes) - const point = p256.ProjectivePoint.fromHex(formattedKey) + const point = p256.ProjectivePoint.fromHex(formattedPublicKeyBytes) const x = point.x.toString(16).padStart(64, '0') const y = point.y.toString(16).padStart(64, '0') diff --git a/yarn.lock b/yarn.lock index b8212855c..bc435613e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1175,7 +1175,7 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.4.0.tgz#45814aa329f30e4fe0ba49426f49dfccdd066426" integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== -"@noble/hashes@1.5.0", "@noble/hashes@^1.3.3", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": +"@noble/hashes@1.5.0", "@noble/hashes@^1.4.0", "@noble/hashes@~1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== @@ -1745,8 +1745,6 @@ version "5.0.4" dependencies: "@noble/curves" "^1.6.0" - "@noble/hashes" "^1.3.3" - "@peculiar/asn1-schema" "^2.3.13" "@safe-global/safe-deployments" "^1.37.14" "@safe-global/safe-modules-deployments" "^2.2.4" "@safe-global/types-kit" "^1.0.0" From bde71c4fad3c287442ebed550071465549dcdb27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Tue, 19 Nov 2024 11:48:31 +0100 Subject: [PATCH 10/17] Improve code --- .../src/utils/passkeys/extractPasskeyData.ts | 59 +++++++------------ 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 7a5629281..2f42eef35 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -39,59 +39,42 @@ function base64ToUint8Array(base64: string): Uint8Array { return new Uint8Array(binaryBuffer) } -/** - * Formats the public key to ensure it is in the correct uncompressed format. - * - * If the public key is in ASN.1 DER format, it extracts the key. If the key is 64 bytes, - * it prepends 0x04 to indicate it's uncompressed. This is necessary for compatibility - * with elliptic curve operations. - * - * @param {Uint8Array} publicKeyBytes - The public key bytes to format. - * @returns {Uint8Array} The formatted public key in uncompressed format. - */ -function formatPublicKey(publicKeyBytes: Uint8Array): Uint8Array { - // ASN.1 DER encoding of an EC public key - // Android Keystore returns the public key in ASN.1 DER format - // https://developer.android.com/privacy-and-security/keystore#ImportingEncryptedKeys - if (publicKeyBytes.length > 65) { - const decodedPublicKey = AsnParser.parse(publicKeyBytes.buffer, ECPublicKey) - - return new Uint8Array(decodedPublicKey.publicKey) - } - - // Missing prefix - if (publicKeyBytes.length === 64) { - const uncompressedKey = new Uint8Array(65) - uncompressedKey[0] = 0x04 - uncompressedKey.set(publicKeyBytes, 1) - - return uncompressedKey - } - - return publicKeyBytes -} - /** * Decodes a Base64-encoded ECDSA public key for React Native and extracts the x and y coordinates. * - * This function decodes a Base64-encoded public key, formats it to ensure compatibility with - * elliptic curve operations, and extracts the x and y coordinates using the `@noble/curves` library. + * This function handles both ASN.1 DER-encoded keys and uncompressed keys. It decodes a Base64-encoded + * public key, checks its format, and extracts the x and y coordinates using the `@noble/curves` library. * The coordinates are returned as hexadecimal strings prefixed with '0x'. * * @param {string} publicKey - The Base64-encoded public key to decode. * @returns {PasskeyCoordinates} An object containing the x and y coordinates of the public key. - * @throws {Error} Throws an error if the key coordinates cannot be extracted. + * @throws {Error} Throws an error if the key is empty or if the coordinates cannot be extracted. */ function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { - const publicKeyBytes = base64ToUint8Array(publicKey) + let publicKeyBytes = base64ToUint8Array(publicKey) + console.log('publicKey', publicKey, publicKeyBytes) + if (publicKeyBytes.length === 0) { throw new Error('Decoded public key is empty.') } - const formattedPublicKeyBytes = formatPublicKey(publicKeyBytes) + const isAsn1Encoded = publicKeyBytes[0] === 0x30 + const isUncompressedKey = publicKeyBytes.length === 64 + + if (isAsn1Encoded) { + const asn1ParsedKey = AsnParser.parse(publicKeyBytes.buffer, ECPublicKey) + + publicKeyBytes = new Uint8Array(asn1ParsedKey.publicKey) + } else if (isUncompressedKey) { + const uncompressedKey = new Uint8Array(65) + uncompressedKey[0] = 0x04 + uncompressedKey.set(publicKeyBytes, 1) + + publicKeyBytes = uncompressedKey + } - const point = p256.ProjectivePoint.fromHex(formattedPublicKeyBytes) + const point = p256.ProjectivePoint.fromHex(publicKeyBytes) const x = point.x.toString(16).padStart(64, '0') const y = point.y.toString(16).padStart(64, '0') From c74f90d923e231ba297e6d184b4d64c9a1a04d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 10:01:45 +0100 Subject: [PATCH 11/17] Improve code --- packages/protocol-kit/src/Safe.ts | 12 +++-- packages/protocol-kit/src/types/passkeys.ts | 45 ++++++++++++++++++- .../src/utils/passkeys/PasskeyClient.ts | 9 ++-- .../src/utils/passkeys/extractPasskeyData.ts | 40 ++++++++++++----- 4 files changed, 85 insertions(+), 21 deletions(-) diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index 77836b642..617711f3f 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -43,7 +43,9 @@ import { SwapOwnerTxParams, SafeModulesPaginated, RemovePasskeyOwnerTxParams, - PasskeyArgType + PasskeyArgType, + PasskeyCredential, + PasskeyAuthenticatorAttestationResponse } from './types' import { EthSafeSignature, @@ -1703,11 +1705,13 @@ class Safe { /** * This method creates a signer to be used with the init method - * @param {Credential} credentials - The credentials to be used to create the signer. Can be generated in the web with navigator.credentials.create + * @param {PasskeyCredential} credential - The credential to be used to create the signer. Can be generated in the web with navigator.credentials.create * @returns {PasskeyArgType} - The signer to be used with the init method */ - static createPasskeySigner = async (credentials: Credential): Promise => { - return extractPasskeyData(credentials) + static createPasskeySigner = async ( + credential: PasskeyCredential + ): Promise => { + return extractPasskeyData(credential) } } diff --git a/packages/protocol-kit/src/types/passkeys.ts b/packages/protocol-kit/src/types/passkeys.ts index e42e373d8..be1a066ed 100644 --- a/packages/protocol-kit/src/types/passkeys.ts +++ b/packages/protocol-kit/src/types/passkeys.ts @@ -1,11 +1,52 @@ +export type PasskeyCredentialRequestOptions = { + publicKey: PasskeyPublicKeyCredentialRequestOptions +} + +export interface PasskeyPublicKeyCredentialRequestOptions { + challenge: Uint8Array + rpId?: string + allowCredentials: { + type: 'public-key' + id: Uint8Array + }[] + userVerification?: Exclude + attestation?: 'none' +} + +export type PasskeyUserVerificationRequirement = 'required' | 'preferred' | 'discouraged' + +export type PasskeyCredential = { + id: string + type: 'public-key' + rawId: ArrayBuffer + response: T +} + +export type PasskeyAuthenticatorAttestationResponse = { + clientDataJSON: ArrayBuffer + attestationObject: ArrayBuffer + getPublicKey(): ArrayBuffer | string +} + +export interface PasskeyAuthenticatorAssertionResponse { + clientDataJSON: ArrayBuffer + authenticatorData: ArrayBuffer + signature: ArrayBuffer + userHandle: ArrayBuffer +} + export type PasskeyCoordinates = { x: string y: string } +export type GetPasskeyCredentialFn = ( + options?: PasskeyCredentialRequestOptions +) => Promise> + export type PasskeyArgType = { rawId: string // required to sign data coordinates: PasskeyCoordinates // required to sign data - customVerifierAddress?: string // optional - getFn?: (options?: CredentialRequestOptions) => Promise + customVerifierAddress?: string + getFn?: GetPasskeyCredentialFn } diff --git a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts index f2c4f7ab6..52e440912 100644 --- a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts +++ b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts @@ -22,7 +22,10 @@ import { PasskeyArgType, PasskeyClient, SafeWebAuthnSignerFactoryContractImplementationType, - SafeWebAuthnSharedSignerContractImplementationType + SafeWebAuthnSharedSignerContractImplementationType, + GetPasskeyCredentialFn, + PasskeyCredential, + PasskeyAuthenticatorAssertionResponse } from '@safe-global/protocol-kit/types' import { getDefaultFCLP256VerifierAddress } from './extractPasskeyData' import { asHex } from '../types' @@ -34,7 +37,7 @@ export const PASSKEY_CLIENT_NAME = 'Passkey Wallet Client' const sign = async ( passkeyRawId: Uint8Array, data: Uint8Array, - getFn?: (options?: CredentialRequestOptions) => Promise + getFn?: GetPasskeyCredentialFn ): Promise => { // Avoid loosing the context for navigator.credentials.get function that leads to an error const getCredentials = getFn || navigator.credentials.get.bind(navigator.credentials) @@ -45,7 +48,7 @@ const sign = async ( allowCredentials: [{ type: 'public-key', id: passkeyRawId }], userVerification: 'required' } - })) as PublicKeyCredential & { response: AuthenticatorAssertionResponse } + })) as PasskeyCredential if (!assertion?.response?.authenticatorData) { throw new Error('Failed to sign data with passkey Signer') diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 2f42eef35..8e3b73adf 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,7 +1,12 @@ import { Buffer } from 'buffer' import { p256 } from '@noble/curves/p256' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' -import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' +import { + PasskeyArgType, + PasskeyAuthenticatorAttestationResponse, + PasskeyCoordinates, + PasskeyCredential +} from '@safe-global/protocol-kit/types' import { AsnParser, AsnProp, AsnPropTypes, AsnType, AsnTypeTypes } from '@peculiar/asn1-schema' @AsnType({ type: AsnTypeTypes.Sequence }) @@ -22,6 +27,20 @@ class ECPublicKey { public publicKey: ArrayBuffer = new ArrayBuffer(0) } +/** + * Checks if a given string is Base64 encoded. + * + * This function uses a regular expression to verify if the string + * conforms to the Base64 encoding pattern, including optional padding. + * + * @param {string} str - The string to check. + * @returns {boolean} Returns true if the string is Base64 encoded, otherwise false. + */ +function isBase64Encoded(str: string): boolean { + const base64Regex = /^(?:[A-Za-z0-9+\/]{4})*?(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ + return base64Regex.test(str) +} + /** * Converts a Base64 URL-encoded string to a Uint8Array. * @@ -53,8 +72,6 @@ function base64ToUint8Array(base64: string): Uint8Array { function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { let publicKeyBytes = base64ToUint8Array(publicKey) - console.log('publicKey', publicKey, publicKeyBytes) - if (publicKeyBytes.length === 0) { throw new Error('Decoded public key is empty.') } @@ -128,7 +145,7 @@ async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { const publicKey = response.getPublicKey() @@ -136,7 +153,7 @@ async function decodePublicKey( throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') } - if (typeof publicKey === 'string') { + if (typeof publicKey === 'string' && isBase64Encoded(publicKey)) { // Public key is base64 encoded // - React Native platform uses base64 encoded strings return decodePublicKeyForReactNative(publicKey) @@ -154,18 +171,17 @@ async function decodePublicKey( /** * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. * - * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. + * @param {PasskeyCredential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. * @returns {Promise} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey. * This is the important information in the Safe account context and should be stored securely as it is used to verify the passkey and to instantiate the SDK * as a signer (`Safe.init()) * @throws {Error} Throws an error if the coordinates could not be extracted */ -export async function extractPasskeyData(passkeyCredential: Credential): Promise { - const passkey = passkeyCredential as PublicKeyCredential - const attestationResponse = passkey.response as AuthenticatorAttestationResponse - - const rawId = Buffer.from(passkey.rawId).toString('hex') - const coordinates = await decodePublicKey(attestationResponse) +export async function extractPasskeyData( + passkeyCredential: PasskeyCredential +): Promise { + const rawId = Buffer.from(passkeyCredential.rawId).toString('hex') + const coordinates = await decodePublicKey(passkeyCredential.response) return { rawId, From 8798aaf1e7fc6d037731eb5ee5f25b1407cddf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 10:42:15 +0100 Subject: [PATCH 12/17] Add passkeys dependencies as optional --- packages/protocol-kit/package.json | 6 +- .../src/utils/passkeys/extractPasskeyData.ts | 74 ++++++++++--------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/packages/protocol-kit/package.json b/packages/protocol-kit/package.json index d82c56962..130618500 100644 --- a/packages/protocol-kit/package.json +++ b/packages/protocol-kit/package.json @@ -66,13 +66,15 @@ "web3": "^4.12.1" }, "dependencies": { - "@noble/curves": "^1.6.0", - "@peculiar/asn1-schema": "^2.3.13", "@safe-global/safe-deployments": "^1.37.14", "@safe-global/safe-modules-deployments": "^2.2.4", "@safe-global/types-kit": "^1.0.0", "abitype": "^1.0.2", "semver": "^7.6.3", "viem": "^2.21.8" + }, + "optionalDependencies": { + "@noble/curves": "^1.6.0", + "@peculiar/asn1-schema": "^2.3.13" } } diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index 8e3b73adf..a347a29c7 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,5 +1,4 @@ import { Buffer } from 'buffer' -import { p256 } from '@noble/curves/p256' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' import { PasskeyArgType, @@ -7,39 +6,6 @@ import { PasskeyCoordinates, PasskeyCredential } from '@safe-global/protocol-kit/types' -import { AsnParser, AsnProp, AsnPropTypes, AsnType, AsnTypeTypes } from '@peculiar/asn1-schema' - -@AsnType({ type: AsnTypeTypes.Sequence }) -class AlgorithmIdentifier { - @AsnProp({ type: AsnPropTypes.ObjectIdentifier }) - public id: string = '' - - @AsnProp({ type: AsnPropTypes.ObjectIdentifier, optional: true }) - public curve: string = '' -} - -@AsnType({ type: AsnTypeTypes.Sequence }) -class ECPublicKey { - @AsnProp({ type: AlgorithmIdentifier }) - public algorithm = new AlgorithmIdentifier() - - @AsnProp({ type: AsnPropTypes.BitString }) - public publicKey: ArrayBuffer = new ArrayBuffer(0) -} - -/** - * Checks if a given string is Base64 encoded. - * - * This function uses a regular expression to verify if the string - * conforms to the Base64 encoding pattern, including optional padding. - * - * @param {string} str - The string to check. - * @returns {boolean} Returns true if the string is Base64 encoded, otherwise false. - */ -function isBase64Encoded(str: string): boolean { - const base64Regex = /^(?:[A-Za-z0-9+\/]{4})*?(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ - return base64Regex.test(str) -} /** * Converts a Base64 URL-encoded string to a Uint8Array. @@ -58,6 +24,40 @@ function base64ToUint8Array(base64: string): Uint8Array { return new Uint8Array(binaryBuffer) } +/** + * Dynamic import libraries required for decoding public keys. + */ +async function importLibs() { + const { p256 } = await import('@noble/curves/p256') + + const { AsnParser, AsnProp, AsnPropTypes, AsnType, AsnTypeTypes } = await import( + '@peculiar/asn1-schema' + ) + + @AsnType({ type: AsnTypeTypes.Sequence }) + class AlgorithmIdentifier { + @AsnProp({ type: AsnPropTypes.ObjectIdentifier }) + public id: string = '' + + @AsnProp({ type: AsnPropTypes.ObjectIdentifier, optional: true }) + public curve: string = '' + } + + @AsnType({ type: AsnTypeTypes.Sequence }) + class ECPublicKey { + @AsnProp({ type: AlgorithmIdentifier }) + public algorithm = new AlgorithmIdentifier() + + @AsnProp({ type: AsnPropTypes.BitString }) + public publicKey: ArrayBuffer = new ArrayBuffer(0) + } + + return { + p256, + AsnParser, + ECPublicKey + } +} /** * Decodes a Base64-encoded ECDSA public key for React Native and extracts the x and y coordinates. * @@ -69,7 +69,9 @@ function base64ToUint8Array(base64: string): Uint8Array { * @returns {PasskeyCoordinates} An object containing the x and y coordinates of the public key. * @throws {Error} Throws an error if the key is empty or if the coordinates cannot be extracted. */ -function decodePublicKeyForReactNative(publicKey: string): PasskeyCoordinates { +async function decodePublicKeyForReactNative(publicKey: string): Promise { + const { p256, AsnParser, ECPublicKey } = await importLibs() + let publicKeyBytes = base64ToUint8Array(publicKey) if (publicKeyBytes.length === 0) { @@ -153,7 +155,7 @@ async function decodePublicKey( throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') } - if (typeof publicKey === 'string' && isBase64Encoded(publicKey)) { + if (typeof publicKey === 'string') { // Public key is base64 encoded // - React Native platform uses base64 encoded strings return decodePublicKeyForReactNative(publicKey) From c25d50f3441d598d54a1b6e943a06a06d5c0766e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 15:10:08 +0100 Subject: [PATCH 13/17] Remove yalc dep --- packages/sdk-starter-kit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-starter-kit/package.json b/packages/sdk-starter-kit/package.json index 7e1e72aac..e9443b1b5 100644 --- a/packages/sdk-starter-kit/package.json +++ b/packages/sdk-starter-kit/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@safe-global/api-kit": "^2.5.4", - "@safe-global/protocol-kit": "file:.yalc/@safe-global/protocol-kit", + "@safe-global/protocol-kit": "^5.0.4", "@safe-global/relay-kit": "^3.2.4", "@safe-global/types-kit": "^1.0.0", "viem": "^2.21.8" From c1126319a68ccebee8f0dcee19eadedac8d0c07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 15:15:54 +0100 Subject: [PATCH 14/17] Fix tests --- .../protocol-kit/tests/e2e/utils/passkeys.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/protocol-kit/tests/e2e/utils/passkeys.ts b/packages/protocol-kit/tests/e2e/utils/passkeys.ts index 676524a73..a4fe1e35c 100644 --- a/packages/protocol-kit/tests/e2e/utils/passkeys.ts +++ b/packages/protocol-kit/tests/e2e/utils/passkeys.ts @@ -2,6 +2,7 @@ import { PasskeyArgType, PasskeyClient } from '@safe-global/protocol-kit' import { WebAuthnCredentials } from './webauthnShim' import { WalletClient, keccak256, toBytes, Transport, Chain, Account } from 'viem' import { asHex } from '@safe-global/protocol-kit/utils/types' +import { PasskeyCoordinates } from '@safe-global/protocol-kit/types' let singleInstance: WebAuthnCredentials @@ -19,6 +20,29 @@ export function getWebAuthnCredentials() { return singleInstance } +async function extractPasskeyCoordinates(publicKey: ArrayBuffer): Promise { + const algorithm = { + name: 'ECDSA', + namedCurve: 'P-256', + hash: { name: 'SHA-256' } + } + + const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) + + const { x, y } = await crypto.subtle.exportKey('jwk', key) + + const isValidCoordinates = !!x && !!y + + if (!isValidCoordinates) { + throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') + } + + return { + x: '0x' + Buffer.from(x, 'base64').toString('hex'), + y: '0x' + Buffer.from(y, 'base64').toString('hex') + } +} + /** * Deploys the passkey contract for each of the signers. * @param passkeys An array of PasskeyClient representing the passkeys to deploy. @@ -54,7 +78,7 @@ export async function createMockPasskey( webAuthnCredentials?: WebAuthnCredentials ): Promise { const credentialsInstance = webAuthnCredentials ?? getWebAuthnCredentials() - const passkeyCredential = await credentialsInstance.create({ + const passkeyCredential = credentialsInstance.create({ publicKey: { rp: { name: 'Safe', @@ -85,6 +109,7 @@ export async function createMockPasskey( const exportedPublicKey = await crypto.subtle.exportKey('spki', key) const rawId = Buffer.from(passkeyCredential.rawId).toString('hex') + const coordinates = await extractPasskeyCoordinates(exportedPublicKey) const passkey: PasskeyArgType = { From 8c9346bd9f1f830cf6095948ce58cbcfa8dd57d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 15:37:18 +0100 Subject: [PATCH 15/17] Remove extractPasskeyCoordinates --- .../src/utils/passkeys/extractPasskeyData.ts | 8 +++--- .../protocol-kit/tests/e2e/utils/passkeys.ts | 27 ++----------------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index a347a29c7..e7f12a764 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -69,7 +69,9 @@ async function importLibs() { * @returns {PasskeyCoordinates} An object containing the x and y coordinates of the public key. * @throws {Error} Throws an error if the key is empty or if the coordinates cannot be extracted. */ -async function decodePublicKeyForReactNative(publicKey: string): Promise { +export async function decodePublicKeyForReactNative( + publicKey: string +): Promise { const { p256, AsnParser, ECPublicKey } = await importLibs() let publicKeyBytes = base64ToUint8Array(publicKey) @@ -116,7 +118,7 @@ async function decodePublicKeyForReactNative(publicKey: string): Promise { +export async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { const algorithm = { name: 'ECDSA', namedCurve: 'P-256', @@ -146,7 +148,7 @@ async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise { const publicKey = response.getPublicKey() diff --git a/packages/protocol-kit/tests/e2e/utils/passkeys.ts b/packages/protocol-kit/tests/e2e/utils/passkeys.ts index a4fe1e35c..c23e46489 100644 --- a/packages/protocol-kit/tests/e2e/utils/passkeys.ts +++ b/packages/protocol-kit/tests/e2e/utils/passkeys.ts @@ -2,7 +2,7 @@ import { PasskeyArgType, PasskeyClient } from '@safe-global/protocol-kit' import { WebAuthnCredentials } from './webauthnShim' import { WalletClient, keccak256, toBytes, Transport, Chain, Account } from 'viem' import { asHex } from '@safe-global/protocol-kit/utils/types' -import { PasskeyCoordinates } from '@safe-global/protocol-kit/types' +import { decodePublicKeyForWeb } from '@safe-global/protocol-kit/utils' let singleInstance: WebAuthnCredentials @@ -20,29 +20,6 @@ export function getWebAuthnCredentials() { return singleInstance } -async function extractPasskeyCoordinates(publicKey: ArrayBuffer): Promise { - const algorithm = { - name: 'ECDSA', - namedCurve: 'P-256', - hash: { name: 'SHA-256' } - } - - const key = await crypto.subtle.importKey('spki', publicKey, algorithm, true, ['verify']) - - const { x, y } = await crypto.subtle.exportKey('jwk', key) - - const isValidCoordinates = !!x && !!y - - if (!isValidCoordinates) { - throw new Error('Failed to generate passkey Coordinates. crypto.subtle.exportKey() failed') - } - - return { - x: '0x' + Buffer.from(x, 'base64').toString('hex'), - y: '0x' + Buffer.from(y, 'base64').toString('hex') - } -} - /** * Deploys the passkey contract for each of the signers. * @param passkeys An array of PasskeyClient representing the passkeys to deploy. @@ -110,7 +87,7 @@ export async function createMockPasskey( const rawId = Buffer.from(passkeyCredential.rawId).toString('hex') - const coordinates = await extractPasskeyCoordinates(exportedPublicKey) + const coordinates = await decodePublicKeyForWeb(exportedPublicKey) const passkey: PasskeyArgType = { rawId, From 0638fa284a56e86b7bd6dd9d2efb40659a83f05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 18:45:20 +0100 Subject: [PATCH 16/17] Remove types --- packages/protocol-kit/src/Safe.ts | 10 ++--- packages/protocol-kit/src/types/passkeys.ts | 41 +------------------ .../src/utils/passkeys/PasskeyClient.ts | 12 +++--- .../src/utils/passkeys/extractPasskeyData.ts | 26 +++++------- packages/sdk-starter-kit/package.json | 2 +- 5 files changed, 22 insertions(+), 69 deletions(-) diff --git a/packages/protocol-kit/src/Safe.ts b/packages/protocol-kit/src/Safe.ts index 617711f3f..6b39deeb8 100644 --- a/packages/protocol-kit/src/Safe.ts +++ b/packages/protocol-kit/src/Safe.ts @@ -43,9 +43,7 @@ import { SwapOwnerTxParams, SafeModulesPaginated, RemovePasskeyOwnerTxParams, - PasskeyArgType, - PasskeyCredential, - PasskeyAuthenticatorAttestationResponse + PasskeyArgType } from './types' import { EthSafeSignature, @@ -1705,12 +1703,10 @@ class Safe { /** * This method creates a signer to be used with the init method - * @param {PasskeyCredential} credential - The credential to be used to create the signer. Can be generated in the web with navigator.credentials.create + * @param {Credential} credential - The credential to be used to create the signer. Can be generated in the web with navigator.credentials.create * @returns {PasskeyArgType} - The signer to be used with the init method */ - static createPasskeySigner = async ( - credential: PasskeyCredential - ): Promise => { + static createPasskeySigner = async (credential: Credential): Promise => { return extractPasskeyData(credential) } } diff --git a/packages/protocol-kit/src/types/passkeys.ts b/packages/protocol-kit/src/types/passkeys.ts index be1a066ed..e440692d4 100644 --- a/packages/protocol-kit/src/types/passkeys.ts +++ b/packages/protocol-kit/src/types/passkeys.ts @@ -1,48 +1,9 @@ -export type PasskeyCredentialRequestOptions = { - publicKey: PasskeyPublicKeyCredentialRequestOptions -} - -export interface PasskeyPublicKeyCredentialRequestOptions { - challenge: Uint8Array - rpId?: string - allowCredentials: { - type: 'public-key' - id: Uint8Array - }[] - userVerification?: Exclude - attestation?: 'none' -} - -export type PasskeyUserVerificationRequirement = 'required' | 'preferred' | 'discouraged' - -export type PasskeyCredential = { - id: string - type: 'public-key' - rawId: ArrayBuffer - response: T -} - -export type PasskeyAuthenticatorAttestationResponse = { - clientDataJSON: ArrayBuffer - attestationObject: ArrayBuffer - getPublicKey(): ArrayBuffer | string -} - -export interface PasskeyAuthenticatorAssertionResponse { - clientDataJSON: ArrayBuffer - authenticatorData: ArrayBuffer - signature: ArrayBuffer - userHandle: ArrayBuffer -} - export type PasskeyCoordinates = { x: string y: string } -export type GetPasskeyCredentialFn = ( - options?: PasskeyCredentialRequestOptions -) => Promise> +export type GetPasskeyCredentialFn = (options?: CredentialRequestOptions) => Promise export type PasskeyArgType = { rawId: string // required to sign data diff --git a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts index 52e440912..40bec29dd 100644 --- a/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts +++ b/packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts @@ -23,9 +23,7 @@ import { PasskeyClient, SafeWebAuthnSignerFactoryContractImplementationType, SafeWebAuthnSharedSignerContractImplementationType, - GetPasskeyCredentialFn, - PasskeyCredential, - PasskeyAuthenticatorAssertionResponse + GetPasskeyCredentialFn } from '@safe-global/protocol-kit/types' import { getDefaultFCLP256VerifierAddress } from './extractPasskeyData' import { asHex } from '../types' @@ -48,13 +46,15 @@ const sign = async ( allowCredentials: [{ type: 'public-key', id: passkeyRawId }], userVerification: 'required' } - })) as PasskeyCredential + })) as PublicKeyCredential - if (!assertion?.response?.authenticatorData) { + const assertionResponse = assertion.response as AuthenticatorAssertionResponse + + if (!assertionResponse?.authenticatorData) { throw new Error('Failed to sign data with passkey Signer') } - const { authenticatorData, signature, clientDataJSON } = assertion.response + const { authenticatorData, signature, clientDataJSON } = assertionResponse return encodeAbiParameters(parseAbiParameters('bytes, bytes, uint256[2]'), [ toHex(new Uint8Array(authenticatorData)), diff --git a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts index e7f12a764..f11807270 100644 --- a/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts +++ b/packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts @@ -1,11 +1,6 @@ import { Buffer } from 'buffer' import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments' -import { - PasskeyArgType, - PasskeyAuthenticatorAttestationResponse, - PasskeyCoordinates, - PasskeyCredential -} from '@safe-global/protocol-kit/types' +import { PasskeyArgType, PasskeyCoordinates } from '@safe-global/protocol-kit/types' /** * Converts a Base64 URL-encoded string to a Uint8Array. @@ -144,14 +139,15 @@ export async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise} response + * @param {AuthenticatorResponse} response * @returns {PasskeyCoordinates} Object containing the coordinates derived from the public key of the passkey. * @throws {Error} Throws an error if the coordinates could not be extracted via `p256.ProjectivePoint.fromHex` */ export async function decodePublicKey( - response: PasskeyAuthenticatorAttestationResponse + response: AuthenticatorResponse ): Promise { - const publicKey = response.getPublicKey() + const publicKeyAuthenticatorResponse = response as AuthenticatorAttestationResponse + const publicKey = publicKeyAuthenticatorResponse.getPublicKey() if (!publicKey) { throw new Error('Failed to generate passkey coordinates. getPublicKey() failed') @@ -175,17 +171,17 @@ export async function decodePublicKey( /** * Extracts and returns the passkey data (coordinates and rawId) from a given passkey Credential. * - * @param {PasskeyCredential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. + * @param {Credential} passkeyCredential - The passkey credential generated via `navigator.credentials.create()` or other method in another platforms. * @returns {Promise} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey. * This is the important information in the Safe account context and should be stored securely as it is used to verify the passkey and to instantiate the SDK * as a signer (`Safe.init()) * @throws {Error} Throws an error if the coordinates could not be extracted */ -export async function extractPasskeyData( - passkeyCredential: PasskeyCredential -): Promise { - const rawId = Buffer.from(passkeyCredential.rawId).toString('hex') - const coordinates = await decodePublicKey(passkeyCredential.response) +export async function extractPasskeyData(passkeyCredential: Credential): Promise { + const passkeyPublicKeyCredential = passkeyCredential as PublicKeyCredential + + const rawId = Buffer.from(passkeyPublicKeyCredential.rawId).toString('hex') + const coordinates = await decodePublicKey(passkeyPublicKeyCredential.response) return { rawId, diff --git a/packages/sdk-starter-kit/package.json b/packages/sdk-starter-kit/package.json index e9443b1b5..7e1e72aac 100644 --- a/packages/sdk-starter-kit/package.json +++ b/packages/sdk-starter-kit/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@safe-global/api-kit": "^2.5.4", - "@safe-global/protocol-kit": "^5.0.4", + "@safe-global/protocol-kit": "file:.yalc/@safe-global/protocol-kit", "@safe-global/relay-kit": "^3.2.4", "@safe-global/types-kit": "^1.0.0", "viem": "^2.21.8" From bcaab0b3ce0d66e60a6b694bb24e2e5c11c9ffb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez=20V=C3=A1zquez?= Date: Thu, 21 Nov 2024 18:48:05 +0100 Subject: [PATCH 17/17] remove yalc --- packages/sdk-starter-kit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-starter-kit/package.json b/packages/sdk-starter-kit/package.json index 7e1e72aac..e9443b1b5 100644 --- a/packages/sdk-starter-kit/package.json +++ b/packages/sdk-starter-kit/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@safe-global/api-kit": "^2.5.4", - "@safe-global/protocol-kit": "file:.yalc/@safe-global/protocol-kit", + "@safe-global/protocol-kit": "^5.0.4", "@safe-global/relay-kit": "^3.2.4", "@safe-global/types-kit": "^1.0.0", "viem": "^2.21.8"