在ethers.js中验证WebAuthn P-256 ECDSA签名遇错求解决方案
问题:WebAuthn P-256签名在ethers.js中验证失败,无法正确恢复以太坊地址
问题背景
使用SimpleWebAuthn TypeScript包生成基于P-256曲线的ECDSA-SHA256密钥对,并用其对挑战签名,该包通过crypto.webcrypto.subtle可正常验证签名。但尝试在ethers.js中验证此签名(为后续以太坊智能合约用ecrecover做WebAuthn签名验证做概念验证)时,ethers.utils.recoverAddress要么每次恢复的地址都不同,要么抛出invalid point错误。
当前代码实现
import crypto from 'crypto'; import base64url from 'base64url'; import { ethers } from 'ethers'; import { AuthenticationCredentialJSON } from '@simplewebauthn/typescript-types'; import { ECDSASigValue } from '@peculiar/asn1-ecc'; import { AsnParser } from '@peculiar/asn1-schema'; // Helper functions from @simplewebauthn/server function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; } function fromUTF8String(utf8String: string): Uint8Array { const encoder = new globalThis.TextEncoder(); return encoder.encode(utf8String); } async function digest(data: Uint8Array, _algorithm: number): Promise<Uint8Array> { const hashed = await crypto.webcrypto.subtle.digest('SHA-256', data); return new Uint8Array(hashed); } async function toHash(data: Uint8Array | string, algorithm = -7): Promise<Uint8Array> { if (typeof data === 'string') { data = fromUTF8String(data); } return digest(data, algorithm); } function concat(arrays: Uint8Array[]): Uint8Array { let pointer = 0; const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); const toReturn = new Uint8Array(totalLength); arrays.forEach((arr) => { toReturn.set(arr, pointer); pointer += arr.length; }); return toReturn; } async function verifyAuthentication(authJson: AuthenticationCredentialJSON) { // 1. Creates the digest WebAuthn signs const authDataBuffer = base64url.toBuffer(authJson.response.authenticatorData); const clientDataHash = await toHash(base64url.toBuffer(authJson.response.clientDataJSON)); const signatureBase = concat([authDataBuffer, clientDataHash]); // 2. Retrieving the r and s values const parsedSignature = AsnParser.parse( base64url.toBuffer(authJson.response.signature), ECDSASigValue, ); let rBytes = new Uint8Array(parsedSignature.r); let sBytes = new Uint8Array(parsedSignature.s); if (shouldRemoveLeadingZero(rBytes)) { rBytes = rBytes.slice(1); } if (shouldRemoveLeadingZero(sBytes)) { sBytes = sBytes.slice(1); } // 3. Recover the Ethereum address from the digest and the signature const finalSignature = ethers.utils.concat([rBytes, sBytes]); return ethers.utils.recoverAddress(signatureBase, finalSignature) } // 4. authJson is the result from the startAuthentication method const authJson = { id: 'tS94WWAhrxcCg0tKfStI7wCgL14rAzqlHX5qaNbE8jw', rawId: 'tS94WWAhrxcCg0tKfStI7wCgL14rAzqlHX5qaNbE8jw', response: { authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA', clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZDlmMjlhNGUzNDdhZDg5ZGM3MDQ5MDEyNGVlNjk3NWZiYzA2OTNjN2U3MmQ2YmMzODM2NzNiZmQwZTg4NDFmMiIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0', signature: 'MEUCIDfTCO7Eei5iOC4exyEL65yFrr_ZWZCLz6n3BVeGWt8uAiEAkHyTMXDESHWzW7XKpq1l4eF2KkmDbt1-55CAXpQ3AkE', userHandle: '7e5ba61e-6f42-4351-93a1-f7b057551df7' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' } verifyAuthentication(authJson)
报错信息
Error: invalid point at ShortCurve.pointFromX (/Users/alex/programming/projects/playground/api/node_modules/elliptic/lib/elliptic/curve/short.js:195:11) at EC.recoverPubKey (/Users/alex/programming/projects/playground/api/node_modules/elliptic/lib/elliptic/ec/index.js:215:20) at recoverPublicKey (/Users/alex/programming/projects/playground/api/node_modules/@ethersproject/signing-key/src.ts/index.ts:82:30) at Object.recoverAddress (/Users/alex/programming/projects/playground/api/node_modules/@ethersproject/transactions/src.ts/index.ts:115:43) at WebAuthnApiService.verifyAuthentication (/Users/alex/programming/projects/playground/api/src/services/webauthn/webauthn.service.ts:343:20) at target (/Users/alex/programming/projects/playground/api/node_modules/@nestjs/core/helpers/external-context-creator.js:77:28) at Object.verifyAuthentication (/Users/alex/programming/projects/playground/api/node_modules/@nestjs/core/helpers/external-proxy.js:9:24)
原因分析
- 曲线不匹配:ethers.js默认使用secp256k1曲线处理签名逻辑,但WebAuthn生成的密钥基于P-256(secp256r1)曲线,曲线参数差异导致公钥恢复失败。
- 哈希算法冲突:ethers.js的
recoverAddress默认会对输入消息做keccak256哈希,但WebAuthn签名的是SHA-256哈希后的消息,双重哈希导致签名验证逻辑不兼容。
解决方案
绕过ethers.js默认的secp256k1逻辑,用WebCrypto处理P-256签名验证,再将P-256公钥转换为以太坊格式地址:
修改后的完整代码
import crypto from 'crypto'; import base64url from 'base64url'; import { ethers } from 'ethers'; import { AuthenticationCredentialJSON } from '@simplewebauthn/typescript-types'; import { ECDSASigValue } from '@peculiar/asn1-ecc'; import { AsnParser } from '@peculiar/asn1-schema'; // 替换为WebAuthn注册时保存的SPKI格式公钥 const REGISTERED_PUB_KEY = base64url.toBuffer('pQECAyYgASFYILeL7z+8Q8QKz+GJZ4X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X7X'); // Helper functions from @simplewebauthn/server function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; } function fromUTF8String(utf8String: string): Uint8Array { const encoder = new globalThis.TextEncoder(); return encoder.encode(utf8String); } async function digest(data: Uint8Array, algorithm: string): Promise<Uint8Array> { const hashed = await crypto.webcrypto.subtle.digest(algorithm, data); return new Uint8Array(hashed); } async function toHash(data: Uint8Array | string, algorithm = 'SHA-256'): Promise<Uint8Array> { if (typeof data === 'string') { data = fromUTF8String(data); } return digest(data, algorithm); } function concat(arrays: Uint8Array[]): Uint8Array { let pointer = 0; const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); const toReturn = new Uint8Array(totalLength); arrays.forEach((arr) => { toReturn.set(arr, pointer); pointer += arr.length; }); return toReturn; } // 将P-256公钥转换为以太坊地址 async function p256PubKeyToEthAddress(pubKeyBuffer: Uint8Array): Promise<string> { // 导入SPKI格式公钥 const pubKey = await crypto.webcrypto.subtle.importKey( 'spki', pubKeyBuffer, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] ); // 导出原始公钥(x+y坐标,共64字节) const rawPubKey = await crypto.webcrypto.subtle.exportKey('raw', pubKey); const rawPubKeyBytes = new Uint8Array(rawPubKey); // 计算keccak256哈希,取最后20字节生成以太坊地址 const keccakHash = ethers.utils.keccak256(rawPubKeyBytes); const ethAddress = ethers.utils.getAddress(`0x${keccakHash.slice(-40)}`); return ethAddress; } async function verifyAuthentication(authJson: AuthenticationCredentialJSON) { // 1. 构造WebAuthn签名的原始数据 const authDataBuffer = base64url.toBuffer(authJson.response.authenticatorData); const clientDataHash = await toHash(base64url.toBuffer(authJson.response.clientDataJSON)); const signatureBase = concat([authDataBuffer, clientDataHash]); // 2. 解析签名的r和s值 const parsedSignature = AsnParser.parse( base64url.toBuffer(authJson.response.signature), ECDSASigValue, ); let rBytes = new Uint8Array(parsedSignature.r); let sBytes = new Uint8Array(parsedSignature.s); if (shouldRemoveLeadingZero(rBytes)) { rBytes = rBytes.slice(1); } if (shouldRemoveLeadingZero(sBytes)) { sBytes = sBytes.slice(1); } // 3. 用WebCrypto验证P-256签名 const pubKey = await crypto.webcrypto.subtle.importKey( 'spki', REGISTERED_PUB_KEY, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify'] ); const signatureBytes = concat([rBytes, sBytes]); const isSignatureValid = await crypto.webcrypto.subtle.verify( { name: 'ECDSA', hash: 'SHA-256' }, pubKey, signatureBytes, signatureBase ); if (!isSignatureValid) { throw new Error('WebAuthn signature verification failed'); } // 4. 转换公钥为以太坊地址 const ethAddress = await p256PubKeyToEthAddress(REGISTERED_PUB_KEY); return ethAddress; } // 示例authJson const authJson = { id: 'tS94WWAhrxcCg0tKfStI7wCgL14rAzqlHX5qaNbE8jw', rawId: 'tS94WWAhrxcCg0tKfStI7wCgL14rAzqlHX5qaNbE8jw', response: { authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA', clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiZDlmMjlhNGUzNDdhZDg5ZGM3MDQ5MDEyNGVlNjk3NWZiYzA2OTNjN2U3MmQ2YmMzODM2NzNiZmQwZTg4NDFmMiIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0', signature: 'MEUCIDfTCO7Eei5iOC4exyEL65yFrr_ZWZCLz6n3BVeGWt8uAiEAkHyTMXDESHWzW7XKpq1l4eF2KkmDbt1-55CAXpQ3AkE', userHandle: '7e5ba61e-6f42-4351-93a1-f7b057551df7' }, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform' }; verifyAuthentication(authJson) .then(address => console.log('Recovered Ethereum Address:', address)) .catch(err => console.error('Error:', err));
关键说明
- 曲线适配:用
crypto.webcrypto.subtle直接处理P-256曲线的签名验证,避开ethers.js默认的secp256k1逻辑。 - 哈希对齐:保持WebAuthn的SHA-256哈希逻辑,不额外做keccak256哈希,确保签名验证的一致性。
- 地址转换:以太坊地址由公钥的keccak256哈希后取最后20字节生成,与公钥所属曲线无关,只需提取P-256公钥的原始x/y坐标即可转换。
智能合约端适配
以太坊原生ecrecover仅支持secp256k1曲线,无法直接验证P-256签名,可采用以下方案:
- 链下完成P-256签名验证,将验证结果(如签名有效凭证)提交到合约。
- 自定义合约逻辑,引入P-256曲线的验证算法(需依赖第三方密码学库实现)。
内容的提问来源于stack exchange,提问作者Yao
相关产品推荐
相关产品推荐

