◐ Off-By-One · answer catalog

js-webauthn-cose-es256-assertion-verifier

2 answer(s)jsnode20jsnode20

Problem class: js-webauthn-cose-es256-assertion-verifier

📦 Source in repository (JSON)

Answer 1

I built and verified the solution. The complete self-contained document is at ~/webauthn-verifier/SOLUTION.md, with the implementation in webauthn.js and a 41-case suite in test.js. Here is the solution.


WebAuthn Assertion Verifier — ES256 / COSE with node:crypto only

Problem class: js-webauthn-cose-es256-assertion-verifier Runtime: Node.js (verified on v22.23.2; APIs used exist since Node 15) Dependencies: none beyond node:crypto (no cbor, cose, or jose)


1. Root-cause analysis

The failure class is a set of trust-boundary mistakes that a hand-rolled verifier makes. They fall into three groups.

A. Cryptographic / encoding mistakes

  1. Wrong signed message. WebAuthn signs authenticatorData || SHA-256(clientDataJSON). Typical bugs: hashing the concatenation again, reversing the order (hash || authData — the mandated "wrong concatenation order" case), or signing the JSON/base64 text instead of its digest.
  2. Base64url confusion. clientDataJSON arrives base64url-encoded. Decode it first, then clientDataHash = SHA-256(decoded bytes). Hashing the base64url text is wrong.
  3. Malleable signatures. OpenSSL/Node accepts high-S ECDSA signatures (s > n/2). For P-256 (r,s) and (r, n−s) both verify, so a correct verifier must reject s > n/2.
  4. Encoding coverage. Authenticators may emit DER (SEQUENCE { INTEGER r, INTEGER s }) or raw IEEE P1363 (64 bytes). Accept both, with strict DER parsing (no trailing bytes, no non-minimal/negative integers).

B. Parser mistakes (canonical CBOR)

COSE keys and extensions are CBOR and must be canonical (RFC 8949 deterministic). A permissive parser enables parser-differential attacks. Must reject: indefinite-length items (ai == 31); non-minimal integers/lengths (e.g. 0x18 0x01 for 1); duplicate map keys; unsorted map keys; trailing bytes. RFC 8949 orders keys by byte-wise lexicographic encoded bytes; the older RFC 7049/CTAP2 form orders by length first.

C. Validation omissions

rpIdHash not checked; origin/challenge/type not checked; UP/UV not enforced; reserved flag bits ignored; signature counter not compared; and truncated authenticator data read at fixed offsets without bounds checks (yields RangeError instead of clean failure).


2. Exact fix

2.1 API

const { verifyAssertion, verifyAssertionSafe } = require('./webauthn');

const result = verifyAssertion({
  authenticatorData,                 // Buffer
  clientDataJSON,                    // Buffer (raw) or base64url string
  signature,                         // Buffer: DER or raw 64-byte P1363
  publicKey,                         // KeyObject | JWK | COSE_Key Map | SPKI/PEM Buffer
  expectedChallenge,                 // Buffer or base64url string
  expectedOrigin,                    // string or string[]
  expectedRpId,                      // string (hashed + compared) or expectedRpIdHash: Buffer
  previousSignCount: 0,
  requireUserPresence: true,         // default
  requireUserVerification: false,    // set true for UV policy
});
// throws WebAuthnError on failure; verifyAssertionSafe() -> {ok, result|error}

2.2 Critical defensive paths

Canonical CBOR header (indefinite + non-minimal rejection):

} else if (ai === 27) {
  value = readUint(8);
  if (canonical && major !== 7 && value <= 0xffffffffn) {
    throw new CBORError('non-minimal integer/length (8-byte form)');
  }
} else if (ai === 31) {
  throw new CBORError('indefinite-length items are not allowed');
}

Map ordering / duplicates compared on the encoded key bytes:

const cmp = compareKeys(previousKeyBytes, keyBytes);
if (cmp === 0) throw new CBORError('duplicate map key');
if (cmp > 0) throw new CBORError('map keys are not in canonical order');

Low-S + range enforcement and raw/DER normalisation:

if (r <= 0n || r >= N) throw new WebAuthnError('signature r is out of range');
if (s <= 0n || s >= N) throw new WebAuthnError('signature s is out of range');
if (s > HALF_N) throw new WebAuthnError('high-S signature rejected (ECDSA malleability)');

The signed byte string (order matters):

// The signed message is exactly authData || clientDataHash.
const signedData = Buffer.concat([authDataBytes, client.hash]);
if (!verifyES256(key, signedData, signature)) {
  throw new WebAuthnError('assertion signature verification failed');
}

2.3 webauthn.js (complete)

'use strict';

const crypto = require('node:crypto');

class WebAuthnError extends Error {
  constructor(message) { super(message); this.name = 'WebAuthnError'; }
}
class CBORError extends WebAuthnError {
  constructor(message) { super(message); this.name = 'CBORError'; }
}

// P-256 (SEC 2 / FIPS 186-4)
const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
const A = P - 3n;
const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
const N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
const HALF_N = N >> 1n;
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);

