diff --git a/bun.lockb b/bun.lockb index 98c229f9..d70ce661 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/plugins/multi-chain-weighted-validator/CHANGELOG.md b/plugins/multi-chain-weighted-validator/CHANGELOG.md index 86b0c8c2..4f470475 100644 --- a/plugins/multi-chain-weighted-validator/CHANGELOG.md +++ b/plugins/multi-chain-weighted-validator/CHANGELOG.md @@ -1,5 +1,11 @@ # @zerodev/multi-chain-weighted-validator +## 5.3.0-alpha-2 + +### Patch Changes + +- Separated webauthn signer and webauthn key module + ## 5.3.0-alpha-1 ### Patch Changes diff --git a/plugins/multi-chain-weighted-validator/index.ts b/plugins/multi-chain-weighted-validator/index.ts index 3e682493..b4262de0 100644 --- a/plugins/multi-chain-weighted-validator/index.ts +++ b/plugins/multi-chain-weighted-validator/index.ts @@ -1,4 +1,9 @@ import type { KernelValidator } from "@zerodev/sdk/types" +import { + type WebAuthnKey, + WebAuthnMode, + toWebAuthnKey +} from "@zerodev/webauthn-key" import { type ApproveUserOperationParameters, type ApproveUserOperationReturnType, @@ -20,9 +25,7 @@ import { type ECDSASignerParams, toECDSASigner } from "./signers/toECDSASigner.js" -import { WebAuthnMode, toWebAuthnPubKey } from "./signers/toWebAuthnPubKey.js" import { - type WebAuthnKey, type WebAuthnModularSignerParams, toWebAuthnSigner } from "./signers/toWebAuthnSigner.js" @@ -41,7 +44,7 @@ export { type KernelValidator, toECDSASigner, type ECDSASignerParams, - toWebAuthnPubKey, + toWebAuthnKey, type WebAuthnKey, WebAuthnMode, type WebAuthnModularSignerParams, diff --git a/plugins/multi-chain-weighted-validator/package.json b/plugins/multi-chain-weighted-validator/package.json index ca660fff..14280f7d 100644 --- a/plugins/multi-chain-weighted-validator/package.json +++ b/plugins/multi-chain-weighted-validator/package.json @@ -1,6 +1,6 @@ { "name": "@zerodev/multi-chain-weighted-validator", - "version": "5.3.0-alpha-1", + "version": "5.3.0-alpha-2", "author": "ZeroDev", "main": "./_cjs/index.js", "module": "./_esm/index.js", @@ -42,6 +42,7 @@ "peerDependencies": { "viem": "^2.16.3", "@zerodev/sdk": "^5.2.1", + "@zerodev/webauthn-key": "^5.3.0", "permissionless": "^0.1.18" } } diff --git a/plugins/multi-chain-weighted-validator/signers/toWebAuthnPubKey.ts b/plugins/multi-chain-weighted-validator/signers/toWebAuthnPubKey.ts deleted file mode 100644 index a47aaa21..00000000 --- a/plugins/multi-chain-weighted-validator/signers/toWebAuthnPubKey.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Buffer } from "buffer" -import { type Hex, keccak256 } from "viem" -import type { WebAuthnKey } from "./toWebAuthnSigner.js" -import { b64ToBytes, uint8ArrayToHexString } from "./webAuthnUtils.js" - -export enum WebAuthnMode { - Register = "register", - Login = "login" -} - -export const toWebAuthnPubKey = async ({ - passkeyName, - passkeyServerUrl, - mode = WebAuthnMode.Login -}: { - passkeyName: string - passkeyServerUrl: string - mode: WebAuthnMode -}): Promise => { - let pubKey: string | undefined - let authenticatorIdHash: Hex - if (mode === WebAuthnMode.Login) { - // Get login options - const loginOptionsResponse = await fetch( - `${passkeyServerUrl}/login/options`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include" - } - ) - const loginOptions = await loginOptionsResponse.json() - - // Start authentication (login) - const { startAuthentication } = await import("@simplewebauthn/browser") - const loginCred = await startAuthentication(loginOptions) - - // get authenticatorIdHash - authenticatorIdHash = keccak256( - uint8ArrayToHexString(b64ToBytes(loginCred.id)) - ) - - // Verify authentication - const loginVerifyResponse = await fetch( - `${passkeyServerUrl}/login/verify`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cred: loginCred }), - credentials: "include" - } - ) - - const loginVerifyResult = await loginVerifyResponse.json() - - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - sessionStorage.setItem("userId", loginVerifyResult.userId) - - if (!loginVerifyResult.verification.verified) { - throw new Error("Login not verified") - } - // Import the key - pubKey = loginVerifyResult.pubkey // Uint8Array pubkey - } else { - if (!passkeyName) { - throw new Error("No passkey name provided") - } - // Get registration options - const registerOptionsResponse = await fetch( - `${passkeyServerUrl}/register/options`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ username: passkeyName }), - credentials: "include" - } - ) - const registerOptions = await registerOptionsResponse.json() - - // save userId to sessionStorage - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - sessionStorage.setItem("userId", registerOptions.userId) - - // Start registration - const { startRegistration } = await import("@simplewebauthn/browser") - const registerCred = await startRegistration(registerOptions.options) - - // get authenticatorIdHash - authenticatorIdHash = keccak256( - uint8ArrayToHexString(b64ToBytes(registerCred.id)) - ) - - // Verify registration - const registerVerifyResponse = await fetch( - `${passkeyServerUrl}/register/verify`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - userId: registerOptions.userId, - username: passkeyName, - cred: registerCred - }), - credentials: "include" - } - ) - - const registerVerifyResult = await registerVerifyResponse.json() - if (!registerVerifyResult.verified) { - throw new Error("Registration not verified") - } - - // Import the key - pubKey = registerCred.response.publicKey - } - if (!pubKey) { - throw new Error("No public key returned from registration credential") - } - - const spkiDer = Buffer.from(pubKey, "base64") - const key = await crypto.subtle.importKey( - "spki", - spkiDer, - { - name: "ECDSA", - namedCurve: "P-256" - }, - true, - ["verify"] - ) - - // Export the key to the raw format - const rawKey = await crypto.subtle.exportKey("raw", key) - const rawKeyBuffer = Buffer.from(rawKey) - - // The first byte is 0x04 (uncompressed), followed by x and y coordinates (32 bytes each for P-256) - const pubKeyX = rawKeyBuffer.subarray(1, 33).toString("hex") - const pubKeyY = rawKeyBuffer.subarray(33).toString("hex") - - return { - pubX: BigInt(`0x${pubKeyX}`), - pubY: BigInt(`0x${pubKeyY}`), - authenticatorIdHash - } -} diff --git a/plugins/multi-chain-weighted-validator/signers/toWebAuthnSigner.ts b/plugins/multi-chain-weighted-validator/signers/toWebAuthnSigner.ts index 70faba07..c88f2c43 100644 --- a/plugins/multi-chain-weighted-validator/signers/toWebAuthnSigner.ts +++ b/plugins/multi-chain-weighted-validator/signers/toWebAuthnSigner.ts @@ -1,44 +1,126 @@ import type { PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/typescript-types" +import { + type WebAuthnKey, + b64ToBytes, + base64FromUint8Array, + findQuoteIndices, + hexStringToUint8Array, + isRIP7212SupportedNetwork, + parseAndNormalizeSig, + uint8ArrayToHexString +} from "@zerodev/webauthn-key" import type { TypedData } from "abitype" import { SignTransactionNotSupportedBySmartAccount } from "permissionless/accounts" import { type Chain, type Client, - type Hex, type LocalAccount, type SignTypedDataParameters, type Transport, type TypedDataDefinition, + concatHex, getTypesForEIP712Domain, hashTypedData, + pad, + toHex, validateTypedData } from "viem" import { type SignableMessage, encodeAbiParameters } from "viem" import { toAccount } from "viem/accounts" -import { getChainId } from "viem/actions" +import { getChainId, signMessage } from "viem/actions" import { SIGNER_TYPE } from "../constants.js" import type { WeightedSigner } from "../toMultiChainWeightedValidatorPlugin.js" -import { WebAuthnMode, toWebAuthnPubKey } from "./toWebAuthnPubKey.js" -import { - b64ToBytes, - encodeWebAuthnPubKey, - findQuoteIndices, - isRIP7212SupportedNetwork, - parseAndNormalizeSig, - uint8ArrayToHexString -} from "./webAuthnUtils.js" -export type WebAuthnKey = { - pubX: bigint - pubY: bigint - authenticatorIdHash: Hex +export type WebAuthnModularSignerParams = { + webAuthnKey: WebAuthnKey } -export type WebAuthnModularSignerParams = { - passkeyName: string - passkeyServerUrl: string - pubKey?: WebAuthnKey - mode?: WebAuthnMode +export const encodeWebAuthnPubKey = (pubKey: WebAuthnKey) => { + return concatHex([ + toHex(pubKey.pubX, { size: 32 }), + toHex(pubKey.pubY, { size: 32 }), + pad(pubKey.authenticatorIdHash, { size: 32 }) + ]) +} + +const signMessageUsingWebAuthn = async ( + message: SignableMessage, + chainId: number, + allowCredentials?: PublicKeyCredentialRequestOptionsJSON["allowCredentials"] +) => { + let messageContent: string + if (typeof message === "string") { + // message is a string + messageContent = message + } else if ("raw" in message && typeof message.raw === "string") { + // message.raw is a Hex string + messageContent = message.raw + } else if ("raw" in message && message.raw instanceof Uint8Array) { + // message.raw is a ByteArray + messageContent = message.raw.toString() + } else { + throw new Error("Unsupported message format") + } + + // remove 0x prefix if present + const formattedMessage = messageContent.startsWith("0x") + ? messageContent.slice(2) + : messageContent + + const challenge = base64FromUint8Array( + hexStringToUint8Array(formattedMessage), + true + ) + + // prepare assertion options + const assertionOptions: PublicKeyCredentialRequestOptionsJSON = { + challenge, + allowCredentials, + userVerification: "required" + } + + // start authentication (signing) + + const { startAuthentication } = await import("@simplewebauthn/browser") + const cred = await startAuthentication(assertionOptions) + + // get authenticator data + const { authenticatorData } = cred.response + const authenticatorDataHex = uint8ArrayToHexString( + b64ToBytes(authenticatorData) + ) + + // get client data JSON + const clientDataJSON = atob(cred.response.clientDataJSON) + + // get challenge and response type location + const { beforeType } = findQuoteIndices(clientDataJSON) + + // get signature r,s + const { signature } = cred.response + const signatureHex = uint8ArrayToHexString(b64ToBytes(signature)) + const { r, s } = parseAndNormalizeSig(signatureHex) + + // encode signature + const encodedSignature = encodeAbiParameters( + [ + { name: "authenticatorData", type: "bytes" }, + { name: "clientDataJSON", type: "string" }, + { name: "responseTypeLocation", type: "uint256" }, + { name: "r", type: "uint256" }, + { name: "s", type: "uint256" }, + { name: "usePrecompiled", type: "bool" } + ], + [ + authenticatorDataHex, + clientDataJSON, + beforeType, + BigInt(r), + BigInt(s), + isRIP7212SupportedNetwork(chainId) + ] + ) + return encodedSignature } export const toWebAuthnSigner = async < @@ -46,132 +128,17 @@ export const toWebAuthnSigner = async < TChain extends Chain | undefined = Chain | undefined >( client: Client, - { - pubKey, - passkeyServerUrl, - passkeyName, - mode = WebAuthnMode.Register - }: WebAuthnModularSignerParams + { webAuthnKey }: WebAuthnModularSignerParams ): Promise => { - pubKey = - pubKey ?? - (await toWebAuthnPubKey({ - passkeyName, - passkeyServerUrl, - mode - })) - if (!pubKey) { - throw new Error("WebAuthn public key not found") - } - const chainId = await getChainId(client) - const signMessageUsingWebAuthn = async (message: SignableMessage) => { - let messageContent: string - if (typeof message === "string") { - // message is a string - messageContent = message - } else if ("raw" in message && typeof message.raw === "string") { - // message.raw is a Hex string - messageContent = message.raw - } else if ("raw" in message && message.raw instanceof Uint8Array) { - // message.raw is a ByteArray - messageContent = message.raw.toString() - } else { - throw new Error("Unsupported message format") - } - - // remove 0x prefix if present - const formattedMessage = messageContent.startsWith("0x") - ? messageContent.slice(2) - : messageContent - - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - const userId = sessionStorage.getItem("userId") - - // initiate signing - const signInitiateResponse = await fetch( - `${passkeyServerUrl}/sign-initiate`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: formattedMessage, userId }), - credentials: "include" - } - ) - const signInitiateResult = await signInitiateResponse.json() - - // prepare assertion options - const assertionOptions: PublicKeyCredentialRequestOptionsJSON = { - challenge: signInitiateResult.challenge, - allowCredentials: signInitiateResult.allowCredentials, - userVerification: "required" - } - - // start authentication (signing) - - const { startAuthentication } = await import("@simplewebauthn/browser") - const cred = await startAuthentication(assertionOptions) - - // verify signature from server - const verifyResponse = await fetch(`${passkeyServerUrl}/sign-verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cred, userId }), - credentials: "include" - }) - - const verifyResult = await verifyResponse.json() - - if (!verifyResult.success) { - throw new Error("Signature not verified") - } - - // get authenticator data - const authenticatorData = verifyResult.authenticatorData - const authenticatorDataHex = uint8ArrayToHexString( - b64ToBytes(authenticatorData) - ) - - // get client data JSON - const clientDataJSON = atob(cred.response.clientDataJSON) - - // get challenge and response type location - const { beforeType } = findQuoteIndices(clientDataJSON) - - // get signature r,s - const signature = verifyResult.signature - const signatureHex = uint8ArrayToHexString(b64ToBytes(signature)) - const { r, s } = parseAndNormalizeSig(signatureHex) - - // encode signature - const encodedSignature = encodeAbiParameters( - [ - { name: "authenticatorData", type: "bytes" }, - { name: "clientDataJSON", type: "string" }, - { name: "responseTypeLocation", type: "uint256" }, - { name: "r", type: "uint256" }, - { name: "s", type: "uint256" }, - { name: "usePrecompiled", type: "bool" } - ], - [ - authenticatorDataHex, - clientDataJSON, - beforeType, - BigInt(r), - BigInt(s), - isRIP7212SupportedNetwork(chainId) - ] - ) - return encodedSignature - } const account: LocalAccount = toAccount({ // note that this address will be overwritten by actual address address: "0x0000000000000000000000000000000000000000", async signMessage({ message }) { - return signMessageUsingWebAuthn(message) + return signMessageUsingWebAuthn(message, chainId, [ + { id: webAuthnKey.authenticatorId, type: "public-key" } + ]) }, async signTransaction(_, __) { throw new SignTransactionNotSupportedBySmartAccount() @@ -193,7 +160,11 @@ export const toWebAuthnSigner = async < validateTypedData({ domain, message, primaryType, types }) const hash = hashTypedData(typedData) - return signMessageUsingWebAuthn(hash) + const signature = await signMessage(client, { + account, + message: hash + }) + return signature } }) @@ -201,8 +172,8 @@ export const toWebAuthnSigner = async < type: SIGNER_TYPE.PASSKEY, account, getPublicKey: () => { - if (!pubKey) return "0x" - return encodeWebAuthnPubKey(pubKey) + if (!webAuthnKey) return "0x" + return encodeWebAuthnPubKey(webAuthnKey) }, getDummySignature: () => { return encodeAbiParameters( diff --git a/plugins/multi-chain-weighted-validator/signers/webAuthnUtils.ts b/plugins/multi-chain-weighted-validator/signers/webAuthnUtils.ts deleted file mode 100644 index 6bc040c0..00000000 --- a/plugins/multi-chain-weighted-validator/signers/webAuthnUtils.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { p256 } from "@noble/curves/p256" -import { - type Hex, - bytesToBigInt, - concatHex, - hexToBytes, - pad, - toHex -} from "viem" -import type { WebAuthnKey } from "./toWebAuthnSigner.js" - -const RIP7212_SUPPORTED_NETWORKS = [80001] - -export const uint8ArrayToHexString = (array: Uint8Array): `0x${string}` => { - return `0x${Array.from(array, (byte) => - byte.toString(16).padStart(2, "0") - ).join("")}` as `0x${string}` -} - -export const b64ToBytes = (base64: string): Uint8Array => { - const paddedBase64 = base64 - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=") - const binString = atob(paddedBase64) - return Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0) -} - -export const findQuoteIndices = ( - input: string -): { beforeType: bigint; beforeChallenge: bigint } => { - const beforeTypeIndex = BigInt(input.lastIndexOf('"type":"webauthn.get"')) - const beforeChallengeIndex = BigInt(input.indexOf('"challenge')) - return { - beforeType: beforeTypeIndex, - beforeChallenge: beforeChallengeIndex - } -} - -// Parse DER-encoded P256-SHA256 signature to contract-friendly signature -// and normalize it so the signature is not malleable. -export function parseAndNormalizeSig(derSig: Hex): { r: bigint; s: bigint } { - const parsedSignature = p256.Signature.fromDER(derSig.slice(2)) - const bSig = hexToBytes(`0x${parsedSignature.toCompactHex()}`) - // assert(bSig.length === 64, "signature is not 64 bytes"); - const bR = bSig.slice(0, 32) - const bS = bSig.slice(32) - - // Avoid malleability. Ensure low S (<= N/2 where N is the curve order) - const r = bytesToBigInt(bR) - let s = bytesToBigInt(bS) - const n = BigInt( - "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" - ) - if (s > n / 2n) { - s = n - s - } - return { r, s } -} - -export const isRIP7212SupportedNetwork = (chainId: number): boolean => - RIP7212_SUPPORTED_NETWORKS.includes(chainId) - -export const encodeWebAuthnPubKey = (pubKey: WebAuthnKey) => { - return concatHex([ - toHex(pubKey.pubX, { size: 32 }), - toHex(pubKey.pubY, { size: 32 }), - pad(pubKey.authenticatorIdHash, { size: 32 }) - ]) -} diff --git a/plugins/multi-chain-weighted-validator/toMultiChainWeightedValidatorPlugin.ts b/plugins/multi-chain-weighted-validator/toMultiChainWeightedValidatorPlugin.ts index 1bc45862..6e988cf8 100644 --- a/plugins/multi-chain-weighted-validator/toMultiChainWeightedValidatorPlugin.ts +++ b/plugins/multi-chain-weighted-validator/toMultiChainWeightedValidatorPlugin.ts @@ -1,4 +1,5 @@ import type { GetKernelVersion, KernelValidator } from "@zerodev/sdk/types" +import type { WebAuthnKey } from "@zerodev/webauthn-key" import type { TypedData } from "abitype" import { type UserOperation, @@ -31,8 +32,7 @@ import { decodeSignatures, encodeSignatures } from "./index.js" -import type { WebAuthnKey } from "./signers/toWebAuthnSigner.js" -import { encodeWebAuthnPubKey } from "./signers/webAuthnUtils.js" +import { encodeWebAuthnPubKey } from "./signers/toWebAuthnSigner.js" export type WeightedSigner = { account: LocalAccount diff --git a/plugins/multichain/CHANGELOG.md b/plugins/multichain/CHANGELOG.md index 4976d7c6..33b2de17 100644 --- a/plugins/multichain/CHANGELOG.md +++ b/plugins/multichain/CHANGELOG.md @@ -1,5 +1,11 @@ # @zerodev/multi-chain-sdk +## 5.3.3 + +### Patch Changes + +- Separated webauthn validator and webauthn key module + ## 5.3.2 ### Patch Changes diff --git a/plugins/multichain/package.json b/plugins/multichain/package.json index 2f6e9d1f..84a9e963 100644 --- a/plugins/multichain/package.json +++ b/plugins/multichain/package.json @@ -1,6 +1,6 @@ { "name": "@zerodev/multi-chain-validator", - "version": "5.3.2", + "version": "5.3.3", "author": "ZeroDev", "main": "./_cjs/index.js", "module": "./_esm/index.js", @@ -37,6 +37,7 @@ "peerDependencies": { "viem": "^2.16.3", "@zerodev/sdk": "^5.2.15", + "@zerodev/webauthn-key": "^5.3.0", "permissionless": "^0.1.18" }, "dependencies": { diff --git a/plugins/multichain/webauthn/index.ts b/plugins/multichain/webauthn/index.ts index 0c66e643..3f2091d5 100644 --- a/plugins/multichain/webauthn/index.ts +++ b/plugins/multichain/webauthn/index.ts @@ -1,4 +1,4 @@ export * from "./webauthnSignUserOps.js" export * from "./webauthnSignUserOpsWithEnable.js" export * from "./toMultiChainWebAuthnValidator.js" -export * from "./toWebAuthnAccount.js" +export { WebAuthnMode, toWebAuthnKey } from "@zerodev/webauthn-key" diff --git a/plugins/multichain/webauthn/toMultiChainWebAuthnValidator.ts b/plugins/multichain/webauthn/toMultiChainWebAuthnValidator.ts index 9e2960b6..d9a23793 100644 --- a/plugins/multichain/webauthn/toMultiChainWebAuthnValidator.ts +++ b/plugins/multichain/webauthn/toMultiChainWebAuthnValidator.ts @@ -1,5 +1,15 @@ import type { PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/typescript-types" import type { GetKernelVersion, KernelValidator } from "@zerodev/sdk/types" +import type { WebAuthnKey } from "@zerodev/webauthn-key" +import { + b64ToBytes, + base64FromUint8Array, + findQuoteIndices, + hexStringToUint8Array, + isRIP7212SupportedNetwork, + parseAndNormalizeSig, + uint8ArrayToHexString +} from "@zerodev/webauthn-key" import type { TypedData } from "abitype" import { type UserOperation, getUserOperationHash } from "permissionless" import { SignTransactionNotSupportedBySmartAccount } from "permissionless/accounts" @@ -25,16 +35,6 @@ import { import { toAccount } from "viem/accounts" import { getChainId, signMessage } from "viem/actions" import { MULTI_CHAIN_WEBAUTHN_VALIDATOR_ADDRESS } from "../constants.js" -import type { WebAuthnKey } from "./toWebAuthnAccount.js" -import { - b64ToBytes, - base64FromUint8Array, - findQuoteIndices, - hexStringToUint8Array, - isRIP7212SupportedNetwork, - parseAndNormalizeSig, - uint8ArrayToHexString -} from "./webAuthnUtils.js" const signMessageUsingWebAuthn = async ( message: SignableMessage, diff --git a/plugins/multichain/webauthn/webAuthnUtils.ts b/plugins/multichain/webauthn/webAuthnUtils.ts deleted file mode 100644 index 0b4b4160..00000000 --- a/plugins/multichain/webauthn/webAuthnUtils.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { p256 } from "@noble/curves/p256" -import { type Hex, bytesToBigInt, hexToBytes } from "viem" - -const RIP7212_SUPPORTED_NETWORKS = [80001] - -export const uint8ArrayToHexString = (array: Uint8Array): `0x${string}` => { - return `0x${Array.from(array, (byte) => - byte.toString(16).padStart(2, "0") - ).join("")}` as `0x${string}` -} - -export const b64ToBytes = (base64: string): Uint8Array => { - const paddedBase64 = base64 - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=") - const binString = atob(paddedBase64) - return Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0) -} - -export const findQuoteIndices = ( - input: string -): { beforeType: bigint; beforeChallenge: bigint } => { - const beforeTypeIndex = BigInt(input.lastIndexOf('"type":"webauthn.get"')) - const beforeChallengeIndex = BigInt(input.indexOf('"challenge')) - return { - beforeType: beforeTypeIndex, - beforeChallenge: beforeChallengeIndex - } -} - -// Parse DER-encoded P256-SHA256 signature to contract-friendly signature -// and normalize it so the signature is not malleable. -export function parseAndNormalizeSig(derSig: Hex): { r: bigint; s: bigint } { - const parsedSignature = p256.Signature.fromDER(derSig.slice(2)) - const bSig = hexToBytes(`0x${parsedSignature.toCompactHex()}`) - // assert(bSig.length === 64, "signature is not 64 bytes"); - const bR = bSig.slice(0, 32) - const bS = bSig.slice(32) - - // Avoid malleability. Ensure low S (<= N/2 where N is the curve order) - const r = bytesToBigInt(bR) - let s = bytesToBigInt(bS) - const n = BigInt( - "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" - ) - if (s > n / 2n) { - s = n - s - } - return { r, s } -} - -export const isRIP7212SupportedNetwork = (chainId: number): boolean => - RIP7212_SUPPORTED_NETWORKS.includes(chainId) - -export const hexStringToUint8Array = (hexString: string): Uint8Array => { - const formattedHexString = hexString.startsWith("0x") - ? hexString.slice(2) - : hexString - const byteArray = new Uint8Array(formattedHexString.length / 2) - for (let i = 0; i < formattedHexString.length; i += 2) { - byteArray[i / 2] = Number.parseInt( - formattedHexString.substring(i, i + 2), - 16 - ) - } - return byteArray -} - -/** - * Convenience function for creating a base64 encoded string from an ArrayBuffer instance - * Copied from @hexagon/base64 package (base64.fromArrayBuffer) - * @public - * - * @param {Uint8Array} uint8Arr - Uint8Array to be encoded - * @param {boolean} [urlMode] - If set to true, URL mode string will be returned - * @returns {string} - Base64 representation of data - */ -export const base64FromUint8Array = ( - uint8Arr: Uint8Array, - urlMode: boolean -): string => { - const // Regular base64 characters - chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - const // Base64url characters - charsUrl = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - - let result = "" - - const len = uint8Arr.length - const target = urlMode ? charsUrl : chars - - for (let i = 0; i < len; i += 3) { - result += target[uint8Arr[i] >> 2] - result += target[((uint8Arr[i] & 3) << 4) | (uint8Arr[i + 1] >> 4)] - result += target[((uint8Arr[i + 1] & 15) << 2) | (uint8Arr[i + 2] >> 6)] - result += target[uint8Arr[i + 2] & 63] - } - - const remainder = len % 3 - if (remainder === 2) { - result = result.substring(0, result.length - 1) + (urlMode ? "" : "=") - } else if (remainder === 1) { - result = result.substring(0, result.length - 2) + (urlMode ? "" : "==") - } - - return result -} diff --git a/plugins/passkey/CHANGELOG.md b/plugins/passkey/CHANGELOG.md index e4172be2..b432cc24 100644 --- a/plugins/passkey/CHANGELOG.md +++ b/plugins/passkey/CHANGELOG.md @@ -1,5 +1,11 @@ # @zerodev/passkey-validator +## 5.3.5 + +### Patch Changes + +- Separated webauthn validator and webauthn key module + ## 5.3.4 ### Patch Changes diff --git a/plugins/passkey/index.ts b/plugins/passkey/index.ts index 03f4bd17..bb320d11 100644 --- a/plugins/passkey/index.ts +++ b/plugins/passkey/index.ts @@ -3,13 +3,13 @@ import { validateKernelVersionWithEntryPoint } from "@zerodev/sdk" import type { GetKernelVersion, KernelValidator } from "@zerodev/sdk/types" +import { WebAuthnMode, toWebAuthnKey } from "@zerodev/webauthn-key" import type { EntryPoint } from "permissionless/types/entrypoint.js" import { type Address, zeroAddress } from "viem" import { deserializePasskeyValidator, toPasskeyValidator } from "./toPasskeyValidator.js" -import { WebAuthnMode, toWebAuthnKey } from "./toWebAuthnKey.js" export { deserializePasskeyValidator, diff --git a/plugins/passkey/package.json b/plugins/passkey/package.json index 77cffaf7..67dc18b0 100644 --- a/plugins/passkey/package.json +++ b/plugins/passkey/package.json @@ -1,6 +1,6 @@ { "name": "@zerodev/passkey-validator", - "version": "5.3.4", + "version": "5.3.5", "author": "ZeroDev", "main": "./_cjs/index.js", "module": "./_esm/index.js", @@ -41,6 +41,7 @@ "peerDependencies": { "viem": "^2.16.3", "@zerodev/sdk": "^5.2.1", + "@zerodev/webauthn-key": "^5.3.0", "permissionless": "^0.1.18" } } diff --git a/plugins/passkey/toPasskeyValidator.ts b/plugins/passkey/toPasskeyValidator.ts index 2a2728ab..847d76a6 100644 --- a/plugins/passkey/toPasskeyValidator.ts +++ b/plugins/passkey/toPasskeyValidator.ts @@ -1,5 +1,17 @@ import type { PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/typescript-types" import type { GetKernelVersion, KernelValidator } from "@zerodev/sdk/types" +import { + type WebAuthnKey, + b64ToBytes, + base64FromUint8Array, + deserializePasskeyValidatorData, + findQuoteIndices, + hexStringToUint8Array, + isRIP7212SupportedNetwork, + parseAndNormalizeSig, + serializePasskeyValidatorData, + uint8ArrayToHexString +} from "@zerodev/webauthn-key" import type { TypedData } from "abitype" import { type UserOperation, getUserOperationHash } from "permissionless" import { SignTransactionNotSupportedBySmartAccount } from "permissionless/accounts" @@ -26,18 +38,6 @@ import { toAccount } from "viem/accounts" import { signMessage } from "viem/actions" import { getChainId } from "viem/actions" import { getValidatorAddress } from "./index.js" -import type { WebAuthnKey } from "./toWebAuthnKey.js" -import { - b64ToBytes, - base64FromUint8Array, - deserializePasskeyValidatorData, - findQuoteIndices, - hexStringToUint8Array, - isRIP7212SupportedNetwork, - parseAndNormalizeSig, - serializePasskeyValidatorData, - uint8ArrayToHexString -} from "./utils.js" const signMessageUsingWebAuthn = async ( message: SignableMessage, @@ -129,14 +129,12 @@ export async function toPasskeyValidator< webAuthnKey, entryPoint: entryPointAddress, kernelVersion, - validatorAddress: _validatorAddress, - credentials = "include" + validatorAddress: _validatorAddress }: { webAuthnKey: WebAuthnKey entryPoint: entryPoint kernelVersion: GetKernelVersion validatorAddress?: Address - credentials?: RequestCredentials } ): Promise< KernelValidator & { @@ -274,7 +272,6 @@ export async function toPasskeyValidator< getSerializedData() { return serializePasskeyValidatorData({ - credentials, entryPoint: entryPointAddress, validatorAddress, pubKeyX: webAuthnKey.pubX, @@ -307,7 +304,6 @@ export async function deserializePasskeyValidator< } > { const { - credentials, entryPoint, validatorAddress, pubKeyX, @@ -438,7 +434,6 @@ export async function deserializePasskeyValidator< }, getSerializedData() { return serializePasskeyValidatorData({ - credentials, entryPoint, validatorAddress, pubKeyX, diff --git a/plugins/passkey/toWebAuthnKey.ts b/plugins/passkey/toWebAuthnKey.ts deleted file mode 100644 index b0c1a1e2..00000000 --- a/plugins/passkey/toWebAuthnKey.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { type Hex, keccak256 } from "viem" -import { b64ToBytes, uint8ArrayToHexString } from "./utils.js" - -export enum WebAuthnMode { - Register = "register", - Login = "login" -} - -export type WebAuthnKey = { - pubX: bigint - pubY: bigint - authenticatorId: string - authenticatorIdHash: Hex -} - -export type WebAuthnAccountParams = { - passkeyName: string - passkeyServerUrl: string - webAuthnKey?: WebAuthnKey - mode?: WebAuthnMode -} - -export const toWebAuthnKey = async ({ - passkeyName, - passkeyServerUrl, - webAuthnKey, - mode = WebAuthnMode.Register -}: WebAuthnAccountParams): Promise => { - if (webAuthnKey) { - return webAuthnKey - } - let pubKey: string | undefined - let authenticatorId: string | undefined - if (mode === WebAuthnMode.Login) { - // Get login options - const loginOptionsResponse = await fetch( - `${passkeyServerUrl}/login/options`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include" - } - ) - const loginOptions = await loginOptionsResponse.json() - - // Start authentication (login) - const { startAuthentication } = await import("@simplewebauthn/browser") - const loginCred = await startAuthentication(loginOptions) - - authenticatorId = loginCred.id - - // Verify authentication - const loginVerifyResponse = await fetch( - `${passkeyServerUrl}/login/verify`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cred: loginCred }), - credentials: "include" - } - ) - - const loginVerifyResult = await loginVerifyResponse.json() - - if (!loginVerifyResult.verification.verified) { - throw new Error("Login not verified") - } - // Import the key - pubKey = loginVerifyResult.pubkey // Uint8Array pubkey - } else { - // Get registration options - const registerOptionsResponse = await fetch( - `${passkeyServerUrl}/register/options`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ username: passkeyName }), - credentials: "include" - } - ) - const registerOptions = await registerOptionsResponse.json() - - // Start registration - const { startRegistration } = await import("@simplewebauthn/browser") - const registerCred = await startRegistration(registerOptions.options) - - authenticatorId = registerCred.id - - // Verify registration - const registerVerifyResponse = await fetch( - `${passkeyServerUrl}/register/verify`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - userId: registerOptions.userId, - username: passkeyName, - cred: registerCred - }), - credentials: "include" - } - ) - - const registerVerifyResult = await registerVerifyResponse.json() - if (!registerVerifyResult.verified) { - throw new Error("Registration not verified") - } - - // Import the key - pubKey = registerCred.response.publicKey - } - - if (!pubKey) { - throw new Error("No public key returned from registration credential") - } - if (!authenticatorId) { - throw new Error( - "No authenticator id returned from registration credential" - ) - } - - const authenticatorIdHash = keccak256( - uint8ArrayToHexString(b64ToBytes(authenticatorId)) - ) - const spkiDer = Buffer.from(pubKey, "base64") - const key = await crypto.subtle.importKey( - "spki", - spkiDer, - { - name: "ECDSA", - namedCurve: "P-256" - }, - true, - ["verify"] - ) - - // Export the key to the raw format - const rawKey = await crypto.subtle.exportKey("raw", key) - const rawKeyBuffer = Buffer.from(rawKey) - - // The first byte is 0x04 (uncompressed), followed by x and y coordinates (32 bytes each for P-256) - const pubKeyX = rawKeyBuffer.subarray(1, 33).toString("hex") - const pubKeyY = rawKeyBuffer.subarray(33).toString("hex") - - return { - pubX: BigInt(`0x${pubKeyX}`), - pubY: BigInt(`0x${pubKeyY}`), - authenticatorId, - authenticatorIdHash - } -} diff --git a/plugins/permission/CHANGELOG.md b/plugins/permission/CHANGELOG.md index 8f5f5f1a..a5c7492a 100644 --- a/plugins/permission/CHANGELOG.md +++ b/plugins/permission/CHANGELOG.md @@ -1,5 +1,11 @@ # @zerodev/permissions +## 5.3.2 + +### Patch Changes + +- Separated webauthn signer and webauthn key module + ## 5.3.1 ### Patch Changes diff --git a/plugins/permission/deserializePermissionAccount.ts b/plugins/permission/deserializePermissionAccount.ts index 01eab9bd..1ce70b38 100644 --- a/plugins/permission/deserializePermissionAccount.ts +++ b/plugins/permission/deserializePermissionAccount.ts @@ -11,6 +11,7 @@ import type { ValidatorInitData } from "@zerodev/sdk/types" import { getEntryPointVersion } from "permissionless" +import type { SmartAccountSigner } from "permissionless/accounts" import type { EntryPoint } from "permissionless/types" import type { Chain, Client, Hex, Transport } from "viem" import { decodeFunctionData } from "viem" @@ -52,7 +53,9 @@ export const deserializePermissionAccount = async < let signer: ModularSigner if (params.privateKey) signer = toECDSASigner({ - signer: privateKeyToAccount(params.privateKey) + signer: privateKeyToAccount( + params.privateKey + ) as SmartAccountSigner<"privateKey", `0x${string}`> }) else if (modularSigner) signer = modularSigner else throw new Error("No signer or serialized sessionKey provided") diff --git a/plugins/permission/package.json b/plugins/permission/package.json index cca91019..c109ebe7 100644 --- a/plugins/permission/package.json +++ b/plugins/permission/package.json @@ -1,6 +1,6 @@ { "name": "@zerodev/permissions", - "version": "5.3.1", + "version": "5.3.2", "author": "ZeroDev", "main": "./_cjs/index.js", "module": "./_esm/index.js", @@ -58,6 +58,7 @@ "peerDependencies": { "viem": "^2.16.3", "@zerodev/sdk": "^5.2.13", + "@zerodev/webauthn-key": "^5.3.0", "permissionless": "^0.1.18" } } diff --git a/plugins/permission/signers/index.ts b/plugins/permission/signers/index.ts index b32d87fe..09bcdd5f 100644 --- a/plugins/permission/signers/index.ts +++ b/plugins/permission/signers/index.ts @@ -6,6 +6,6 @@ export { toWebAuthnSigner, type WebAuthnModularSignerParams } from "./toWebAuthnSigner.js" -export { WebAuthnMode, toWebAuthnKey } from "./toWebAuthnKey.js" +export { WebAuthnMode, toWebAuthnKey } from "@zerodev/webauthn-key" export { toSignerId } from "./utils/toSignerId.js" export { toEmptyECDSASigner } from "./toEmptyECDSASigner.js" diff --git a/plugins/permission/signers/toWebAuthnKey.ts b/plugins/permission/signers/toWebAuthnKey.ts deleted file mode 100644 index a5252cf6..00000000 --- a/plugins/permission/signers/toWebAuthnKey.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Buffer } from "buffer" -import { type Hex, keccak256 } from "viem" -import { b64ToBytes, uint8ArrayToHexString } from "./webAuthnUtils.js" - -export enum WebAuthnMode { - Register = "register", - Login = "login" -} - -export type WebAuthnKey = { - pubX: bigint - pubY: bigint - authenticatorIdHash: Hex -} - -export const toWebAuthnKey = async ({ - passkeyName, - passkeyServerUrl, - webAuthnKey, - mode = WebAuthnMode.Login -}: { - passkeyName: string - passkeyServerUrl: string - webAuthnKey?: WebAuthnKey - mode: WebAuthnMode -}): Promise => { - if (webAuthnKey) { - return webAuthnKey - } - let pubKey: string | undefined - let authenticatorIdHash: Hex - if (mode === WebAuthnMode.Login) { - // Get login options - const loginOptionsResponse = await fetch( - `${passkeyServerUrl}/login/options`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include" - } - ) - const loginOptions = await loginOptionsResponse.json() - - // Start authentication (login) - const { startAuthentication } = await import("@simplewebauthn/browser") - const loginCred = await startAuthentication(loginOptions) - - // get authenticatorIdHash - authenticatorIdHash = keccak256( - uint8ArrayToHexString(b64ToBytes(loginCred.id)) - ) - - // Verify authentication - const loginVerifyResponse = await fetch( - `${passkeyServerUrl}/login/verify`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cred: loginCred }), - credentials: "include" - } - ) - - const loginVerifyResult = await loginVerifyResponse.json() - - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - sessionStorage.setItem("userId", loginVerifyResult.userId) - - if (!loginVerifyResult.verification.verified) { - throw new Error("Login not verified") - } - // Import the key - pubKey = loginVerifyResult.pubkey // Uint8Array pubkey - } else { - if (!passkeyName) { - throw new Error("No passkey name provided") - } - // Get registration options - const registerOptionsResponse = await fetch( - `${passkeyServerUrl}/register/options`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ username: passkeyName }), - credentials: "include" - } - ) - const registerOptions = await registerOptionsResponse.json() - - // save userId to sessionStorage - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - sessionStorage.setItem("userId", registerOptions.userId) - - // Start registration - const { startRegistration } = await import("@simplewebauthn/browser") - const registerCred = await startRegistration(registerOptions.options) - - // get authenticatorIdHash - authenticatorIdHash = keccak256( - uint8ArrayToHexString(b64ToBytes(registerCred.id)) - ) - - // Verify registration - const registerVerifyResponse = await fetch( - `${passkeyServerUrl}/register/verify`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - userId: registerOptions.userId, - username: passkeyName, - cred: registerCred - }), - credentials: "include" - } - ) - - const registerVerifyResult = await registerVerifyResponse.json() - if (!registerVerifyResult.verified) { - throw new Error("Registration not verified") - } - - // Import the key - pubKey = registerCred.response.publicKey - } - if (!pubKey) { - throw new Error("No public key returned from registration credential") - } - - const spkiDer = Buffer.from(pubKey, "base64") - const key = await crypto.subtle.importKey( - "spki", - spkiDer, - { - name: "ECDSA", - namedCurve: "P-256" - }, - true, - ["verify"] - ) - - // Export the key to the raw format - const rawKey = await crypto.subtle.exportKey("raw", key) - const rawKeyBuffer = Buffer.from(rawKey) - - // The first byte is 0x04 (uncompressed), followed by x and y coordinates (32 bytes each for P-256) - const pubKeyX = rawKeyBuffer.subarray(1, 33).toString("hex") - const pubKeyY = rawKeyBuffer.subarray(33).toString("hex") - - return { - pubX: BigInt(`0x${pubKeyX}`), - pubY: BigInt(`0x${pubKeyY}`), - authenticatorIdHash - } -} diff --git a/plugins/permission/signers/toWebAuthnSigner.ts b/plugins/permission/signers/toWebAuthnSigner.ts index 6c9ceddd..c85c458e 100644 --- a/plugins/permission/signers/toWebAuthnSigner.ts +++ b/plugins/permission/signers/toWebAuthnSigner.ts @@ -1,4 +1,14 @@ import type { PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/typescript-types" +import type { WebAuthnKey } from "@zerodev/webauthn-key" +import { + b64ToBytes, + base64FromUint8Array, + findQuoteIndices, + hexStringToUint8Array, + isRIP7212SupportedNetwork, + parseAndNormalizeSig, + uint8ArrayToHexString +} from "@zerodev/webauthn-key" import type { TypedData } from "abitype" import { SignTransactionNotSupportedBySmartAccount } from "permissionless/accounts" import { @@ -14,23 +24,93 @@ import { } from "viem" import { type SignableMessage, encodeAbiParameters } from "viem" import { toAccount } from "viem/accounts" -import { getChainId } from "viem/actions" +import { getChainId, signMessage } from "viem/actions" import { WEBAUTHN_SIGNER_CONTRACT } from "../constants.js" import type { ModularSigner, ModularSignerParams } from "../types.js" -import type { WebAuthnKey } from "./toWebAuthnKey.js" -import { - b64ToBytes, - findQuoteIndices, - isRIP7212SupportedNetwork, - parseAndNormalizeSig, - uint8ArrayToHexString -} from "./webAuthnUtils.js" export type WebAuthnModularSignerParams = ModularSignerParams & { - passkeyServerUrl: string webAuthnKey: WebAuthnKey } +const signMessageUsingWebAuthn = async ( + message: SignableMessage, + chainId: number, + allowCredentials?: PublicKeyCredentialRequestOptionsJSON["allowCredentials"] +) => { + let messageContent: string + if (typeof message === "string") { + // message is a string + messageContent = message + } else if ("raw" in message && typeof message.raw === "string") { + // message.raw is a Hex string + messageContent = message.raw + } else if ("raw" in message && message.raw instanceof Uint8Array) { + // message.raw is a ByteArray + messageContent = message.raw.toString() + } else { + throw new Error("Unsupported message format") + } + + // remove 0x prefix if present + const formattedMessage = messageContent.startsWith("0x") + ? messageContent.slice(2) + : messageContent + + const challenge = base64FromUint8Array( + hexStringToUint8Array(formattedMessage), + true + ) + + // prepare assertion options + const assertionOptions: PublicKeyCredentialRequestOptionsJSON = { + challenge, + allowCredentials, + userVerification: "required" + } + + // start authentication (signing) + const { startAuthentication } = await import("@simplewebauthn/browser") + const cred = await startAuthentication(assertionOptions) + + // get authenticator data + const { authenticatorData } = cred.response + const authenticatorDataHex = uint8ArrayToHexString( + b64ToBytes(authenticatorData) + ) + + // get client data JSON + const clientDataJSON = atob(cred.response.clientDataJSON) + + // get challenge and response type location + const { beforeType } = findQuoteIndices(clientDataJSON) + + // get signature r,s + const { signature } = cred.response + const signatureHex = uint8ArrayToHexString(b64ToBytes(signature)) + const { r, s } = parseAndNormalizeSig(signatureHex) + + // encode signature + const encodedSignature = encodeAbiParameters( + [ + { name: "authenticatorData", type: "bytes" }, + { name: "clientDataJSON", type: "string" }, + { name: "responseTypeLocation", type: "uint256" }, + { name: "r", type: "uint256" }, + { name: "s", type: "uint256" }, + { name: "usePrecompiled", type: "bool" } + ], + [ + authenticatorDataHex, + clientDataJSON, + beforeType, + BigInt(r), + BigInt(s), + isRIP7212SupportedNetwork(chainId) + ] + ) + return encodedSignature +} + export const toWebAuthnSigner = async < TTransport extends Transport = Transport, TChain extends Chain | undefined = Chain | undefined @@ -38,118 +118,18 @@ export const toWebAuthnSigner = async < client: Client, { signerContractAddress = WEBAUTHN_SIGNER_CONTRACT, - passkeyServerUrl, webAuthnKey }: WebAuthnModularSignerParams ): Promise => { const chainId = await getChainId(client) - const signMessageUsingWebAuthn = async (message: SignableMessage) => { - let messageContent: string - if (typeof message === "string") { - // message is a string - messageContent = message - } else if ("raw" in message && typeof message.raw === "string") { - // message.raw is a Hex string - messageContent = message.raw - } else if ("raw" in message && message.raw instanceof Uint8Array) { - // message.raw is a ByteArray - messageContent = message.raw.toString() - } else { - throw new Error("Unsupported message format") - } - - // remove 0x prefix if present - const formattedMessage = messageContent.startsWith("0x") - ? messageContent.slice(2) - : messageContent - - if (window.sessionStorage === undefined) { - throw new Error("sessionStorage is not available") - } - const userId = sessionStorage.getItem("userId") - - // initiate signing - const signInitiateResponse = await fetch( - `${passkeyServerUrl}/sign-initiate`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: formattedMessage, userId }), - credentials: "include" - } - ) - const signInitiateResult = await signInitiateResponse.json() - - // prepare assertion options - const assertionOptions: PublicKeyCredentialRequestOptionsJSON = { - challenge: signInitiateResult.challenge, - allowCredentials: signInitiateResult.allowCredentials, - userVerification: "required" - } - - // start authentication (signing) - - const { startAuthentication } = await import("@simplewebauthn/browser") - const cred = await startAuthentication(assertionOptions) - - // verify signature from server - const verifyResponse = await fetch(`${passkeyServerUrl}/sign-verify`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cred, userId }), - credentials: "include" - }) - - const verifyResult = await verifyResponse.json() - - if (!verifyResult.success) { - throw new Error("Signature not verified") - } - - // get authenticator data - const authenticatorData = verifyResult.authenticatorData - const authenticatorDataHex = uint8ArrayToHexString( - b64ToBytes(authenticatorData) - ) - - // get client data JSON - const clientDataJSON = atob(cred.response.clientDataJSON) - - // get challenge and response type location - const { beforeType } = findQuoteIndices(clientDataJSON) - - // get signature r,s - const signature = verifyResult.signature - const signatureHex = uint8ArrayToHexString(b64ToBytes(signature)) - const { r, s } = parseAndNormalizeSig(signatureHex) - - // encode signature - const encodedSignature = encodeAbiParameters( - [ - { name: "authenticatorData", type: "bytes" }, - { name: "clientDataJSON", type: "string" }, - { name: "responseTypeLocation", type: "uint256" }, - { name: "r", type: "uint256" }, - { name: "s", type: "uint256" }, - { name: "usePrecompiled", type: "bool" } - ], - [ - authenticatorDataHex, - clientDataJSON, - beforeType, - BigInt(r), - BigInt(s), - isRIP7212SupportedNetwork(chainId) - ] - ) - return encodedSignature - } const account: LocalAccount = toAccount({ // note that this address will be overwritten by actual address address: "0x0000000000000000000000000000000000000000", async signMessage({ message }) { - return signMessageUsingWebAuthn(message) + return signMessageUsingWebAuthn(message, chainId, [ + { id: webAuthnKey.authenticatorId, type: "public-key" } + ]) }, async signTransaction(_, __) { throw new SignTransactionNotSupportedBySmartAccount() @@ -171,7 +151,11 @@ export const toWebAuthnSigner = async < validateTypedData({ domain, message, primaryType, types }) const hash = hashTypedData(typedData) - return signMessageUsingWebAuthn(hash) + const signature = await signMessage(client, { + account, + message: hash + }) + return signature } }) @@ -209,7 +193,7 @@ export const toWebAuthnSigner = async < ], [ "0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97631d00000000", - '{"type":"webauthn.get","challenge":"tbxXNFS9X_4Byr1cMwqKrIGB-_30a0QhZ6y7ucM0BOE","origin":"http://localhost:3000","crossOrigin":false}', + '{"type":"webauthn.get","challenge":"tbxXNFS9X_4Byr1cMwqKrIGB-_30a0QhZ6y7ucM0BOE","origin":"http://localhost:3000","crossOrigin":false, "other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}', 1n, 44941127272049826721201904734628716258498742255959991581049806490182030242267n, 9910254599581058084911561569808925251374718953855182016200087235935345969636n, diff --git a/plugins/permission/signers/webAuthnUtils.ts b/plugins/permission/signers/webAuthnUtils.ts deleted file mode 100644 index d8c30814..00000000 --- a/plugins/permission/signers/webAuthnUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { p256 } from "@noble/curves/p256" -import { type Hex, bytesToBigInt, hexToBytes } from "viem" - -const RIP7212_SUPPORTED_NETWORKS = [80001] - -export const uint8ArrayToHexString = (array: Uint8Array): `0x${string}` => { - return `0x${Array.from(array, (byte) => - byte.toString(16).padStart(2, "0") - ).join("")}` as `0x${string}` -} - -export const b64ToBytes = (base64: string): Uint8Array => { - const paddedBase64 = base64 - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=") - const binString = atob(paddedBase64) - return Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0) -} - -export const findQuoteIndices = ( - input: string -): { beforeType: bigint; beforeChallenge: bigint } => { - const beforeTypeIndex = BigInt(input.lastIndexOf('"type":"webauthn.get"')) - const beforeChallengeIndex = BigInt(input.indexOf('"challenge')) - return { - beforeType: beforeTypeIndex, - beforeChallenge: beforeChallengeIndex - } -} - -// Parse DER-encoded P256-SHA256 signature to contract-friendly signature -// and normalize it so the signature is not malleable. -export function parseAndNormalizeSig(derSig: Hex): { r: bigint; s: bigint } { - const parsedSignature = p256.Signature.fromDER(derSig.slice(2)) - const bSig = hexToBytes(`0x${parsedSignature.toCompactHex()}`) - // assert(bSig.length === 64, "signature is not 64 bytes"); - const bR = bSig.slice(0, 32) - const bS = bSig.slice(32) - - // Avoid malleability. Ensure low S (<= N/2 where N is the curve order) - const r = bytesToBigInt(bR) - let s = bytesToBigInt(bS) - const n = BigInt( - "0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551" - ) - if (s > n / 2n) { - s = n - s - } - return { r, s } -} - -export const isRIP7212SupportedNetwork = (chainId: number): boolean => - RIP7212_SUPPORTED_NETWORKS.includes(chainId) diff --git a/plugins/webauthn-key/CHANGELOG.md b/plugins/webauthn-key/CHANGELOG.md new file mode 100644 index 00000000..7e557ecd --- /dev/null +++ b/plugins/webauthn-key/CHANGELOG.md @@ -0,0 +1,7 @@ +# @zerodev/passkey-validator + +## 5.3.0 + +### Patch Changes + +- Add `toWebAuthnKey` function diff --git a/plugins/webauthn-key/index.ts b/plugins/webauthn-key/index.ts new file mode 100644 index 00000000..72b4a76b --- /dev/null +++ b/plugins/webauthn-key/index.ts @@ -0,0 +1,2 @@ +export * from "./toWebAuthnKey.js" +export * from "./utils.js" diff --git a/plugins/webauthn-key/package.json b/plugins/webauthn-key/package.json new file mode 100644 index 00000000..bb9dc0d3 --- /dev/null +++ b/plugins/webauthn-key/package.json @@ -0,0 +1,44 @@ +{ + "name": "@zerodev/webauthn-key", + "version": "5.3.0", + "author": "ZeroDev", + "main": "./_cjs/index.js", + "module": "./_esm/index.js", + "types": "./_types/index.d.ts", + "typings": "./_types/index.d.ts", + "type": "module", + "sideEffects": false, + "license": "MIT", + "files": [ + "_esm", + "_cjs", + "_types", + "./**/*.ts", + "!_esm/**/*.tsbuildinfo", + "!_cjs/**/*.tsbuildinfo", + "!_types/**/*.tsbuildinfo", + "!.env", + "!./**/*.test.ts", + "!.changeset" + ], + "scripts": { + "build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types", + "build:cjs": "tsc --project ./tsconfig.build.json --module commonjs --outDir ./_cjs --removeComments --verbatimModuleSyntax false && printf '{\"type\":\"commonjs\"}' > ./_cjs/package.json", + "build:esm": "tsc --project ./tsconfig.build.json --module es2020 --outDir ./_esm && printf '{\"type\": \"module\",\"sideEffects\":false}' > ./_esm/package.json", + "build:types": "tsc --project ./tsconfig.build.json --module esnext --declarationDir ./_types --emitDeclarationOnly --declaration --declarationMap", + "clean": "rimraf _esm _cjs _types", + "changeset": "changeset", + "changeset:release": "bun run build && changeset publish", + "changeset:version": "changeset version && bun install --lockfile-only", + "format": "biome format . --write", + "lint": "biome check .", + "lint:fix": "bun run lint --apply" + }, + "dependencies": { + "@noble/curves": "^1.3.0", + "@simplewebauthn/browser": "^8.3.4" + }, + "peerDependencies": { + "viem": "^2.16.3" + } +} diff --git a/plugins/multichain/webauthn/toWebAuthnAccount.ts b/plugins/webauthn-key/toWebAuthnKey.ts similarity index 92% rename from plugins/multichain/webauthn/toWebAuthnAccount.ts rename to plugins/webauthn-key/toWebAuthnKey.ts index 08f2577e..d5e81ff9 100644 --- a/plugins/multichain/webauthn/toWebAuthnAccount.ts +++ b/plugins/webauthn-key/toWebAuthnKey.ts @@ -1,5 +1,5 @@ -import { type Hex, keccak256 } from "viem" -import { b64ToBytes, uint8ArrayToHexString } from "./webAuthnUtils.js" +import { type Hex, concatHex, keccak256, pad, toHex } from "viem" +import { b64ToBytes, uint8ArrayToHexString } from "./utils.js" export enum WebAuthnMode { Register = "register", @@ -21,6 +21,14 @@ export type WebAuthnAccountParams = { credentials?: RequestCredentials } +export const encodeWebAuthnPubKey = (pubKey: WebAuthnKey) => { + return concatHex([ + toHex(pubKey.pubX, { size: 32 }), + toHex(pubKey.pubY, { size: 32 }), + pad(pubKey.authenticatorIdHash, { size: 32 }) + ]) +} + export const toWebAuthnKey = async ({ passkeyName, passkeyServerUrl, diff --git a/plugins/webauthn-key/tsconfig.build.json b/plugins/webauthn-key/tsconfig.build.json new file mode 100644 index 00000000..cc7e1921 --- /dev/null +++ b/plugins/webauthn-key/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "../../templates/typescript/tsconfig.build.json", + "include": ["./"], + "exclude": ["./**/*.test.ts", "./**/*.test-d.ts", "./**/*.bench.ts"], + "compilerOptions": { + "moduleResolution": "node", + "sourceMap": true, + "rootDir": "./", + "baseUrl": "./" + } +} diff --git a/plugins/passkey/utils.ts b/plugins/webauthn-key/utils.ts similarity index 99% rename from plugins/passkey/utils.ts rename to plugins/webauthn-key/utils.ts index 4d195b31..d488d1c7 100644 --- a/plugins/passkey/utils.ts +++ b/plugins/webauthn-key/utils.ts @@ -68,7 +68,6 @@ export function parseAndNormalizeSig(derSig: Hex): { r: bigint; s: bigint } { } type PasskeyValidatorSerializedData = { - credentials: string entryPoint: Hex validatorAddress: Hex pubKeyX: bigint