typescript - Verifiera WebAuthn ECDSA-signatur i ethers.js


Med hjälp av SimpleWebAuthn TypeScript-paketet har jag genererat ett ECDSA-SHA256-nyckelpar och jag använde nyckelparet för att signera en utmaning. SimpleWebAuthn-paketet använder crypto.webcrypto.subtle (se denna rad) för att verifiera signaturen. Jag vill verifiera signaturen i ethers.js som ett proof of concept så att jag kan verifiera WebAuthn-signaturen i mitt Ethereum smarta kontrakt med ecrecover. Nedan är ett kodavsnitt av vad jag gör för närvarande

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)

Ibland skulle ethers.utils.recoverAddress köra framgångsrikt men den offentliga adress som den kunde återställa var olika varje gång. Ibland skulle ethers.utils.recoverAddress kasta detta fel

 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 hanterar nycklar med secp256k-kurvan men WebAuthn-nyckelparet tillhör P-256-kurvan, så det kan vara därför det ger felet ogiltig punkt, men jag förstår inte varför det bara händer ibland. Jag kan inte göra mycket här eftersom de enda typerna av ECDSA-nyckelpar som min enhet stöder tillhör P-256-kurvan

Ett annat problemområde kan vara meddelandehashformatet, vanligtvis kör vi keccak256 på meddelandevärdet innan vi signerar datan med ethers.js, men WebAuthn verkar inte göra det så vi kan inte återställa den publika adressen på ett pålitligt sätt.

Obs: Jag använder en uppdaterad version av ethers.js (v5.5.1)

Svar #1

Ja, både kurvan och hashvärdet är fel.

Först, namnet på kurvan som används av Ethereum och därmed av ethers.js (och även Bitcoin) är secp256k1 -- även om detta tekniskt sett är redundant, eftersom det inte finns några andra SECG prime Koblitz-kurvor av storlek 256. WebAuthn, liksom många andra saker, använder (de flesta av) de kurvor som antagits av NIST i FIPS186-2 och uppåt (men som nu föreslås flyttas till SP800-186), inklusive P-256 som också kallas av SECG secp256r1 (notera r) och av X9 prime256v1.

ECDSA-verifiering-med-återställningsalgoritmen genererar effektivt de möjliga värdena av kG vid signaturgenerering och hittar den -- exakt en för en giltig signatur -- som motsvarar den korrekta offentliga nyckeln (dG) (givet rätt meddelanderepresentant z, se nedan). Eftersom alla X9/SECG primtalskurvor inklusive secp256k1 valdes för att ha kofaktor 1 och därmed n inom Hasse-gränsen för p, har detta nästan alltid x-koordinaten r+0n, mycket sällan r+1n, och aldrig mer. Men om du ger den en ogiltig signatur -- och en signatur beräknad för P-256 är ogiltig för secp256k1 -- genererar den effektivt med 50% sannolikhet en x-koordinat som inte motsvarar några punkter (och får undantaget) och med 50% sannolikhet en x-koordinat som motsvarar en punkt som uppfyller verifieringsekvationen men som inte har någon som helst relation till den 'korrekta' offentliga nyckel som användes vid signaturgenereringen för P-256.

Dessutom, även om ECDSA-primitiven var korrekt (d.v.s. använde samma kurva), använder WebAuthn, igen som de flesta saker, FIPS180-hashar som rekommenderas av FIPS186 - i detta fall SHA-256 för P-256, se raderna 79-88 på sidan du länkar - inte den Keccak-variant som används av Ethereum, och skulle därför fortfarande 'återställa' en helt felaktig och i praktiken slumpmässig publik nyckel (och adressen, d.v.s. x-koordinaten för den nyckeln).

ethers.js kan endast verifiera och återställa en Ethereum-signatur på data, och detta är inte en Ethereum-signatur. Ditt koncept måste ändras.

Svar #2

Som @dave_thompson_085 har nämnt, fungerar inte exemplet i min fråga eftersom WebAuthn-nyckelparet (COSE-algoritm-ID är -7) är på secp256r1/p256-kurvan och signaturen använder SHA-256-algoritmen, men ethers.js fungerar endast för nycklar på secp256k1-kurvan och keccak256-hashvarianten.

Därför behövde jag en signaturverifieringsfunktion som fungerar för p256-kurvan. Jag hittade ett Solidity smartkontrakt som gör precis det. Dock underhålls detta arkiv inte aktivt och kontraktet använder Solidity v0.5.0 och det kraschar om vi uppdaterar det till den senaste Solidity-kompilatorversionen (v0.8.x) på grund av aritmetiska overflow/underflow-fel. Så för att använda detta EllipticCurve.sol-kontrakt behöver vi först distribuera kontraktet och använda det som ett externt kontrakt.

Vi kan testa kontraktet som författaren distribuerade på mainnet. Här är ett exempel på kodsnutt för att tolka WebAuthn-authenticatorns svar och dess offentliga nyckel så att vi kan skicka dessa värden som indata till validateSignature-funktionen i EllipticCurve-kontraktet.

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

Du kan få credentialPublicKey från navigator.credentials.create-funktionen (kom ihåg att specificera algoritm-ID till -7) och du kan få authResponse från navigator.credentials.get-funktionen.

Här är en skärmdump på mig som använder validateSignature-funktionen på Etherscan

Exempel på inmatningsargumenten är

{
  # 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]
}

Relaterade artiklar