function toBuffer(value, encoding) {
  if (Buffer.isBuffer(value)) return value;
  if (value instanceof Uint8Array) return Buffer.from(value);
  if (typeof value === 'string') return Buffer.from(value, encoding || 'utf8');
  throw new WebAuthnError('expected Buffer/Uint8Array/string');
}
function toBase64url(buf) {
  return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64url(str) {
  if (typeof str !== 'string') throw new WebAuthnError('base64url value must be a string');
  if (!/^[A-Za-z0-9_-]*={0,2}$/.test(str)) throw new WebAuthnError('invalid base64url characters');
  return Buffer.from(str.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function timingSafeEqual(a, b) {
  if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) return false;
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
function bufToBigInt(buf) { let v = 0n; for (const b of buf) v = (v << 8n) | BigInt(b); return v; }
function bigIntToBuf(value, length) {
  let hex = value.toString(16);
  if (hex.length % 2) hex = '0' + hex;
  let buf = Buffer.from(hex, 'hex');
  if (length !== undefined) {
    if (buf.length > length) throw new WebAuthnError('integer does not fit');
    if (buf.length < length) buf = Buffer.concat([Buffer.alloc(length - buf.length), buf]);
  }
  return buf;
}
function mod(value, m) { const r = value % m; return r < 0n ? r + m : r; }

// ---------------------------------------------------------------------------
// Canonical CBOR decoder (RFC 8949 deterministic encoding)
// ---------------------------------------------------------------------------
class CborTag { constructor(tag, value) { this.tag = tag; this.value = value; } }
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });

function decodeCBOR(buf, offset = 0, options = {}) {
  if (!Buffer.isBuffer(buf)) throw new CBORError('CBOR input must be a Buffer');
  const canonical = options.canonical !== false;
  const keyOrder = options.keyOrder || 'bytewise'; // 'bytewise' | 'length-first'
  const start = offset;
  let pos = offset;

  function compareKeys(a, b) {
    if (keyOrder === 'length-first') {
      if (a.length !== b.length) return a.length - b.length;
      return Buffer.compare(a, b);
    }
    return Buffer.compare(a, b);
  }
  function readBytes(n) {
    if (!Number.isInteger(n) || n < 0) throw new CBORError('invalid length');
    if (pos + n > buf.length) throw new CBORError('truncated CBOR');
    const out = buf.subarray(pos, pos + n); pos += n; return out;
  }
  function readUint(n) { let v = 0n; for (const b of readBytes(n)) v = (v << 8n) | BigInt(b); return v; }
  function toLength(value) {
    if (value > BigInt(buf.length)) throw new CBORError('CBOR length exceeds input size');
    return Number(value);
  }
  function readHeader() {
    if (pos >= buf.length) throw new CBORError('truncated CBOR header');
    const ib = buf[pos++]; const major = ib >> 5; const ai = ib & 0x1f;
    let value;
    if (ai < 24) value = BigInt(ai);
    else if (ai === 24) {
      value = readUint(1);
      if (canonical && major !== 7 && value < 24n) throw new CBORError('non-minimal integer/length (1-byte form for value < 24)');
    } else if (ai === 25) {
      value = readUint(2);
      if (canonical && major !== 7 && value <= 0xffn) throw new CBORError('non-minimal integer/length (2-byte form)');
    } else if (ai === 26) {
      value = readUint(4);
      if (canonical && major !== 7 && value <= 0xffffn) throw new CBORError('non-minimal integer/length (4-byte form)');
    } else if (ai === 27) {
      value = readUint(8);
      if (canonical && major !== 7 && value <= 0xffffffffn) throw new CBORError('non-minimal integer/length (8-byte form)');
    } else if (ai === 31) {
      throw new CBORError('indefinite-length items are not allowed');
    } else {
      throw new CBORError('reserved additional information value');
    }
    return { major, ai, value };
  }
  function decodeItem() {
    const { major, ai, value } = readHeader();
    switch (major) {
      case 0: return value <= MAX_SAFE ? Number(value) : value;
      case 1: return value <= MAX_SAFE ? -1 - Number(value) : -1n - value;
      case 2: return Buffer.from(readBytes(toLength(value)));
      case 3: {
        const bytes = readBytes(toLength(value));
        try { return utf8Decoder.decode(bytes); }
        catch { throw new CBORError('invalid UTF-8 in CBOR text string'); }
      }
      case 4: {
        const len = toLength(value); const arr = new Array(len);
        for (let i = 0; i < len; i++) arr[i] = decodeItem();
        return arr;
      }
      case 5: {
        const len = toLength(value); const map = new Map(); let previousKeyBytes = null;
        for (let i = 0; i < len; i++) {
          const keyStart = pos; const key = decodeItem();
          const keyBytes = buf.subarray(keyStart, pos);
          if (canonical && previousKeyBytes !== null) {
            const cmp = compareKeys(previousKeyBytes, keyBytes);
            if (cmp === 0) throw new CBORError('duplicate map key');
            if (cmp > 0) throw new CBORError('map keys are not in canonical order');
          }
          if (map.has(key)) throw new CBORError('duplicate map key');
          map.set(key, decodeItem());
          previousKeyBytes = keyBytes;
        }
        return map;
      }
      case 6: return new CborTag(value, decodeItem());
      case 7: {
        if (ai === 20) return false;
        if (ai === 21) return true;
        if (ai === 22) return null;
        if (ai === 23) return undefined;
        if (ai === 24) {
          const simple = readUint(1);
          if (canonical && simple < 32n) throw new CBORError('non-minimal simple value');
          return { simple: Number(simple) };
        }
        if (ai === 25) return readFloat16(readBytes(2));
        if (ai === 26) return readBytes(4).readFloatBE(0);
        if (ai === 27) return readBytes(8).readDoubleBE(0);
        throw new CBORError('reserved simple/float additional information');
      }
      default: throw new CBORError('unknown CBOR major type');
    }
  }
  const value = decodeItem();
  return { value, offset: pos, start };
}
function decodeCBORExact(buf, options) {
  const { value, offset } = decodeCBOR(buf, 0, options);
  if (offset !== buf.length) throw new CBORError('trailing bytes after CBOR item');
  return value;
}
function readFloat16(buf) {
  const half = buf.readUInt16BE(0);
  const sign = half & 0x8000 ? -1 : 1;
  const exponent = (half >> 10) & 0x1f;
  const fraction = half & 0x3ff;
  if (exponent === 0) return sign * Math.pow(2, -14) * (fraction / 1024);
  if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : NaN;
  return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
}

// ---------------------------------------------------------------------------
// COSE_Key (EC2 / ES256)
// ---------------------------------------------------------------------------
function mapGetInt(map, label) {
  const wanted = BigInt(label);
  for (const [key, value] of map) {
    if (typeof key === 'number' && Number.isInteger(key) && BigInt(key) === wanted) return value;
    if (typeof key === 'bigint' && key === wanted) return value;
  }
  return undefined;
}
function isOnP256(x, y) {
  if (x < 0n || y < 0n || x >= P || y >= P) return false;
  return mod(y * y, P) === mod(x * x * x + A * x + B, P);
}
function coseKeyToPublicKey(coseKey) {
  if (!(coseKey instanceof Map)) throw new WebAuthnError('COSE key must be a CBOR map');
  const kty = mapGetInt(coseKey, 1);
  const alg = mapGetInt(coseKey, 3);
  const crv = mapGetInt(coseKey, -1);
  const x = mapGetInt(coseKey, -2);
  const y = mapGetInt(coseKey, -3);
  if (kty !== 2) throw new WebAuthnError('COSE key is not EC2 (kty=2)');
  if (alg !== undefined && alg !== -7) throw new WebAuthnError('COSE key alg is not ES256 (-7)');
  if (crv !== 1) throw new WebAuthnError('COSE key curve is not P-256 (crv=1)');
  if (!Buffer.isBuffer(x) || x.length !== 32) throw new WebAuthnError('COSE key x must be a 32-byte byte string');
  if (!Buffer.isBuffer(y) || y.length !== 32) throw new WebAuthnError('COSE key y must be a 32-byte byte string');
  if (!isOnP256(bufToBigInt(x), bufToBigInt(y))) throw new WebAuthnError('COSE key point is not on the P-256 curve');
  return crypto.createPublicKey({
    key: { kty: 'EC', crv: 'P-256', x: toBase64url(x), y: toBase64url(y) }, format: 'jwk',
  });
}
function importPublicKey(input) {
  if (input === undefined || input === null) throw new WebAuthnError('public key is required');
  if (input instanceof crypto.KeyObject) return input;
  if (input instanceof Map) return coseKeyToPublicKey(input);
  if (typeof input === 'string') return crypto.createPublicKey(input);
  if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
    const buf = toBuffer(input);
    if (buf.length > 0 && buf[0] === 0x30) return crypto.createPublicKey({ key: buf, format: 'der', type: 'spki' });
    return crypto.createPublicKey(buf);
  }
  if (typeof input === 'object') {
    if (input.kty === 'EC') {
      if (input.crv !== undefined && input.crv !== 'P-256') throw new WebAuthnError('EC public key is not P-256');
      return crypto.createPublicKey({ key: input, format: 'jwk' });
    }
    if (input.key) return crypto.createPublicKey(input);
  }
  throw new WebAuthnError('unsupported public key format');
}

