Skip to content

Commit

Permalink
feat(protocol-kit): Add react native compatibility (#1033)
Browse files Browse the repository at this point in the history
  • Loading branch information
yagopv authored Nov 25, 2024
1 parent 32b408b commit 920d159
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 42 deletions.
5 changes: 4 additions & 1 deletion packages/protocol-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@
"web3": "^4.12.1"
},
"dependencies": {
"@noble/hashes": "^1.3.3",
"@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"
}
}
15 changes: 13 additions & 2 deletions packages/protocol-kit/src/Safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import {
SigningMethodType,
SwapOwnerTxParams,
SafeModulesPaginated,
RemovePasskeyOwnerTxParams
RemovePasskeyOwnerTxParams,
PasskeyArgType
} from './types'
import {
EthSafeSignature,
Expand All @@ -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'
Expand Down Expand Up @@ -1698,6 +1700,15 @@ class Safe {
}): ContractInfo | undefined => {
return getContractInfo(contractAddress)
}

/**
* This method creates a signer to be used with the init method
* @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: Credential): Promise<PasskeyArgType> => {
return extractPasskeyData(credential)
}
}

export default Safe
2 changes: 0 additions & 2 deletions packages/protocol-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import {
estimateTxGas,
estimateSafeTxGas,
estimateSafeDeploymentGas,
extractPasskeyCoordinates,
extractPasskeyData,
validateEthereumAddress,
validateEip3770Address
Expand Down Expand Up @@ -74,7 +73,6 @@ export {
estimateSafeTxGas,
estimateSafeDeploymentGas,
extractPasskeyData,
extractPasskeyCoordinates,
ContractManager,
CreateCallBaseContract,
createERC20TokenTransferTransaction,
Expand Down
5 changes: 4 additions & 1 deletion packages/protocol-kit/src/types/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ export type PasskeyCoordinates = {
y: string
}

export type GetPasskeyCredentialFn = (options?: CredentialRequestOptions) => Promise<Credential>

export type PasskeyArgType = {
rawId: string // required to sign data
coordinates: PasskeyCoordinates // required to sign data
customVerifierAddress?: string // optional
customVerifierAddress?: string
getFn?: GetPasskeyCredentialFn
}
44 changes: 35 additions & 9 deletions packages/protocol-kit/src/utils/passkeys/PasskeyClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
PasskeyArgType,
PasskeyClient,
SafeWebAuthnSignerFactoryContractImplementationType,
SafeWebAuthnSharedSignerContractImplementationType
SafeWebAuthnSharedSignerContractImplementationType,
GetPasskeyCredentialFn
} from '@safe-global/protocol-kit/types'
import { getDefaultFCLP256VerifierAddress } from './extractPasskeyData'
import { asHex } from '../types'
Expand All @@ -31,20 +32,29 @@ 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<Hex> => {
const assertion = (await navigator.credentials.get({
const sign = async (
passkeyRawId: Uint8Array,
data: Uint8Array,
getFn?: GetPasskeyCredentialFn
): Promise<Hex> => {
// 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 }],
userVerification: 'required'
}
})) as PublicKeyCredential & { response: AuthenticatorAssertionResponse }
})) as PublicKeyCredential

const assertionResponse = assertion.response as AuthenticatorAssertionResponse

if (!assertion?.response?.authenticatorData) {
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)),
Expand Down Expand Up @@ -104,10 +114,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,
Expand Down Expand Up @@ -145,6 +159,17 @@ 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])
}

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
Expand All @@ -157,7 +182,8 @@ 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)

const match = decodedClientDataJSON.match(
/^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/
)
Expand Down
187 changes: 164 additions & 23 deletions packages/protocol-kit/src/utils/passkeys/extractPasskeyData.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,119 @@
import { getFCLP256VerifierDeployment } from '@safe-global/safe-modules-deployments'
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'

/**
* 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()` using correct parameters.
* @returns {Promise<PasskeyArgType>} A promise that resolves to an object containing the coordinates and the rawId derived from the passkey.
* @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<PasskeyArgType> {
const passkey = passkeyCredential as PublicKeyCredential
const attestationResponse = passkey.response as AuthenticatorAttestationResponse
function base64ToUint8Array(base64: string): Uint8Array {
const base64Fixed = base64.replace(/-/g, '+').replace(/_/g, '/')
const binaryBuffer = Buffer.from(base64Fixed, 'base64')

const publicKey = attestationResponse.getPublicKey()
return new Uint8Array(binaryBuffer)
}

if (!publicKey) {
throw new Error('Failed to generate passkey Coordinates. getPublicKey() failed')
/**
* 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 = ''
}

const coordinates = await extractPasskeyCoordinates(publicKey)
const rawId = Buffer.from(passkey.rawId).toString('hex')
@AsnType({ type: AsnTypeTypes.Sequence })
class ECPublicKey {
@AsnProp({ type: AlgorithmIdentifier })
public algorithm = new AlgorithmIdentifier()

@AsnProp({ type: AsnPropTypes.BitString })
public publicKey: ArrayBuffer = new ArrayBuffer(0)
}

return {
rawId,
coordinates
p256,
AsnParser,
ECPublicKey
}
}

/**
* Extracts and returns coordinates from a given passkey public key.
* Decodes a Base64-encoded ECDSA public key for React Native and extracts the x and y coordinates.
*
* @param {ArrayBuffer} publicKey - The public key of the passkey from which coordinates will be extracted.
* @returns {Promise<PasskeyCoordinates>} 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()`
* 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 is empty or if the coordinates cannot be extracted.
*/
export async function extractPasskeyCoordinates(
publicKey: ArrayBuffer
export async function decodePublicKeyForReactNative(
publicKey: string
): Promise<PasskeyCoordinates> {
const { p256, AsnParser, ECPublicKey } = await importLibs()

let publicKeyBytes = base64ToUint8Array(publicKey)

if (publicKeyBytes.length === 0) {
throw new Error('Decoded public key is empty.')
}

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(publicKeyBytes)

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
}
}

/**
* 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<PasskeyCoordinates>} 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.
*/
export async function decodePublicKeyForWeb(publicKey: ArrayBuffer): Promise<PasskeyCoordinates> {
const algorithm = {
name: 'ECDSA',
namedCurve: 'P-256',
Expand All @@ -60,6 +136,71 @@ export async function extractPasskeyCoordinates(
}
}

/**
* Decodes the x and y coordinates of the public key from a created public key credential 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: AuthenticatorResponse
): Promise<PasskeyCoordinates> {
const publicKeyAuthenticatorResponse = response as AuthenticatorAttestationResponse
const publicKey = publicKeyAuthenticatorResponse.getPublicKey()

if (!publicKey) {
throw new Error('Failed to generate passkey coordinates. getPublicKey() failed')
}

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<PasskeyArgType>} 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<PasskeyArgType> {
const passkeyPublicKeyCredential = passkeyCredential as PublicKeyCredential

const rawId = Buffer.from(passkeyPublicKeyCredential.rawId).toString('hex')
const coordinates = await decodePublicKey(passkeyPublicKeyCredential.response)

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',
Expand Down
Loading

0 comments on commit 920d159

Please sign in to comment.