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”:
- Converted the COSE encoded public key to a format supported by Web Crypto (such as JWK)
- 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);