// ---------------------------------------------------------------------------
// ECDSA signature normalisation (DER <-> IEEE P1363) + low-S
// ---------------------------------------------------------------------------
function readDERLength(buf, state) {
  if (state.pos >= buf.length) throw new WebAuthnError('truncated DER length');
  const first = buf[state.pos++];
  if (first < 0x80) return first;
  const count = first & 0x7f;
  if (count === 0) throw new WebAuthnError('indefinite DER length');
  if (count > 4) throw new WebAuthnError('DER length too large');
  let length = 0;
  for (let i = 0; i < count; i++) {
    if (state.pos >= buf.length) throw new WebAuthnError('truncated DER length');
    const byte = buf[state.pos++];
    if (i === 0 && byte === 0) throw new WebAuthnError('non-minimal DER length');
    length = (length << 8) | byte;
  }
  if (length < 0x80) throw new WebAuthnError('non-minimal DER length');
  return length;
}
function readDERInteger(buf, state) {
  if (state.pos >= buf.length || buf[state.pos++] !== 0x02) throw new WebAuthnError('DER integer tag missing');
  const length = readDERLength(buf, state);
  if (length === 0) throw new WebAuthnError('empty DER integer');
  if (state.pos + length > buf.length) throw new WebAuthnError('truncated DER integer');
  const bytes = buf.subarray(state.pos, state.pos + length);
  state.pos += length;
  if (bytes[0] & 0x80) throw new WebAuthnError('negative DER integer');
  if (bytes.length > 1 && bytes[0] === 0x00 && (bytes[1] & 0x80) === 0) throw new WebAuthnError('non-minimal DER integer');
  return bufToBigInt(bytes);
}
function parseDERSignature(sig) {
  const state = { pos: 0 };
  if (sig.length < 2 || sig[0] !== 0x30) throw new WebAuthnError('not a DER SEQUENCE');
  state.pos = 1;
  const length = readDERLength(sig, state);
  if (state.pos + length !== sig.length) throw new WebAuthnError('DER length mismatch');
  const r = readDERInteger(sig, state);
  const s = readDERInteger(sig, state);
  if (state.pos !== sig.length) throw new WebAuthnError('trailing bytes in DER signature');
  return { r, s };
}
function encodeDERInteger(value) {
  let bytes = bigIntToBuf(value);
  if (bytes.length === 0) bytes = Buffer.from([0]);
  if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0x00]), bytes]);
  return Buffer.concat([Buffer.from([0x02, bytes.length]), bytes]);
}
function encodeDERSignature(r, s) {
  const body = Buffer.concat([encodeDERInteger(r), encodeDERInteger(s)]);
  if (body.length > 0x7f) throw new WebAuthnError('DER signature too long');
  return Buffer.concat([Buffer.from([0x30, body.length]), body]);
}
function parseSignature(signature) {
  const sig = toBuffer(signature);
  let r; let s; let encoding = null;
  if (sig.length > 0 && sig[0] === 0x30) {
    try { const p = parseDERSignature(sig); r = p.r; s = p.s; encoding = 'der'; }
    catch { /* fall through to raw */ }
  }
  if (encoding === null && sig.length === 64) {
    r = bufToBigInt(sig.subarray(0, 32)); s = bufToBigInt(sig.subarray(32, 64)); encoding = 'p1363';
  }
  if (encoding === null) throw new WebAuthnError('unrecognised signature encoding');
  if (r <= 0n || r >= N) throw new WebAuthnError('signature r is out of range');
  if (s <= 0n || s >= N) throw new WebAuthnError('signature s is out of range');
  if (s > HALF_N) throw new WebAuthnError('high-S signature rejected (ECDSA malleability)');
  return { r, s, encoding };
}
function verifyES256(publicKey, signedData, signature) {
  const { r, s } = parseSignature(signature);
  const der = encodeDERSignature(r, s);
  const verifier = crypto.createVerify('SHA256');
  verifier.update(signedData);
  return verifier.verify(publicKey, der);
}

// ---------------------------------------------------------------------------
// Authenticator data
// ---------------------------------------------------------------------------
const FLAG_UP = 0x01, FLAG_RFU1 = 0x02, FLAG_UV = 0x04, FLAG_BE = 0x08,
      FLAG_BS = 0x10, FLAG_RFU2 = 0x20, FLAG_AT = 0x40, FLAG_ED = 0x80;

function parseAuthenticatorData(authData) {
  const data = toBuffer(authData);
  if (data.length < 37) throw new WebAuthnError('authenticator data is truncated (< 37 bytes)');
  const rpIdHash = Buffer.from(data.subarray(0, 32));
  const flags = data[32];
  const signCount = data.readUInt32BE(33);
  let offset = 37;
  const result = {
    rpIdHash, flags, signCount,
    userPresent: (flags & FLAG_UP) !== 0,
    userVerified: (flags & FLAG_UV) !== 0,
    backupEligible: (flags & FLAG_BE) !== 0,
    backupState: (flags & FLAG_BS) !== 0,
    attestedCredentialDataIncluded: (flags & FLAG_AT) !== 0,
    extensionDataIncluded: (flags & FLAG_ED) !== 0,
    aaguid: null, credentialId: null, credentialPublicKey: null, extensions: null,
  };
  if (flags & (FLAG_RFU1 | FLAG_RFU2)) throw new WebAuthnError('authenticator data has reserved flag bits set');
  if (result.attestedCredentialDataIncluded) {
    if (offset + 18 > data.length) throw new WebAuthnError('attested credential data is truncated (AAGUID/length)');
    result.aaguid = Buffer.from(data.subarray(offset, offset + 16)); offset += 16;
    const credentialIdLength = data.readUInt16BE(offset); offset += 2;
    if (offset + credentialIdLength > data.length) throw new WebAuthnError('credential id is truncated');
    result.credentialId = Buffer.from(data.subarray(offset, offset + credentialIdLength)); offset += credentialIdLength;
    const { value, offset: next } = decodeCBOR(data, offset, { canonical: true });
    result.credentialPublicKey = value; offset = next;
  }
  if (result.extensionDataIncluded) {
    const { value, offset: next } = decodeCBOR(data, offset, { canonical: true });
    result.extensions = value; offset = next;
  }
  if (offset !== data.length) throw new WebAuthnError('authenticator data has trailing bytes');
  return result;
}

