You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在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));

关键说明

  1. 曲线适配:用crypto.webcrypto.subtle直接处理P-256曲线的签名验证,避开ethers.js默认的secp256k1逻辑。
  2. 哈希对齐:保持WebAuthn的SHA-256哈希逻辑,不额外做keccak256哈希,确保签名验证的一致性。
  3. 地址转换:以太坊地址由公钥的keccak256哈希后取最后20字节生成,与公钥所属曲线无关,只需提取P-256公钥的原始x/y坐标即可转换。

智能合约端适配

以太坊原生ecrecover仅支持secp256k1曲线,无法直接验证P-256签名,可采用以下方案:

  • 链下完成P-256签名验证,将验证结果(如签名有效凭证)提交到合约。
  • 自定义合约逻辑,引入P-256曲线的验证算法(需依赖第三方密码学库实现)。

内容的提问来源于stack exchange,提问作者Yao

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.08 07:30:50