Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(protocol-kit): Add react native compatibility #1033

Merged
merged 20 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should also update our documentation in safe-docs, to use createPasskeySigner instead of extractPasskeyData

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already created a ticket for that

}

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
Loading