// ---------------------------------------------------------------------------
// Client data
// ---------------------------------------------------------------------------
function sha256(data) { return crypto.createHash('sha256').update(data).digest(); }
function resolveClientDataHash(clientDataJSON, isRaw) {
  if (Buffer.isBuffer(clientDataJSON) || clientDataJSON instanceof Uint8Array) {
    const bytes = toBuffer(clientDataJSON);
    return { bytes, hash: sha256(bytes) };
  }
  if (typeof clientDataJSON === 'string') {
    const bytes = isRaw ? Buffer.from(clientDataJSON, 'utf8') : fromBase64url(clientDataJSON);
    return { bytes, hash: sha256(bytes) };
  }
  throw new WebAuthnError('clientDataJSON must be a Buffer or base64url string');
}
function normalizeOrigin(origin) { try { return new URL(origin).origin; } catch { return origin; } }
function checkClientData(clientDataBytes, expected) {
  let parsed;
  try { parsed = JSON.parse(clientDataBytes.toString('utf8')); }
  catch { throw new WebAuthnError('clientDataJSON is not valid JSON'); }
  if (parsed === null || typeof parsed !== 'object') throw new WebAuthnError('clientDataJSON is not an object');
  if (parsed.type !== 'webauthn.get') throw new WebAuthnError('clientData type is not webauthn.get');
  if (expected.challenge !== undefined && expected.challenge !== null) {
    const expectedBytes = Buffer.isBuffer(expected.challenge) ? expected.challenge : fromBase64url(String(expected.challenge));
    let actualBytes;
    try { actualBytes = fromBase64url(String(parsed.challenge || '')); }
    catch { throw new WebAuthnError('clientData challenge is not valid base64url'); }
    if (!timingSafeEqual(actualBytes, expectedBytes)) throw new WebAuthnError('clientData challenge mismatch');
  }
  if (expected.origin !== undefined && expected.origin !== null) {
    const allowed = Array.isArray(expected.origin) ? expected.origin : [expected.origin];
    const actual = normalizeOrigin(String(parsed.origin || ''));
    if (!allowed.some((c) => normalizeOrigin(String(c)) === actual)) throw new WebAuthnError('clientData origin mismatch');
  }
  return parsed;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function verifyAssertion(options) {
  if (!options || typeof options !== 'object') throw new WebAuthnError('options object is required');
  const {
    authenticatorData, clientDataJSON, signature, publicKey,
    expectedChallenge, expectedOrigin, expectedRpId, expectedRpIdHash,
    previousSignCount = 0, requireUserPresence = true, requireUserVerification = false,
    clientDataJSONIsRaw = false,
  } = options;
  if (authenticatorData === undefined) throw new WebAuthnError('authenticatorData is required');
  if (signature === undefined) throw new WebAuthnError('signature is required');
  if (clientDataJSON === undefined) throw new WebAuthnError('clientDataJSON is required');

  const authDataBytes = toBuffer(authenticatorData);
  const auth = parseAuthenticatorData(authDataBytes);

  if (requireUserPresence && !auth.userPresent) throw new WebAuthnError('user presence (UP) flag is not set');
  if (requireUserVerification && !auth.userVerified) throw new WebAuthnError('user verification (UV) flag is not set but is required');

  let expectedHash = null;
  if (expectedRpIdHash !== undefined && expectedRpIdHash !== null) expectedHash = toBuffer(expectedRpIdHash);
  else if (expectedRpId !== undefined && expectedRpId !== null) expectedHash = sha256(Buffer.from(String(expectedRpId), 'utf8'));
  if (expectedHash !== null && !timingSafeEqual(auth.rpIdHash, expectedHash)) throw new WebAuthnError('rpIdHash mismatch (relying party id)');

  const client = resolveClientDataHash(clientDataJSON, clientDataJSONIsRaw);
  checkClientData(client.bytes, { challenge: expectedChallenge, origin: expectedOrigin });

  if (previousSignCount > 0 && auth.signCount <= previousSignCount) {
    throw new WebAuthnError(`signature counter did not increase (stored=${previousSignCount}, received=${auth.signCount})`);
  }
  if (previousSignCount > 0 && auth.signCount === 0) throw new WebAuthnError('signature counter reset to zero (possible cloned authenticator)');

  let key = publicKey;
  if (key === undefined || key === null) {
    if (!auth.credentialPublicKey) throw new WebAuthnError('no public key supplied and authenticator data has no AT credential');
    key = coseKeyToPublicKey(auth.credentialPublicKey);
  } else key = importPublicKey(key);

  // The signed message is exactly authData || clientDataHash.
  const signedData = Buffer.concat([authDataBytes, client.hash]);
  if (!verifyES256(key, signedData, signature)) throw new WebAuthnError('assertion signature verification failed');

  return {
    verified: true, userPresent: auth.userPresent, userVerified: auth.userVerified,
    backupEligible: auth.backupEligible, backupState: auth.backupState,
    signCount: auth.signCount, rpIdHash: auth.rpIdHash, credentialId: auth.credentialId, clientData: client.bytes,
  };
}
function verifyAssertionSafe(options) {
  try { return { ok: true, result: verifyAssertion(options) }; }
  catch (error) { return { ok: false, error }; }
}

module.exports = {
  WebAuthnError, CBORError, CborTag, decodeCBOR, decodeCBORExact,
  coseKeyToPublicKey, importPublicKey, parseAuthenticatorData,
  parseSignature, parseDERSignature, encodeDERSignature, verifyES256,
  verifyAssertion, verifyAssertionSafe, sha256, toBase64url, fromBase64url,
  N, HALF_N, P, A, B, FLAG_UP, FLAG_UV, FLAG_AT, FLAG_ED,
};

3. Verification

3.1 Run

cd ~/webauthn-verifier
node --check webauthn.js
node test.js

The full 41-assertion suite (test.js) ends with:

RESULT: 41 passed, 0 failed, 41 total

3.2 Requirements → tests

Requirement Test(s)
Parse auth data (rpIdHash, flags, counter, AT, ED) [6], [7], [4]
Parse COSE_Key / ES256 accepts a COSE_Key (EC2/ES256) as the public key
UP / UV / AT / ED flags [4] + [7]
Signature counter rejects a non-increasing signature counter, accepts an increased signature counter
Verify SHA-256(authData \|\| clientDataHash) all positive tests
clientDataHash = SHA-256 of base64url-decoded clientDataJSON positives + rejects a substituted challenge
Reject indefinite-length CBOR rejects indefinite-length items, rejects indefinite-length map keys
Reject unsorted / duplicate map keys rejects unsorted map keys, rejects duplicate map keys, RFC-8949 order test
Reject non-minimal integers/lengths rejects non-minimal integer encodings, rejects non-minimal length encodings
Mismatched rpIdHash / origin / challenge rejects rpIdHash mismatch, rejects origin mismatch, rejects a substituted challenge
Accept DER and raw P1363 accepts a low-S raw IEEE P1363 signature, accepts a low-S DER signature
Reject high-S malleable rejects a high-S raw signature, rejects a high-S DER signature
Truncated auth data rejects truncated authenticator data
Wrong concatenation order rejects a signature computed over clientDataHash \|\| authenticatorData (wrong order)

3.3 Malleability proof

Node/OpenSSL accepts the high-S variant of a valid signature; the verifier rejects it, proving the low-S rule is a deliberate cryptographic policy rather than a DER-parsing artifact:

parsed r,s from Node DER signature
s is high-S: true
Node/OpenSSL crypto.verify accepts high-S DER: true
our verifyES256 rejects high-S: high-S signature rejected (ECDSA malleability)

3.4 Integrator checklist


4. Files produced

All 41 tests pass, including the three mandated negative cases: truncated authenticator data, substituted challenge, and a signature over the wrong concatenation order.

Evidence & signatures

# Evidence
- Problem class: js-webauthn-cose-es256-assertion-verifier
- Model: deepseek/deepseek-flash
- Solved: 2026-09-10T22:18:21.645Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement a WebAuthn assertion verifier in Node with no crypto libraries beyond node:crypto: parse authenticator data and CBOR/COSE (COSE_Key plus ES256) structures, validate the UP/UV/AT/ED flags and the signature counter, and verify the ECDSA P-256 signature over SHA-256(authenticatorData || clientDataHash), where clientDataHash binds the SHA-256 of the base64url-encoded clientDataJSON. Reject non-canonical CBOR (indefinite-length items, unsorted or duplicate map keys, non-minimal integers), reject mismatched rpIdHash, origin and challenge, and accept both DER and raw IEEE P1363 signature encodings while rejecting high-S malleable forms. Test with truncated authenticator data, a substituted challenge, and a signature that verifies over the wrong concatenation order.", "environment": "node20", "language": "js", "model": "deepseek/deepseek-flash", "problem_class": "js-webauthn-cose-es256-assertion-verifier", "provider": "deepseek", "solved_at": "2026-09-10T22:18:21.646Z", "version": "20"}

Answer 2

I built and verified the solution. The complete self-contained document is at ~/webauthn-verifier/SOLUTION.md, with the implementation in webauthn.js and a 41-case suite in test.js. Here is the solution.


WebAuthn Assertion Verifier — ES256 / COSE with node:crypto only

Problem class: js-webauthn-cose-es256-assertion-verifier Runtime: Node.js (verified on v22.23.2; APIs used exist since Node 15) Dependencies: none beyond node:crypto (no cbor, cose, or jose)


1. Root-cause analysis

The failure class is a set of trust-boundary mistakes that a hand-rolled verifier makes. They fall into three groups.

A. Cryptographic / encoding mistakes

  1. Wrong signed message. WebAuthn signs authenticatorData || SHA-256(clientDataJSON). Typical bugs: hashing the concatenation again, reversing the order (hash || authData — the mandated "wrong concatenation order" case), or signing the JSON/base64 text instead of its digest.
  2. Base64url confusion. clientDataJSON arrives base64url-encoded. Decode it first, then clientDataHash = SHA-256(decoded bytes). Hashing the base64url text is wrong.
  3. Malleable signatures. OpenSSL/Node accepts high-S ECDSA signatures (s > n/2). For P-256 (r,s) and (r, n−s) both verify, so a correct verifier must reject s > n/2.
  4. Encoding coverage. Authenticators may emit DER (SEQUENCE { INTEGER r, INTEGER s }) or raw IEEE P1363 (64 bytes). Accept both, with strict DER parsing (no trailing bytes, no non-minimal/negative integers).

B. Parser mistakes (canonical CBOR)

COSE keys and extensions are CBOR and must be canonical (RFC 8949 deterministic). A permissive parser enables parser-differential attacks. Must reject: indefinite-length items (ai == 31); non-minimal integers/lengths (e.g. 0x18 0x01 for 1); duplicate map keys; unsorted map keys; trailing bytes. RFC 8949 orders keys by byte-wise lexicographic encoded bytes; the older RFC 7049/CTAP2 form orders by length first.

C. Validation omissions

rpIdHash not checked; origin/challenge/type not checked; UP/UV not enforced; reserved flag bits ignored; signature counter not compared; and truncated authenticator data read at fixed offsets without bounds checks (yields RangeError instead of clean failure).


2. Exact fix

2.1 API

const { verifyAssertion, verifyAssertionSafe } = require('./webauthn');

const result = verifyAssertion({
  authenticatorData,                 // Buffer
  clientDataJSON,                    // Buffer (raw) or base64url string
  signature,                         // Buffer: DER or raw 64-byte P1363
  publicKey,                         // KeyObject | JWK | COSE_Key Map | SPKI/PEM Buffer
  expectedChallenge,                 // Buffer or base64url string
  expectedOrigin,                    // string or string[]
  expectedRpId,                      // string (hashed + compared) or expectedRpIdHash: Buffer
  previousSignCount: 0,
  requireUserPresence: true,         // default
  requireUserVerification: false,    // set true for UV policy
});
// throws WebAuthnError on failure; verifyAssertionSafe() -> {ok, result|error}

2.2 Critical defensive paths

Canonical CBOR header (indefinite + non-minimal rejection):

} else if (ai === 27) {
  value = readUint(8);
  if (canonical && major !== 7 && value <= 0xffffffffn) {
    throw new CBORError('non-minimal integer/length (8-byte form)');
  }
} else if (ai === 31) {
  throw new CBORError('indefinite-length items are not allowed');
}

