Problem class: js-webauthn-cose-es256-assertion-verifier
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.
node:crypto onlyProblem 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)
The failure class is a set of trust-boundary mistakes that a hand-rolled verifier makes. They fall into three groups.
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.clientDataJSON arrives base64url-encoded. Decode it first, then clientDataHash = SHA-256(decoded bytes). Hashing the base64url text is wrong.s > n/2). For P-256 (r,s) and (r, n−s) both verify, so a correct verifier must reject s > n/2.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).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.
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).
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}
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');
}
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,
};
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
| 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) |
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)
expectedChallenge, expectedOrigin, and expectedRpId/expectedRpIdHash; omitting them disables the binding check.signCount per credential and pass previousSignCount; treat a return of 0 as "no counter support" and update stored state.requireUserVerification: true for UV-required flows.clientDataJSON defaults to base64url decoding; pass a Buffer (or clientDataJSONIsRaw: true) if already decoded.rpIdHash and challenge comparisons use crypto.timingSafeEqual.~/webauthn-verifier/webauthn.js — verifier (self-contained, node:crypto only)~/webauthn-verifier/test.js — 41-assertion suite~/webauthn-verifier/SOLUTION.md — full write-up (code + tests + verification)All 41 tests pass, including the three mandated negative cases: truncated authenticator data, substituted challenge, and a signature over the wrong concatenation order.
# 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"}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.
node:crypto onlyProblem 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)
The failure class is a set of trust-boundary mistakes that a hand-rolled verifier makes. They fall into three groups.
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.clientDataJSON arrives base64url-encoded. Decode it first, then clientDataHash = SHA-256(decoded bytes). Hashing the base64url text is wrong.s > n/2). For P-256 (r,s) and (r, n−s) both verify, so a correct verifier must reject s > n/2.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).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.
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).
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}
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');
}
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,
};
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
| 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) |
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)
expectedChallenge, expectedOrigin, and expectedRpId/expectedRpIdHash; omitting them disables the binding check.signCount per credential and pass previousSignCount; treat a return of 0 as "no counter support" and update stored state.requireUserVerification: true for UV-required flows.clientDataJSON defaults to base64url decoding; pass a Buffer (or clientDataJSONIsRaw: true) if already decoded.rpIdHash and challenge comparisons use crypto.timingSafeEqual.~/webauthn-verifier/webauthn.js — verifier (self-contained, node:crypto only)~/webauthn-verifier/test.js — 41-assertion suite~/webauthn-verifier/SOLUTION.md — full write-up (code + tests + verification)All 41 tests pass, including the three mandated negative cases: truncated authenticator data, substituted challenge, and a signature over the wrong concatenation order.
# 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"}