How to Verify an ECDSA Signature Using Web Crypto

If you’ve tried implementing WebAuthn via the browser-provided Credential Management API, you may have faced issues verifying an ES256 signature using Web Crypto or node crypto.

ES256 is one of the algorithms recommended by the Web Authentication specification. If you follow the recommended order, you will need to verify an ECDSA signature (when devices support it).

Perhaps you’ve dealt with all the WebAuthn “quirks”:

  1. Converted the COSE encoded public key to a format supported by Web Crypto (such as JWK)
  2. Generated the correct input data by combining the authenticatorData bytes and the hash of clientDataJSON

…and still the signature fails to verify.

Why?

The core reason is that Web Crypto expects ECDSA signatures to be provided in the r|s format, while WebAuthn, per the specification, produces an ASN.1 DER.

Let's go through the few steps required to convert and verify an ASN.1 formatted signature.

A few utility methods

When working with binary buffers, we’ll need a helper method to merge two buffers:

function mergeBuffer(buffer1: ArrayBuffer, buffer2: ArrayBuffer) {
  const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
  tmp.set(new Uint8Array(buffer1), 0);
  tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
  return tmp.buffer;
}

We’ll also need a way to decode the ASN.1 DER ECDSA-Sig-Value, which is simply an ASN.1 sequence containing two integers:

function readAsn1IntegerSequence(input: Uint8Array) {
  if (input[0] !== 0x30) throw new Error('Input is not an ASN.1 sequence');
  const seqLength = input[1];
  const elements : Uint8Array[] = [];


  let current = input.slice(2, 2 + seqLength);
  while (current.length > 0) {
    const tag = current[0];
    if (tag !== 0x02) throw new Error('Expected ASN.1 sequence element to be an INTEGER');


    const elLength = current[1];
    elements.push(current.slice(2, 2 + elLength));


    current = current.slice(2 + elLength);
  }
  return elements;
}

Accounting for binary formatting

ASN.1 DER and r|s use subtly different paddings and binary encoding techniques, so we’ll need to perform some checks and modifications to the byte arrays.

The following code is adapted from https://github.com/kjur/jsrsasign/blob/58bb24192f501927014b67911bbde8ef27532319/src/ecdsa-modified-1.0.js#L760 to work with binary arrays instead of hex strings.

function convertEcdsaAsn1Signature(input : Uint8Array) {
  const elements = readAsn1IntegerSequence(input);
  if (elements.length !== 2) throw new Error('Expected 2 ASN.1 sequence elements');
  let [r, s] = elements;


  // R and S length is assumed multiple of 128bit.
  // If leading is 0 and modulo of length is 1 byte then
  // leading 0 is for two's complement and will be removed.
  if (r[0] === 0 && r.byteLength % 16 == 1) {
    r = r.slice(1);
  }
  if (s[0] === 0 && s.byteLength % 16 == 1) {
    s = s.slice(1);
  }


  // R and S length is assumed multiple of 128bit.
  // If missing a byte then it will be padded by 0.
  if ((r.byteLength % 16) == 15) {
    r = new Uint8Array(mergeBuffer(new Uint8Array([0]), r));
  }
  if ((s.byteLength % 16) == 15) {
    s = new Uint8Array(mergeBuffer(new Uint8Array([0]), s));
  }


  // If R and S length is not still multiple of 128bit,
  // then error
  if (r.byteLength % 16 != 0) throw Error("unknown ECDSA sig r length error");
  if (s.byteLength % 16 != 0) throw Error("unknown ECDSA sig s length error");


  return mergeBuffer(r, s);
}

Putting it all together

Now we have all the bits and pieces required to convert and verify a WebAuthn ECDSA signature using Web Crypto (except for converting a COSE encoded public key, but we’ll cover that in a future article).

const key : CryptoKey = ...;
const response : AuthenticatorAssertionResponse = ...;

const hashedClientDataJSON = await globalThis.crypto.subtle.digest('SHA-256', response.clientDataJSON);
const data = mergeBuffer(response.authenticatorData, hashedClientDataJSON);

const signature = convertEcdsaAsn1Signature(new Uint8Array(response.signature));

const verified = await globalThis.crypto.subtle.verify({
  name: 'ECDSA',
  hash: ES256
}, key, signature, data);
Criipto is developing products for digital identity and signatures using WebAuthn, and we’re hiring!
Author
Our blog

Latest blog posts

The latest industry news, interviews, technologies, and resources.

Criipto Connect 2024: Key Takeaways and Highlights
What are the next steps for digital wallets in Europe? On November 13th, 2024, we hosted our live conference, Criipto Connect, at Bremen Teater in...
BankID BankAxept Acquires Criipto

We’re pleased to announce BankID BankAxept as the new owner of Criipto!

The Nordic market leader from Norway is acquiring Criipto to create a...

Zero-Knowledge Proofs: A Beginner's Guide

Zero-Knowledge Proofs (ZKPs) are powerful cryptographic tools with a wide range of practical applications.

In this article, we’ll provide a simple...

View all posts

Sign up for our blog

Stay up to date on industry news and insights