Map ordering / duplicates compared on the encoded key bytes:

const cmp = compareKeys(previousKeyBytes, keyBytes);
if (cmp === 0) throw new CBORError('duplicate map key');
if (cmp > 0) throw new CBORError('map keys are not in canonical order');

Low-S + range enforcement and raw/DER normalisation:

if (r <= 0n || r >= N) throw new WebAuthnError('signature r is out of range');
if (s <= 0n || s >= N) throw new WebAuthnError('signature s is out of range');
if (s > HALF_N) throw new WebAuthnError('high-S signature rejected (ECDSA malleability)');

The signed byte string (order matters):

// The signed message is exactly authData || clientDataHash.
const signedData = Buffer.concat([authDataBytes, client.hash]);
if (!verifyES256(key, signedData, signature)) {
  throw new WebAuthnError('assertion signature verification failed');
}

2.3 webauthn.js (complete)

'use strict';

const crypto = require('node:crypto');

class WebAuthnError extends Error {
  constructor(message) { super(message); this.name = 'WebAuthnError'; }
}
class CBORError extends WebAuthnError {
  constructor(message) { super(message); this.name = 'CBORError'; }
}

// P-256 (SEC 2 / FIPS 186-4)
const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
const A = P - 3n;
const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
const N = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
const HALF_N = N >> 1n;
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);

