3

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)

Yao
  • 359
  • 4
  • 10

2 Answers2

2

Yes both the curve and hash are wrong.

First, the name of the curve used by Ethereum and thus by ethers.js (and also Bitcoin) is secp256k1 -- although technically this is redundant, because there are no other SECG prime Koblitz curves of size 256. WebAuthn, like many other things, uses (most of) the curves adopted by NIST in FIPS186-2 up (but now proposed to be moved to SP800-186), including P-256 which is also named by SECG secp256r1 (note r) and by X9 prime256v1.

The ECDSA verification-with-recovery algorithm effectively generates the possible values of kG in the signature generation and finds the one -- exactly one for a valid signature -- that corresponds to the correct public key (dG) (given the correct message representative z, see below). Because all X9/SECG prime curves including secp256k1 were chosen to have cofactor 1 and thus n within the Hasse bound of p, this almost always has x-coordinate r+0n, very rarely r+1n, and never more. But if you give it an invalid signature -- and a signature computed for P-256 is invalid for secp256k1 -- it effectively generates with 50% probability an x-coordinate that does not correspond to any points (and gets the exception) and with 50% probability an x-coordinate that corresponds to a point that satisfies the verification equation but has no relationship at all to the 'correct' public key used in the P-256 signature generation.

In addition even if the ECDSA primitive was correct (i.e. used the same curve), WebAuthn, again like most things, uses FIPS180 hashes as endorsed by FIPS186 -- in this case SHA-256 for P-256, see lines 79-88 on the page you link -- not the Keccak variant used by Ethereum, and thus would still 'recover' an entirely bogus and effectively random public key (and the address i.e. x-coordinate of that key).

ethers.js can only verify-and-recover an Ethereum signature on data, and this is not an Ethereum signature. Your concept must be changed.

dave_thompson_085
  • 34,712
  • 6
  • 50
  • 70
  • Thanks, I was under the impression that SHA256 and Keccak are the same. I think my current plan would be to write a custom function in Solidity to verify the signature generated by the WebAuthn's ECDSA P-256 key. – Yao Dec 16 '22 at 21:10
0

As @dave_thompson_085 has mentioned, the example in my question doesn't work because the WebAuthn keypair (the COSE algorithm ID is -7) is on the secp256r1 / p256 curve and the signature uses the SHA-256 algorithm, but ethers.js only works for keys on the secp256k1 curve and the keccak256 hash variant.

Therefore, I needed a signature verification function that works for the p256 curve. I found a Solidity smart contract that does exactly that. However, this repository is not actively maintained and the contract uses Solidity v0.5.0 and it breaks if we update it to the latest Solidity compiler version (v0.8.x) because of arithmetic overflow/underflow errors. So to use this EllipticCurve.sol contract, we need to deploy the contract first and use it as an external contract.

We can test out the contract that the author deployed on mainnet. Here is the example code snippet for parsing the WebAuthn authenticator response and its public key so we can pass these values as inputs to the validateSignature function of the EllipticCurve contract.

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 };
};

You can get the credentialPublicKey from the navigator.credentials.create function (remember to specify the algorithm ID to be -7) and you can get the authResponse from the navigator.credentials.get function.

Here is a screenshot of me using the validateSignature function on etherscan

etherscan EllipticCurve validateSignature

The example input arguments are

{
  # 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]
}
Yao
  • 359
  • 4
  • 10