typescript - Verificar assinatura ECDSA do WebAuthn em ethers.js
Usando o pacote SimpleWebAuthn em TypeScript, gerei um par de chaves ECDSA-SHA256 e usei o par de chaves para assinar um desafio. O pacote SimpleWebAuthn usa crypto.webcrypto.subtle (veja esta linha) para verificar a assinatura. Quero verificar a assinatura no ethers.js como um prova de conceito para que eu possa verificar a assinatura WebAuthn no meu contrato inteligente Ethereum usando ecrecover. Abaixo está um trecho de código do que estou fazendo atualmente
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, see https://github.com/MasterKale/SimpleWebAuthn/blob/6f363aa53a69cf8c1ea69664924c1e9f8e19dc4e/packages/server/src/authentication/verifyAuthenticationResponse.ts#L189
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, see https://github.com/MasterKale/SimpleWebAuthn/blob/6f363aa53a69cf8c1ea69664924c1e9f8e19dc4e/packages/server/src/helpers/iso/isoCrypto/verifyEC2.ts#L103
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)
Às vezes, o ethers.utils.recoverAddress funcionava com sucesso, mas o endereço público que ele conseguia recuperar era diferente a cada vez. Às vezes, o ethers.utils.recoverAddress lançava este erro
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 lida com chaves com a curva secp256k, mas o par de chaves WebAuthn pertence à curva P-256, então isso pode ser o motivo pelo qual ele gera o erro de ponto inválido, mas eu não entendo por que isso acontece apenas algumas vezes. Não posso fazer muito aqui porque os únicos tipos de par de chaves ECDSA que meu dispositivo suporta pertencem à curva P-256
Outra área de problema pode ser o formato do hash da mensagem; geralmente fazemos keccak256 do valor da mensagem antes de assinarmos os dados usando ethers.js, mas o WebAuthn não parece fazer isso, então não conseguimos recuperar o endereço público de forma confiável.
Observação: estou usando uma versão atualizada do ethers.js (v5.5.1)
Resposta #1
Sim, tanto a curva quanto o hash estão errados.
Primeiro, o nome da curva usada pelo Ethereum e, portanto, pelo ethers.js (e também pelo Bitcoin) é secp256k1 -- embora tecnicamente isso seja redundante, porque não existem outras curvas Koblitz primárias SECG de tamanho 256. O WebAuthn, como muitas outras coisas, usa (a maioria) das curvas adotadas pelo NIST em FIPS186-2 em diante (mas agora propostas para serem transferidas para o SP800-186), incluindo P-256, que também é nomeada pela SECG como secp256r1 (note o r) e pelo X9 como prime256v1.
O algoritmo de verificação-com-recuperação ECDSA gera efetivamente os valores possíveis de kG na geração da assinatura e encontra aquele — exatamente um para uma assinatura válida — que corresponde à chave pública correta (dG) (dada a representante correta da mensagem z, veja abaixo). Porque todas as curvas primárias X9/SECG, incluindo secp256k1, foram escolhidas para ter cofactor 1 e, portanto, n dentro do limite de Hasse de p, isso quase sempre tem coordenada x r+0n, raramente r+1n, e nunca mais. Mas se você fornecer uma assinatura inválida — e uma assinatura calculada para P-256 é inválida para secp256k1 — o algoritmo efetivamente gera com 50% de probabilidade uma coordenada x que não corresponde a nenhum ponto (e gera a exceção) e com 50% de probabilidade uma coordenada x que corresponde a um ponto que satisfaz a equação de verificação, mas que não tem nenhuma relação com a chave pública 'correta' usada na geração da assinatura P-256.
Além disso, mesmo que o primitivo ECDSA estivesse correto (ou seja, usasse a mesma curva), o WebAuthn, novamente como a maioria das coisas, usa hashes FIPS180 conforme endossado pelo FIPS186 -- neste caso SHA-256 para P-256, veja as linhas 79-88 na página que você linka -- e não a variante Keccak usada pelo Ethereum, e portanto ainda assim 'recuperaria' uma chave pública totalmente falsa e efetivamente aleatória (e o endereço, ou seja, a coordenada x dessa chave).
ethers.js só pode verificar e recuperar uma assinatura Ethereum em dados, e isso não é uma assinatura Ethereum. Seu conceito deve ser alterado.
Resposta #2
Como @dave_thompson_085 mencionou, o exemplo na minha pergunta não funciona porque o par de chaves WebAuthn (o ID do algoritmo COSE é -7) está na curva secp256r1/p256 e a assinatura usa o algoritmo SHA-256, mas o ethers.js só funciona para chaves na curva secp256k1 e a variante de hash keccak256.
Portanto, eu precisava de uma função de verificação de assinatura que funcionasse para a curva p256. Eu encontrei um contrato inteligente Solidity que faz exatamente isso. No entanto, este repositório não é mantido ativamente e o contrato usa Solidity v0.5.0 e ele quebra se atualizarmos para a versão mais recente do compilador Solidity (v0.8.x) por causa de erros de overflow/underflow aritmético. Então, para usar este contrato EllipticCurve.sol, precisamos primeiro implantar o contrato e usá-lo como um contrato externo.
Podemos testar o contrato que o autor implantou na mainnet. Aqui está o trecho de código de exemplo para analisar a resposta do autenticador WebAuthn e sua chave pública, para que possamos passar esses valores como entradas para a função validateSignature do contrato EllipticCurve.
import cbor from 'cbor';
import crypto from 'crypto';
import elliptic from 'elliptic';
import base64url from 'base64url';
import { ECDSASigValue } from '@peculiar/asn1-ecc';
import { AsnParser } from '@peculiar/asn1-schema';
import { AuthenticatorAssertionResponseJSON } from '@simplewebauthn/typescript-types';
enum COSEKEYS {
kty = 1,
alg = 3,
crv = -1,
x = -2,
y = -3,
n = -1,
e = -2,
}
const EC = elliptic.ec;
const ec = new EC('p256');
function toHash(data: crypto.BinaryLike, algo = 'SHA256') {
return crypto.createHash(algo).update(data).digest();
}
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
/**
* Parse the WebAuthn data payload and to create the inputs to verify the secp256r1/p256 signatures
* in the EllipticCurve.sol contract, see https://github.com/tdrerup/elliptic-curve-solidity
*/
export const authResponseToSigVerificationInput = (
// Assumes the public key is on the secp256r1/p256 curve
credentialPublicKey: Buffer,
authResponse: AuthenticatorAssertionResponseJSON,
) => {
const authDataBuffer = base64url.toBuffer(authResponse.authenticatorData);
const clientDataHash = toHash(base64url.toBuffer(authResponse.clientDataJSON));
const signatureBase = Buffer.concat([authDataBuffer, clientDataHash]);
// See https://github.dev/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/verifyEC2.ts
// for extraction of the r and s bytes from the raw signature buffer
const parsedSignature = AsnParser.parse(
base64url.toBuffer(authResponse.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);
}
// See convertCOSEtoPKCS.js
const struct = cbor.decodeAllSync(credentialPublicKey)[0];
const x = struct.get(COSEKEYS.x);
const y = struct.get(COSEKEYS.y);
const pk = ec.keyFromPublic({ x, y });
// Message data in sha256 hash
const messageHash = '0x' + toHash(signatureBase).toString('hex');
// r and s values
const signature = [
'0x' + Buffer.from(rBytes).toString('hex'),
'0x' + Buffer.from(sBytes).toString('hex'),
];
// x and y coordinates
const publicKey = [
'0x' + pk.getPublic('hex').slice(2, 66),
'0x' + pk.getPublic('hex').slice(-64),
];
// Pass the following data to the EllipticCurve.validateSignature smart contract function
return { messageHash, signature, publicKey };
};
Você pode obter o credentialPublicKey a partir da função navigator.credentials.create (lembre-se de especificar o ID do algoritmo como -7) e você pode obter o authResponse a partir da função navigator.credentials.get.
Aqui está uma captura de tela de mim usando a função validateSignature no etherscan
Os argumentos de entrada de exemplo são
{
# SHA-256 hased message
message: 0x1509164044f76927cd7c7ab7dee6b5bbb2db8dba129ddcc7ffdb823fe62dc013,
# r, s
rs: [0x3fb52e444cd21ba1594bef9a0aca4b526245785245110c2251fc65f8ba9e0642,0xedf3a7386c08a9eb33370312bb5e641ab2c91a501b98bb859531cc188234d83b],
# x co-ordinate of the public key, y co-ordinate of the public key
Q: [0x8e6da64b3fb510f9a1e17bc678e04c693a68f47d83992f8c0551efe0e0708e10,0x8a6a9dca9e40cc919eb075d92982dd2cdf3e3ad4c262207fab96525635ebe2b4]
}