function toBuffer(value, encoding) {
  if (Buffer.isBuffer(value)) return value;
  if (value instanceof Uint8Array) return Buffer.from(value);
  if (typeof value === 'string') return Buffer.from(value, encoding || 'utf8');
  throw new WebAuthnError('expected Buffer/Uint8Array/string');
}
function toBase64url(buf) {
  return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64url(str) {
  if (typeof str !== 'string') throw new WebAuthnError('base64url value must be a string');
  if (!/^[A-Za-z0-9_-]*={0,2}$/.test(str)) throw new WebAuthnError('invalid base64url characters');
  return Buffer.from(str.replace(/=+$/, '').replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function timingSafeEqual(a, b) {
  if (!Buffer.isBuffer(a) || !Buffer.isBuffer(b)) return false;
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
function bufToBigInt(buf) { let v = 0n; for (const b of buf) v = (v << 8n) | BigInt(b); return v; }
function bigIntToBuf(value, length) {
  let hex = value.toString(16);
  if (hex.length % 2) hex = '0' + hex;
  let buf = Buffer.from(hex, 'hex');
  if (length !== undefined) {
    if (buf.length > length) throw new WebAuthnError('integer does not fit');
    if (buf.length < length) buf = Buffer.concat([Buffer.alloc(length - buf.length), buf]);
  }
  return buf;
}
function mod(value, m) { const r = value % m; return r < 0n ? r + m : r; }

// ---------------------------------------------------------------------------
// Canonical CBOR decoder (RFC 8949 deterministic encoding)
// ---------------------------------------------------------------------------
class CborTag { constructor(tag, value) { this.tag = tag; this.value = value; } }
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });

function decodeCBOR(buf, offset = 0, options = {}) {
  if (!Buffer.isBuffer(buf)) throw new CBORError('CBOR input must be a Buffer');
  const canonical = options.canonical !== false;
  const keyOrder = options.keyOrder || 'bytewise'; // 'bytewise' | 'length-first'
  const start = offset;
  let pos = offset;

  function compareKeys(a, b) {
    if (keyOrder === 'length-first') {
      if (a.length !== b.length) return a.length - b.length;
      return Buffer.compare(a, b);
    }
    return Buffer.compare(a, b);
  }
  function readBytes(n) {
    if (!Number.isInteger(n) || n < 0) throw new CBORError('invalid length');
    if (pos + n > buf.length) throw new CBORError('truncated CBOR');
    const out = buf.subarray(pos, pos + n); pos += n; return out;
  }
  function readUint(n) { let v = 0n; for (const b of readBytes(n)) v = (v << 8n) | BigInt(b); return v; }
  function toLength(value) {
    if (value > BigInt(buf.length)) throw new CBORError('CBOR length exceeds input size');
    return Number(value);
  }
  function readHeader() {
    if (pos >= buf.length) throw new CBORError('truncated CBOR header');
    const ib = buf[pos++]; const major = ib >> 5; const ai = ib & 0x1f;
    let value;
    if (ai < 24) value = BigInt(ai);
    else if (ai === 24) {
      value = readUint(1);
      if (canonical && major !== 7 && value < 24n) throw new CBORError('non-minimal integer/length (1-byte form for value < 24)');
    } else if (ai === 25) {
      value = readUint(2);
      if (canonical && major !== 7 && value <= 0xffn) throw new CBORError('non-minimal integer/length (2-byte form)');
    } else if (ai === 26) {
      value = readUint(4);
      if (canonical && major !== 7 && value <= 0xffffn) throw new CBORError('non-minimal integer/length (4-byte form)');
    } else if (ai === 27) {
      value = readUint(8);
      if (canonical && major !== 7 && value <= 0xffffffffn) throw new CBORError('non-minimal integer/length (8-byte form)');
    } else if (ai === 31) {
      throw new CBORError('indefinite-length items are not allowed');
    } else {
      throw new CBORError('reserved additional information value');
    }
    return { major, ai, value };
  }
  function decodeItem() {
    const { major, ai, value } = readHeader();
    switch (major) {
      case 0: return value <= MAX_SAFE ? Number(value) : value;
      case 1: return value <= MAX_SAFE ? -1 - Number(value) : -1n - value;
      case 2: return Buffer.from(readBytes(toLength(value)));
      case 3: {
        const bytes = readBytes(toLength(value));
        try { return utf8Decoder.decode(bytes); }
        catch { throw new CBORError('invalid UTF-8 in CBOR text string'); }
      }
      case 4: {
        const len = toLength(value); const arr = new Array(len);
        for (let i = 0; i < len; i++) arr[i] = decodeItem();
        return arr;
      }
      case 5: {
        const len = toLength(value); const map = new Map(); let previousKeyBytes = null;
        for (let i = 0; i < len; i++) {
          const keyStart = pos; const key = decodeItem();
          const keyBytes = buf.subarray(keyStart, pos);
          if (canonical && previousKeyBytes !== null) {
            const cmp = compareKeys(previousKeyBytes, keyBytes);
            if (cmp === 0) throw new CBORError('duplicate map key');
            if (cmp > 0) throw new CBORError('map keys are not in canonical order');
          }
          if (map.has(key)) throw new CBORError('duplicate map key');
          map.set(key, decodeItem());
          previousKeyBytes = keyBytes;
        }
        return map;
      }
      case 6: return new CborTag(value, decodeItem());
      case 7: {
        if (ai === 20) return false;
        if (ai === 21) return true;
        if (ai === 22) return null;
        if (ai === 23) return undefined;
        if (ai === 24) {
          const simple = readUint(1);
          if (canonical && simple < 32n) throw new CBORError('non-minimal simple value');
          return { simple: Number(simple) };
        }
        if (ai === 25) return readFloat16(readBytes(2));
        if (ai === 26) return readBytes(4).readFloatBE(0);
        if (ai === 27) return readBytes(8).readDoubleBE(0);
        throw new CBORError('reserved simple/float additional information');
      }
      default: throw new CBORError('unknown CBOR major type');
    }
  }
  const value = decodeItem();
  return { value, offset: pos, start };
}
function decodeCBORExact(buf, options) {
  const { value, offset } = decodeCBOR(buf, 0, options);
  if (offset !== buf.length) throw new CBORError('trailing bytes after CBOR item');
  return value;
}
function readFloat16(buf) {
  const half = buf.readUInt16BE(0);
  const sign = half & 0x8000 ? -1 : 1;
  const exponent = (half >> 10) & 0x1f;
  const fraction = half & 0x3ff;
  if (exponent === 0) return sign * Math.pow(2, -14) * (fraction / 1024);
  if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : NaN;
  return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
}

// ---------------------------------------------------------------------------
// COSE_Key (EC2 / ES256)
// ---------------------------------------------------------------------------
function mapGetInt(map, label) {
  const wanted = BigInt(label);
  for (const [key, value] of map) {
    if (typeof key === 'number' && Number.isInteger(key) && BigInt(key) === wanted) return value;
    if (typeof key === 'bigint' && key === wanted) return value;
  }
  return undefined;
}
function isOnP256(x, y) {
  if (x < 0n || y < 0n || x >= P || y >= P) return false;
  return mod(y * y, P) === mod(x * x * x + A * x + B, P);
}
function coseKeyToPublicKey(coseKey) {
  if (!(coseKey instanceof Map)) throw new WebAuthnError('COSE key must be a CBOR map');
  const kty = mapGetInt(coseKey, 1);
  const alg = mapGetInt(coseKey, 3);
  const crv = mapGetInt(coseKey, -1);
  const x = mapGetInt(coseKey, -2);
  const y = mapGetInt(coseKey, -3);
  if (kty !== 2) throw new WebAuthnError('COSE key is not EC2 (kty=2)');
  if (alg !== undefined && alg !== -7) throw new WebAuthnError('COSE key alg is not ES256 (-7)');
  if (crv !== 1) throw new WebAuthnError('COSE key curve is not P-256 (crv=1)');
  if (!Buffer.isBuffer(x) || x.length !== 32) throw new WebAuthnError('COSE key x must be a 32-byte byte string');
  if (!Buffer.isBuffer(y) || y.length !== 32) throw new WebAuthnError('COSE key y must be a 32-byte byte string');
  if (!isOnP256(bufToBigInt(x), bufToBigInt(y))) throw new WebAuthnError('COSE key point is not on the P-256 curve');
  return crypto.createPublicKey({
    key: { kty: 'EC', crv: 'P-256', x: toBase64url(x), y: toBase64url(y) }, format: 'jwk',
  });
}
function importPublicKey(input) {
  if (input === undefined || input === null) throw new WebAuthnError('public key is required');
  if (input instanceof crypto.KeyObject) return input;
  if (input instanceof Map) return coseKeyToPublicKey(input);
  if (typeof input === 'string') return crypto.createPublicKey(input);
  if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
    const buf = toBuffer(input);
    if (buf.length > 0 && buf[0] === 0x30) return crypto.createPublicKey({ key: buf, format: 'der', type: 'spki' });
    return crypto.createPublicKey(buf);
  }
  if (typeof input === 'object') {
    if (input.kty === 'EC') {
      if (input.crv !== undefined && input.crv !== 'P-256') throw new WebAuthnError('EC public key is not P-256');
      return crypto.createPublicKey({ key: input, format: 'jwk' });
    }
    if (input.key) return crypto.createPublicKey(input);
  }
  throw new WebAuthnError('unsupported public key format');
}

// ---------------------------------------------------------------------------
// ECDSA signature normalisation (DER <-> IEEE P1363) + low-S
// ---------------------------------------------------------------------------
function readDERLength(buf, state) {
  if (state.pos >= buf.length) throw new WebAuthnError('truncated DER length');
  const first = buf[state.pos++];
  if (first < 0x80) return first;
  const count = first & 0x7f;
  if (count === 0) throw new WebAuthnError('indefinite DER length');
  if (count > 4) throw new WebAuthnError('DER length too large');
  let length = 0;
  for (let i = 0; i < count; i++) {
    if (state.pos >= buf.length) throw new WebAuthnError('truncated DER length');
    const byte = buf[state.pos++];
    if (i === 0 && byte === 0) throw new WebAuthnError('non-minimal DER length');
    length = (length << 8) | byte;
  }
  if (length < 0x80) throw new WebAuthnError('non-minimal DER length');
  return length;
}
function readDERInteger(buf, state) {
  if (state.pos >= buf.length || buf[state.pos++] !== 0x02) throw new WebAuthnError('DER integer tag missing');
  const length = readDERLength(buf, state);
  if (length === 0) throw new WebAuthnError('empty DER integer');
  if (state.pos + length > buf.length) throw new WebAuthnError('truncated DER integer');
  const bytes = buf.subarray(state.pos, state.pos + length);
  state.pos += length;
  if (bytes[0] & 0x80) throw new WebAuthnError('negative DER integer');
  if (bytes.length > 1 && bytes[0] === 0x00 && (bytes[1] & 0x80) === 0) throw new WebAuthnError('non-minimal DER integer');
  return bufToBigInt(bytes);
}
function parseDERSignature(sig) {
  const state = { pos: 0 };
  if (sig.length < 2 || sig[0] !== 0x30) throw new WebAuthnError('not a DER SEQUENCE');
  state.pos = 1;
  const length = readDERLength(sig, state);
  if (state.pos + length !== sig.length) throw new WebAuthnError('DER length mismatch');
  const r = readDERInteger(sig, state);
  const s = readDERInteger(sig, state);
  if (state.pos !== sig.length) throw new WebAuthnError('trailing bytes in DER signature');
  return { r, s };
}
function encodeDERInteger(value) {
  let bytes = bigIntToBuf(value);
  if (bytes.length === 0) bytes = Buffer.from([0]);
  if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0x00]), bytes]);
  return Buffer.concat([Buffer.from([0x02, bytes.length]), bytes]);
}
function encodeDERSignature(r, s) {
  const body = Buffer.concat([encodeDERInteger(r), encodeDERInteger(s)]);
  if (body.length > 0x7f) throw new WebAuthnError('DER signature too long');
  return Buffer.concat([Buffer.from([0x30, body.length]), body]);
}
function parseSignature(signature) {
  const sig = toBuffer(signature);
  let r; let s; let encoding = null;
  if (sig.length > 0 && sig[0] === 0x30) {
    try { const p = parseDERSignature(sig); r = p.r; s = p.s; encoding = 'der'; }
    catch { /* fall through to raw */ }
  }
  if (encoding === null && sig.length === 64) {
    r = bufToBigInt(sig.subarray(0, 32)); s = bufToBigInt(sig.subarray(32, 64)); encoding = 'p1363';
  }
  if (encoding === null) throw new WebAuthnError('unrecognised signature encoding');
  if (r <= 0n || r >= N) throw new WebAuthnError('signature r is out of range');
  if (s <= 0n || s >= N) throw new WebAuthnError('signature s is out of range');
  if (s > HALF_N) throw new WebAuthnError('high-S signature rejected (ECDSA malleability)');
  return { r, s, encoding };
}
function verifyES256(publicKey, signedData, signature) {
  const { r, s } = parseSignature(signature);
  const der = encodeDERSignature(r, s);
  const verifier = crypto.createVerify('SHA256');
  verifier.update(signedData);
  return verifier.verify(publicKey, der);
}

