Hvordan verifisere en ECDSA-signatur ved hjelp av Web Crypto

Av Mick Hansen den 23. februar 2024

2 min lesetid

WebAuthn: Verifisering av en ECDSA-signatur med Web Crypto

Hvis du har prøvd å implementere WebAuthn via API-et for autentisering som leveres av nettleseren, har du kanskje hatt problemer med å verifisere en ES256-signatur ved hjelp av Web Crypto eller node crypto.

ES256 er en av algoritmene som anbefales i spesifikasjonen for webautentisering. Hvis du følger den anbefalte rekkefølgen, må du verifisere en ECDSA-signatur (når enheter støtter det).

Kanskje du har jobbet med alle WebAuthn-egenskapene:

  1. konvertert den COSE-kodede offentlige nøkkelen til et format som støttes av Web Crypto (for eksempel JWK)
  2. Genererte de riktige inndataene ved å kombinere authenticatorData-bytes og hashen til clientDataJSON

... og signaturen kan fortsatt ikke verifiseres.

Hvorfor ikke?

Hovedårsaken er at Web Crypto forventer at ECDSA-signaturer skal leveres i r|s-format, mens WebAuthn, i henhold til spesifikasjonen, produserer en ASN.1 DER.

La oss gå gjennom de få trinnene som kreves for å konvertere og verifisere en ASN.1-formatert signatur.

Noen få verktøymetoder

Når vi arbeider med binære buffere, trenger vi en hjelpemetode for å slå sammen to buffere:

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

Vi trenger også en måte å dekode ASN.1 DER ECDSA-Sig-Value på, som ganske enkelt er en ASN.1-sekvens som inneholder to heltall:

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

Tar hensyn til binær formatering

ASN.1 DER og r|s bruker subtilt forskjellige polstringer og binære kodingsteknikker, så vi må utføre noen kontroller og modifikasjoner av byte-arrayene.

Følgende kode er tilpasset fra https://github.com/kjur/jsrsasign/blob/58bb24192f501927014b67911bbde8ef27532319/src/ecdsa-modified-1.0.js#L760 for å fungere med binære matriser i stedet for hex-strenger.

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

Sette det hele sammen

Nå har vi alle bitene som kreves for å konvertere og verifisere en WebAuthn ECDSA-signatur ved hjelp av Web Crypto (med unntak av konverteringen av en COSE-kodet offentlig nøkkel, men det kommer vi tilbake til i en senere artikkel).

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

Idura utvikler produkter for digital identitet og signaturer ved hjelp av WebAuthn, og vi ansetter!