Using the SimpleWebAuthn TypeScript package, I have generated a ECDSA-SHA256 key pair and I used the key pair to sign a challenge. The SimpleWebAuthn package uses crypto.webcrypto.subtle
(see this line) to verify the signature. I want to verify the signature in ethers.js as a proof of concept so I can verify the WebAuthn signature in my Ethereum smart contract using ecrecover
. Below is a code snippet of what I am doing currently
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)
Sometimes the ethers.utils.recoverAddress
would run successfully but the public address it was able to recover was different every time. Sometimes the ethers.utils.recoverAddress
would throw this error
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 handles keys with secp256k curve but the WebAuthn keypair belongs to the P-256
curve, so this might be why it throws the invalid point error, but I don't understand why that happens only some of the times. I can't do much here because the only kinds of ECDSA keypair my device supports belongs to the P-256
curve
Another area of problem might be the message hash format, usually we keccak256 the message value before we sign the data using ethers.js
, but WebAuthn doesn't seem to do that so we are not able to recover the public address reliably.
Note: I am using an up-to-date version of ethers.js (v5.5.1)