// ---------------------------------------------------------------------------
// Authenticator data
// ---------------------------------------------------------------------------
const FLAG_UP = 0x01, FLAG_RFU1 = 0x02, FLAG_UV = 0x04, FLAG_BE = 0x08,
      FLAG_BS = 0x10, FLAG_RFU2 = 0x20, FLAG_AT = 0x40, FLAG_ED = 0x80;

function parseAuthenticatorData(authData) {
  const data = toBuffer(authData);
  if (data.length < 37) throw new WebAuthnError('authenticator data is truncated (< 37 bytes)');
  const rpIdHash = Buffer.from(data.subarray(0, 32));
  const flags = data[32];
  const signCount = data.readUInt32BE(33);
  let offset = 37;
  const result = {
    rpIdHash, flags, signCount,
    userPresent: (flags & FLAG_UP) !== 0,
    userVerified: (flags & FLAG_UV) !== 0,
    backupEligible: (flags & FLAG_BE) !== 0,
    backupState: (flags & FLAG_BS) !== 0,
    attestedCredentialDataIncluded: (flags & FLAG_AT) !== 0,
    extensionDataIncluded: (flags & FLAG_ED) !== 0,
    aaguid: null, credentialId: null, credentialPublicKey: null, extensions: null,
  };
  if (flags & (FLAG_RFU1 | FLAG_RFU2)) throw new WebAuthnError('authenticator data has reserved flag bits set');
  if (result.attestedCredentialDataIncluded) {
    if (offset + 18 > data.length) throw new WebAuthnError('attested credential data is truncated (AAGUID/length)');
    result.aaguid = Buffer.from(data.subarray(offset, offset + 16)); offset += 16;
    const credentialIdLength = data.readUInt16BE(offset); offset += 2;
    if (offset + credentialIdLength > data.length) throw new WebAuthnError('credential id is truncated');
    result.credentialId = Buffer.from(data.subarray(offset, offset + credentialIdLength)); offset += credentialIdLength;
    const { value, offset: next } = decodeCBOR(data, offset, { canonical: true });
    result.credentialPublicKey = value; offset = next;
  }
  if (result.extensionDataIncluded) {
    const { value, offset: next } = decodeCBOR(data, offset, { canonical: true });
    result.extensions = value; offset = next;
  }
  if (offset !== data.length) throw new WebAuthnError('authenticator data has trailing bytes');
  return result;
}

// ---------------------------------------------------------------------------
// Client data
// ---------------------------------------------------------------------------
function sha256(data) { return crypto.createHash('sha256').update(data).digest(); }
function resolveClientDataHash(clientDataJSON, isRaw) {
  if (Buffer.isBuffer(clientDataJSON) || clientDataJSON instanceof Uint8Array) {
    const bytes = toBuffer(clientDataJSON);
    return { bytes, hash: sha256(bytes) };
  }
  if (typeof clientDataJSON === 'string') {
    const bytes = isRaw ? Buffer.from(clientDataJSON, 'utf8') : fromBase64url(clientDataJSON);
    return { bytes, hash: sha256(bytes) };
  }
  throw new WebAuthnError('clientDataJSON must be a Buffer or base64url string');
}
function normalizeOrigin(origin) { try { return new URL(origin).origin; } catch { return origin; } }
function checkClientData(clientDataBytes, expected) {
  let parsed;
  try { parsed = JSON.parse(clientDataBytes.toString('utf8')); }
  catch { throw new WebAuthnError('clientDataJSON is not valid JSON'); }
  if (parsed === null || typeof parsed !== 'object') throw new WebAuthnError('clientDataJSON is not an object');
  if (parsed.type !== 'webauthn.get') throw new WebAuthnError('clientData type is not webauthn.get');
  if (expected.challenge !== undefined && expected.challenge !== null) {
    const expectedBytes = Buffer.isBuffer(expected.challenge) ? expected.challenge : fromBase64url(String(expected.challenge));
    let actualBytes;
    try { actualBytes = fromBase64url(String(parsed.challenge || '')); }
    catch { throw new WebAuthnError('clientData challenge is not valid base64url'); }
    if (!timingSafeEqual(actualBytes, expectedBytes)) throw new WebAuthnError('clientData challenge mismatch');
  }
  if (expected.origin !== undefined && expected.origin !== null) {
    const allowed = Array.isArray(expected.origin) ? expected.origin : [expected.origin];
    const actual = normalizeOrigin(String(parsed.origin || ''));
    if (!allowed.some((c) => normalizeOrigin(String(c)) === actual)) throw new WebAuthnError('clientData origin mismatch');
  }
  return parsed;
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function verifyAssertion(options) {
  if (!options || typeof options !== 'object') throw new WebAuthnError('options object is required');
  const {
    authenticatorData, clientDataJSON, signature, publicKey,
    expectedChallenge, expectedOrigin, expectedRpId, expectedRpIdHash,
    previousSignCount = 0, requireUserPresence = true, requireUserVerification = false,
    clientDataJSONIsRaw = false,
  } = options;
  if (authenticatorData === undefined) throw new WebAuthnError('authenticatorData is required');
  if (signature === undefined) throw new WebAuthnError('signature is required');
  if (clientDataJSON === undefined) throw new WebAuthnError('clientDataJSON is required');

  const authDataBytes = toBuffer(authenticatorData);
  const auth = parseAuthenticatorData(authDataBytes);

  if (requireUserPresence && !auth.userPresent) throw new WebAuthnError('user presence (UP) flag is not set');
  if (requireUserVerification && !auth.userVerified) throw new WebAuthnError('user verification (UV) flag is not set but is required');

  let expectedHash = null;
  if (expectedRpIdHash !== undefined && expectedRpIdHash !== null) expectedHash = toBuffer(expectedRpIdHash);
  else if (expectedRpId !== undefined && expectedRpId !== null) expectedHash = sha256(Buffer.from(String(expectedRpId), 'utf8'));
  if (expectedHash !== null && !timingSafeEqual(auth.rpIdHash, expectedHash)) throw new WebAuthnError('rpIdHash mismatch (relying party id)');

  const client = resolveClientDataHash(clientDataJSON, clientDataJSONIsRaw);
  checkClientData(client.bytes, { challenge: expectedChallenge, origin: expectedOrigin });

  if (previousSignCount > 0 && auth.signCount <= previousSignCount) {
    throw new WebAuthnError(`signature counter did not increase (stored=${previousSignCount}, received=${auth.signCount})`);
  }
  if (previousSignCount > 0 && auth.signCount === 0) throw new WebAuthnError('signature counter reset to zero (possible cloned authenticator)');

  let key = publicKey;
  if (key === undefined || key === null) {
    if (!auth.credentialPublicKey) throw new WebAuthnError('no public key supplied and authenticator data has no AT credential');
    key = coseKeyToPublicKey(auth.credentialPublicKey);
  } else key = importPublicKey(key);

  // The signed message is exactly authData || clientDataHash.
  const signedData = Buffer.concat([authDataBytes, client.hash]);
  if (!verifyES256(key, signedData, signature)) throw new WebAuthnError('assertion signature verification failed');

  return {
    verified: true, userPresent: auth.userPresent, userVerified: auth.userVerified,
    backupEligible: auth.backupEligible, backupState: auth.backupState,
    signCount: auth.signCount, rpIdHash: auth.rpIdHash, credentialId: auth.credentialId, clientData: client.bytes,
  };
}
function verifyAssertionSafe(options) {
  try { return { ok: true, result: verifyAssertion(options) }; }
  catch (error) { return { ok: false, error }; }
}

module.exports = {
  WebAuthnError, CBORError, CborTag, decodeCBOR, decodeCBORExact,
  coseKeyToPublicKey, importPublicKey, parseAuthenticatorData,
  parseSignature, parseDERSignature, encodeDERSignature, verifyES256,
  verifyAssertion, verifyAssertionSafe, sha256, toBase64url, fromBase64url,
  N, HALF_N, P, A, B, FLAG_UP, FLAG_UV, FLAG_AT, FLAG_ED,
};

3. Verification

3.1 Run

cd ~/webauthn-verifier
node --check webauthn.js
node test.js

The full 41-assertion suite (test.js) ends with:

RESULT: 41 passed, 0 failed, 41 total

3.2 Requirements → tests

Requirement Test(s)
Parse auth data (rpIdHash, flags, counter, AT, ED) [6], [7], [4]
Parse COSE_Key / ES256 accepts a COSE_Key (EC2/ES256) as the public key
UP / UV / AT / ED flags [4] + [7]
Signature counter rejects a non-increasing signature counter, accepts an increased signature counter
Verify SHA-256(authData \|\| clientDataHash) all positive tests
clientDataHash = SHA-256 of base64url-decoded clientDataJSON positives + rejects a substituted challenge
Reject indefinite-length CBOR rejects indefinite-length items, rejects indefinite-length map keys
Reject unsorted / duplicate map keys rejects unsorted map keys, rejects duplicate map keys, RFC-8949 order test
Reject non-minimal integers/lengths rejects non-minimal integer encodings, rejects non-minimal length encodings
Mismatched rpIdHash / origin / challenge rejects rpIdHash mismatch, rejects origin mismatch, rejects a substituted challenge
Accept DER and raw P1363 accepts a low-S raw IEEE P1363 signature, accepts a low-S DER signature
Reject high-S malleable rejects a high-S raw signature, rejects a high-S DER signature
Truncated auth data rejects truncated authenticator data
Wrong concatenation order rejects a signature computed over clientDataHash \|\| authenticatorData (wrong order)

3.3 Malleability proof

Node/OpenSSL accepts the high-S variant of a valid signature; the verifier rejects it, proving the low-S rule is a deliberate cryptographic policy rather than a DER-parsing artifact:

parsed r,s from Node DER signature
s is high-S: true
Node/OpenSSL crypto.verify accepts high-S DER: true
our verifyES256 rejects high-S: high-S signature rejected (ECDSA malleability)

3.4 Integrator checklist


4. Files produced

All 41 tests pass, including the three mandated negative cases: truncated authenticator data, substituted challenge, and a signature over the wrong concatenation order.

Evidence & signatures

# Evidence
- Problem class: js-webauthn-cose-es256-assertion-verifier
- Model: deepseek/deepseek-flash
- Solved: 2026-09-10T22:18:21.645Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement a WebAuthn assertion verifier in Node with no crypto libraries beyond node:crypto: parse authenticator data and CBOR/COSE (COSE_Key plus ES256) structures, validate the UP/UV/AT/ED flags and the signature counter, and verify the ECDSA P-256 signature over SHA-256(authenticatorData || clientDataHash), where clientDataHash binds the SHA-256 of the base64url-encoded clientDataJSON. Reject non-canonical CBOR (indefinite-length items, unsorted or duplicate map keys, non-minimal integers), reject mismatched rpIdHash, origin and challenge, and accept both DER and raw IEEE P1363 signature encodings while rejecting high-S malleable forms. Test with truncated authenticator data, a substituted challenge, and a signature that verifies over the wrong concatenation order.", "environment": "node20", "language": "js", "model": "deepseek/deepseek-flash", "problem_class": "js-webauthn-cose-es256-assertion-verifier", "provider": "deepseek", "solved_at": "2026-09-10T22:18:21.646Z", "version": "20"}
Generated from the verified corpus · MIT licensedBack to the catalog