From 13402b84ff9033bd3efea28ff3eecc61ef2482c9 Mon Sep 17 00:00:00 2001 From: Memet Date: Fri, 7 Aug 2026 02:04:57 +0300 Subject: [PATCH 1/3] fix(passkey): throw typed PasskeyError everywhere; drop per-code throw factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled cleanups to align passkey error handling with the rest of the repo: 1. The binary parsers (asn1/der, cbor/decode, cose/key, x509/chain, webauthn authData/clientData/extensions/rpIdMatch/originCheck/flags) threw ~100 generic `Error`s, so a malformed attestation/assertion left the public API as a bare Error with no `code`. Every throw now carries a PasskeyError code: DECODE_ERROR (new, for der/cbor), AUTH_DATA_INVALID, CLIENT_DATA_INVALID, EXTENSION_INVALID, PUBLIC_KEY_UNSUPPORTED / UNSUPPORTED_ALGORITHM, ATTESTATION_INVALID / ATTESTATION_TRUST_ANCHOR_MISSING, INVALID_ARGUMENT. 2. Removed the 24 per-code `throwXxx = throwFactory(code)` exports (plus the unused non-throwing `invalidArgument`) — a bloat no other package carries. Every call site now constructs the error directly, `throw new PasskeyError(ErrorCode.X, msg)`, exactly like jwt / apikey / session. errors.js is now just ErrorCode + PasskeyError. Messages and control flow are unchanged; the 309-test suite (asserts parser errors by message) passes. --- packages/passkey/src/asn1/der.js | 54 ++++--- .../passkey/src/attestation/androidKey.js | 54 ++++--- .../src/attestation/androidSafetynet.js | 72 +++++++--- packages/passkey/src/attestation/apple.js | 59 +++++--- packages/passkey/src/attestation/fidoU2f.js | 29 ++-- packages/passkey/src/attestation/index.js | 7 +- packages/passkey/src/attestation/none.js | 9 +- packages/passkey/src/attestation/packed.js | 45 ++++-- packages/passkey/src/attestation/tpm.js | 134 ++++++++++++------ packages/passkey/src/authentication/begin.js | 23 +-- packages/passkey/src/authentication/finish.js | 80 +++++++---- packages/passkey/src/cbor/decode.js | 40 +++--- packages/passkey/src/cose/key.js | 48 ++++--- packages/passkey/src/errors.js | 38 +---- packages/passkey/src/internal/challenge.js | 30 ++-- packages/passkey/src/mds.js | 30 ++-- packages/passkey/src/registration/begin.js | 25 ++-- packages/passkey/src/registration/finish.js | 96 ++++++++----- packages/passkey/src/webauthn/authData.js | 41 ++++-- packages/passkey/src/webauthn/clientData.js | 27 ++-- packages/passkey/src/webauthn/extensions.js | 58 +++++--- packages/passkey/src/webauthn/flags.js | 29 ++-- packages/passkey/src/webauthn/originCheck.js | 8 +- packages/passkey/src/webauthn/rpIdMatch.js | 7 +- packages/passkey/src/x509/chain.js | 36 +++-- 25 files changed, 694 insertions(+), 385 deletions(-) diff --git a/packages/passkey/src/asn1/der.js b/packages/passkey/src/asn1/der.js index 71f3114..43cb845 100644 --- a/packages/passkey/src/asn1/der.js +++ b/packages/passkey/src/asn1/der.js @@ -19,6 +19,8 @@ * is in `tagNumber`; universal low-tag TLVs are unaffected. */ +import { PasskeyError, ErrorCode } from '../errors.js'; + // Universal tag constants (X.680 §8.6). export const TAG = Object.freeze({ BOOLEAN: 0x01, @@ -44,7 +46,7 @@ export const TAG = Object.freeze({ */ export function contextTag(n) { if (n < 0 || n > 30) { - throw new Error(`der: context tag ${n} out of range`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: context tag ${n} out of range`); } return 0xa0 | n; } @@ -70,10 +72,10 @@ export function contextTag(n) { */ export function readTlv(bytes, offset = 0) { if (!(bytes instanceof Uint8Array)) { - throw new Error('der: expected Uint8Array'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: expected Uint8Array'); } if (offset < 0 || offset >= bytes.byteLength) { - throw new Error(`der: offset ${offset} out of range (len ${bytes.byteLength})`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: offset ${offset} out of range (len ${bytes.byteLength})`); } let pos = offset; @@ -86,30 +88,30 @@ export function readTlv(bytes, offset = 0) { // High-tag-number form: the real number is a base-128 big-endian // run, continuation bit set on every byte but the last. if (pos >= bytes.byteLength) { - throw new Error('der: truncated in high-tag-number form'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: truncated in high-tag-number form'); } if ((bytes[pos] & 0x7f) === 0) { // A leading byte of 0x80 encodes a redundant zero — forbidden // (X.690 §8.1.2.4.2c), and 0x1f 0x00 would mean "use low form". - throw new Error('der: non-minimal high-tag-number encoding'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: non-minimal high-tag-number encoding'); } tagNumber = 0; let count = 0; let b; do { if (pos >= bytes.byteLength) { - throw new Error('der: truncated in high-tag-number form'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: truncated in high-tag-number form'); } b = bytes[pos++]; tagNumber = tagNumber * 128 + (b & 0x7f); count += 1; if (count > 4) { - throw new Error('der: high-tag-number too large'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: high-tag-number too large'); } } while ((b & 0x80) !== 0); } if (pos >= bytes.byteLength) { - throw new Error('der: truncated after tag'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: truncated after tag'); } // Length: short form if MSB clear (0..127), long form otherwise @@ -117,7 +119,7 @@ export function readTlv(bytes, offset = 0) { const firstLen = bytes[pos++]; let length; if (firstLen === 0x80) { - throw new Error('der: indefinite length is not permitted'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: indefinite length is not permitted'); } else if (firstLen < 0x80) { length = firstLen; } else { @@ -125,10 +127,10 @@ export function readTlv(bytes, offset = 0) { if (nBytes === 0 || nBytes > 4) { // Zero-length long form is reserved for future use; more than // 4 bytes wouldn't fit a sensible payload. - throw new Error(`der: unsupported long-form length count ${nBytes}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: unsupported long-form length count ${nBytes}`); } if (pos + nBytes > bytes.byteLength) { - throw new Error('der: truncated inside long-form length'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: truncated inside long-form length'); } // Accumulate with multiplication, not a signed `<<` shift: a // 4-byte length with the top bit set would overflow int32 and go @@ -141,7 +143,10 @@ export function readTlv(bytes, offset = 0) { } if (pos + length > bytes.byteLength) { - throw new Error(`der: TLV declares ${length} bytes but only ${bytes.byteLength - pos} available`); + throw new PasskeyError( + ErrorCode.DECODE_ERROR, + `der: TLV declares ${length} bytes but only ${bytes.byteLength - pos} available`, + ); } const contents = bytes.subarray(pos, pos + length); @@ -158,7 +163,7 @@ export function readTlv(bytes, offset = 0) { */ export function readChildren(contents) { if (!(contents instanceof Uint8Array)) { - throw new Error('der: expected Uint8Array'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: expected Uint8Array'); } const out = []; let pos = 0; @@ -181,7 +186,10 @@ export function readChildren(contents) { export function intoSequence(bytes, expectedTag = TAG.SEQUENCE) { const t = readTlv(bytes); if (t.tag !== expectedTag) { - throw new Error(`der: expected tag 0x${expectedTag.toString(16)}, got 0x${t.tag.toString(16)}`); + throw new PasskeyError( + ErrorCode.DECODE_ERROR, + `der: expected tag 0x${expectedTag.toString(16)}, got 0x${t.tag.toString(16)}`, + ); } return readChildren(t.contents); } @@ -200,7 +208,7 @@ export function intoSequence(bytes, expectedTag = TAG.SEQUENCE) { */ export function decodeOid(contents) { if (!(contents instanceof Uint8Array) || contents.byteLength === 0) { - throw new Error('der: OID must be a non-empty Uint8Array'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: OID must be a non-empty Uint8Array'); } const first = contents[0]; @@ -214,7 +222,7 @@ export function decodeOid(contents) { // Reject leading 0x80 in a multi-byte subidentifier — that would // encode a redundant leading zero, forbidden by X.690 §8.19.2. if (!started && b === 0x80) { - throw new Error('der: OID subidentifier has non-minimal encoding (leading 0x80)'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: OID subidentifier has non-minimal encoding (leading 0x80)'); } started = true; acc = (acc << 7n) | BigInt(b & 0x7f); @@ -225,7 +233,7 @@ export function decodeOid(contents) { } } if (started) { - throw new Error('der: OID truncated (last byte has continuation bit set)'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: OID truncated (last byte has continuation bit set)'); } return parts.join('.'); @@ -243,16 +251,16 @@ export function encodeOid(oid) { const parts = oid.split('.').map(p => { const n = BigInt(p); if (n < 0n) { - throw new Error(`der: OID part ${p} is negative`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: OID part ${p} is negative`); } return n; }); if (parts.length < 2) { - throw new Error('der: OID needs at least two components'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: OID needs at least two components'); } const [a, b] = parts; if (a > 2n || (a < 2n && b >= 40n)) { - throw new Error(`der: OID head ${a}.${b} violates X.690 constraints`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: OID head ${a}.${b} violates X.690 constraints`); } const out = [Number(a * 40n + b)]; for (let i = 2; i < parts.length; i += 1) { @@ -317,12 +325,12 @@ export function encodeOid(oid) { */ export function findExtension(certDer, oid) { if (typeof oid !== 'string') { - throw new Error('der.findExtension: oid must be a string'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der.findExtension: oid must be a string'); } const [tbs] = intoSequence(certDer, TAG.SEQUENCE); if (!tbs || tbs.tag !== TAG.SEQUENCE) { - throw new Error('der: certificate first element is not TBSCertificate'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der: certificate first element is not TBSCertificate'); } const tbsFields = readChildren(tbs.contents); @@ -353,7 +361,7 @@ export function findExtension(certDer, oid) { // last element. const last = parts[parts.length - 1]; if (last.tag !== TAG.OCTET_STRING) { - throw new Error(`der: extension ${oid} extnValue is not an OCTET STRING`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `der: extension ${oid} extnValue is not an OCTET STRING`); } return last.contents; } diff --git a/packages/passkey/src/attestation/androidKey.js b/packages/passkey/src/attestation/androidKey.js index c24c3c4..5a24fb2 100644 --- a/packages/passkey/src/attestation/androidKey.js +++ b/packages/passkey/src/attestation/androidKey.js @@ -44,7 +44,7 @@ import { importCoseKey, algorithmForId } from '../cose/key.js'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSignatureInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const ANDROID_KEY_OID = '1.3.6.1.4.1.11129.2.1.17'; @@ -66,16 +66,22 @@ const CONTEXT_CLASS = 2; function readKeyDescriptionFields(certDer) { const raw = findExtension(certDer, ANDROID_KEY_OID); if (raw === null) { - throwAttestationInvalid(`android-key: leaf missing attestation extension (OID ${ANDROID_KEY_OID})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-key: leaf missing attestation extension (OID ${ANDROID_KEY_OID})`, + ); } const outer = readTlv(raw); if (outer.tag !== TAG.SEQUENCE) { - throwAttestationInvalid('android-key: KeyDescription extension is not a SEQUENCE'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: KeyDescription extension is not a SEQUENCE'); } const fields = readChildren(outer.contents); // Positions 0..7 per KeyDescription. Guard against short inputs. if (fields.length < 8) { - throwAttestationInvalid(`android-key: KeyDescription has ${fields.length} fields, expected at least 8`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-key: KeyDescription has ${fields.length} fields, expected at least 8`, + ); } return fields; } @@ -89,7 +95,10 @@ function readKeyDescriptionFields(certDer) { function readAttestationChallenge(fields) { const challenge = fields[4]; if (challenge.tag !== TAG.OCTET_STRING) { - throwAttestationInvalid('android-key: KeyDescription attestationChallenge is not an OCTET STRING'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-key: KeyDescription attestationChallenge is not an OCTET STRING', + ); } return challenge.contents; } @@ -105,11 +114,15 @@ function assertNoAllApplications(fields) { for (const idx of [6, 7]) { const list = fields[idx]; if (list.tag !== TAG.SEQUENCE) { - throwAttestationInvalid(`android-key: KeyDescription AuthorizationList at index ${idx} is not a SEQUENCE`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-key: KeyDescription AuthorizationList at index ${idx} is not a SEQUENCE`, + ); } for (const entry of readChildren(list.contents)) { if (entry.tagClass === CONTEXT_CLASS && entry.tagNumber === KM_TAG_ALL_APPLICATIONS) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, 'android-key: KeyDescription must not contain allApplications [600] — the key is not RP-scoped', ); } @@ -146,23 +159,23 @@ function verifySignature(publicKey, algParams, data, signature) { */ export function verifyAndroidKey({ attStmt, authDataBytes, clientDataHash, attestedCredentialData, trustAnchors }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('android-key: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt is not a CBOR map'); } const alg = attStmt.get('alg'); const sig = attStmt.get('sig'); const x5cRaw = attStmt.get('x5c'); if (typeof alg !== 'number') { - throwAttestationInvalid('android-key: attStmt.alg missing or not an integer'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.alg missing or not an integer'); } if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { - throwAttestationInvalid('android-key: attStmt.sig missing or empty'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.sig missing or empty'); } if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { - throwAttestationInvalid('android-key: attStmt.x5c must be a non-empty array'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { if (!(c instanceof Uint8Array)) { - throwAttestationInvalid('android-key: attStmt.x5c entries must be byte strings'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.x5c entries must be byte strings'); } } @@ -173,13 +186,19 @@ export function verifyAndroidKey({ attStmt, authDataBytes, clientDataHash, attes // 1. Signature verifies with leaf public key over authData || clientDataHash. const signed = concat(authDataBytes, clientDataHash); if (!verifySignature(leaf.publicKey, algParams, signed, sig)) { - throwSignatureInvalid('android-key: signature does not verify against leaf certificate'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'android-key: signature does not verify against leaf certificate', + ); } // 2. Leaf public key MUST byte-equal the credentialPublicKey (SPKI DER). const credKey = importCoseKey(attestedCredentialData.credentialPublicKey); if (!bytesEqual(new Uint8Array(spkiDer(leaf.publicKey)), new Uint8Array(spkiDer(credKey.publicKey)))) { - throwAttestationInvalid('android-key: leaf public key does not match credentialPublicKey (SPKI mismatch)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-key: leaf public key does not match credentialPublicKey (SPKI mismatch)', + ); } // 3. attestationChallenge in the extension MUST equal clientDataHash, @@ -187,7 +206,10 @@ export function verifyAndroidKey({ attStmt, authDataBytes, clientDataHash, attes const keyDescription = readKeyDescriptionFields(x5cRaw[0]); const challenge = readAttestationChallenge(keyDescription); if (!bytesEqual(challenge, clientDataHash)) { - throwAttestationInvalid('android-key: attestationChallenge in KeyDescription does not equal clientDataHash'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-key: attestationChallenge in KeyDescription does not equal clientDataHash', + ); } assertNoAllApplications(keyDescription); @@ -198,7 +220,7 @@ export function verifyAndroidKey({ attStmt, authDataBytes, clientDataHash, attes verifyChain({ x5c: chain, trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`android-key: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `android-key: ${err.message}`); } } diff --git a/packages/passkey/src/attestation/androidSafetynet.js b/packages/passkey/src/attestation/androidSafetynet.js index 8b0e12a..04446f7 100644 --- a/packages/passkey/src/attestation/androidSafetynet.js +++ b/packages/passkey/src/attestation/androidSafetynet.js @@ -40,7 +40,7 @@ import { createHash, createVerify, X509Certificate } from 'node:crypto'; import { base64url } from '@exortek/crypto/encode'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { concat } from '../internal/bytes.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSignatureInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const LEAF_CN = 'attest.android.com'; const DEFAULT_TIMESTAMP_WINDOW_MS = 5 * 60_000; // 5 minutes either side @@ -49,7 +49,10 @@ function decodeBase64UrlToUint8(str, label) { try { return new Uint8Array(base64url.decode(str)); } catch (err) { - throwAttestationInvalid(`android-safetynet: ${label} is not valid base64url (${err.message})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-safetynet: ${label} is not valid base64url (${err.message})`, + ); } } @@ -59,12 +62,18 @@ function decodeJsonSegment(str, label) { try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (err) { - throwAttestationInvalid(`android-safetynet: ${label} is not valid UTF-8 (${err.message})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-safetynet: ${label} is not valid UTF-8 (${err.message})`, + ); } try { return JSON.parse(text); } catch (err) { - throwAttestationInvalid(`android-safetynet: ${label} is not valid JSON (${err.message})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-safetynet: ${label} is not valid JSON (${err.message})`, + ); } } @@ -99,21 +108,27 @@ export function verifyAndroidSafetynet(params) { } = params; if (!(attStmt instanceof Map)) { - throwAttestationInvalid('android-safetynet: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-safetynet: attStmt is not a CBOR map'); } const ver = attStmt.get('ver'); const response = attStmt.get('response'); if (typeof ver !== 'string' || ver.length === 0) { - throwAttestationInvalid('android-safetynet: attStmt.ver missing or not a string'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-safetynet: attStmt.ver missing or not a string'); } if (!(response instanceof Uint8Array) || response.byteLength === 0) { - throwAttestationInvalid('android-safetynet: attStmt.response missing or not a byte string'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-safetynet: attStmt.response missing or not a byte string', + ); } const responseStr = new TextDecoder('utf-8', { fatal: true }).decode(response); const segments = responseStr.split('.'); if (segments.length !== 3) { - throwAttestationInvalid('android-safetynet: response must be a compact JWS with three segments'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-safetynet: response must be a compact JWS with three segments', + ); } const [headerB64, payloadB64, signatureB64] = segments; const header = decodeJsonSegment(headerB64, 'JWS header'); @@ -121,10 +136,16 @@ export function verifyAndroidSafetynet(params) { const signature = decodeBase64UrlToUint8(signatureB64, 'JWS signature'); if (header.alg !== 'RS256') { - throwAttestationInvalid(`android-safetynet: header.alg must be "RS256" (got "${header.alg}")`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-safetynet: header.alg must be "RS256" (got "${header.alg}")`, + ); } if (!Array.isArray(header.x5c) || header.x5c.length === 0) { - throwAttestationInvalid('android-safetynet: header.x5c must be a non-empty cert array'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-safetynet: header.x5c must be a non-empty cert array', + ); } // Chain — header.x5c is an array of base64-encoded DER certs (RFC 7515 §4.1.6). @@ -132,7 +153,10 @@ export function verifyAndroidSafetynet(params) { try { return new X509Certificate(Buffer.from(b64, 'base64')); } catch (err) { - throwAttestationInvalid(`android-safetynet: header.x5c[${i}] not a valid certificate (${err.message})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `android-safetynet: header.x5c[${i}] not a valid certificate (${err.message})`, + ); return null; } }); @@ -143,13 +167,17 @@ export function verifyAndroidSafetynet(params) { const v = createVerify('RSA-SHA256'); v.update(signingInput); if (!v.verify(leaf.publicKey, signature)) { - throwSignatureInvalid('android-safetynet: JWS signature does not verify against leaf certificate'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'android-safetynet: JWS signature does not verify against leaf certificate', + ); } // Nonce check. const expectedNonceB64 = createHash('sha256').update(concat(authDataBytes, clientDataHash)).digest('base64'); if (payload.nonce !== expectedNonceB64) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `android-safetynet: payload.nonce does not equal base64(SHA-256(authData || clientDataHash))`, ); } @@ -160,27 +188,33 @@ export function verifyAndroidSafetynet(params) { // trailing `.`, so `CN=attest.android.com.attacker.example` slipped // through — a subdomain-suffix bypass of the leaf identity gate. if (!/(?:^|[,\n])CN=attest\.android\.com(?:$|[,\n])/i.test(leaf.subject)) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `android-safetynet: leaf subject CN must be "${LEAF_CN}" (got "${leaf.subject.replace(/\n/g, ' ')}")`, ); } // Integrity flags. if (payload.basicIntegrity !== true) { - throwAttestationInvalid('android-safetynet: payload.basicIntegrity is not true'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-safetynet: payload.basicIntegrity is not true'); } if (enforceCtsCheck && payload.ctsProfileMatch !== true) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, 'android-safetynet: payload.ctsProfileMatch is not true (disable with enforceCtsCheck: false)', ); } // Timestamp — accept ±window either side of `now`. if (typeof payload.timestampMs !== 'number' || !Number.isFinite(payload.timestampMs)) { - throwAttestationInvalid('android-safetynet: payload.timestampMs missing or not a finite number'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'android-safetynet: payload.timestampMs missing or not a finite number', + ); } if (Math.abs(now - payload.timestampMs) > timestampWindowMs) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `android-safetynet: payload.timestampMs (${payload.timestampMs}) is outside the ±${timestampWindowMs}ms window from now (${now})`, ); } @@ -192,7 +226,7 @@ export function verifyAndroidSafetynet(params) { verifyChain({ x5c: chain, trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`android-safetynet: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `android-safetynet: ${err.message}`); } } diff --git a/packages/passkey/src/attestation/apple.js b/packages/passkey/src/attestation/apple.js index 68eb326..001eaf3 100644 --- a/packages/passkey/src/attestation/apple.js +++ b/packages/passkey/src/attestation/apple.js @@ -20,7 +20,7 @@ import { createHash, X509Certificate } from 'node:crypto'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, TAG, contextTag } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const APPLE_NONCE_OID = '1.2.840.113635.100.8.2'; @@ -35,23 +35,35 @@ const APPLE_NONCE_OID = '1.2.840.113635.100.8.2'; function readAppleNonce(certDer) { const raw = findExtension(certDer, APPLE_NONCE_OID); if (raw === null) { - throwAttestationInvalid('apple attestation: leaf missing nonce extension (OID 1.2.840.113635.100.8.2)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: leaf missing nonce extension (OID 1.2.840.113635.100.8.2)', + ); } const seq = readTlv(raw); if (seq.tag !== TAG.SEQUENCE) { - throwAttestationInvalid('apple attestation: nonce extension is not a SEQUENCE'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: nonce extension is not a SEQUENCE'); } const items = readChildren(seq.contents); const tagged = items.find(t => t.tag === contextTag(1)); if (!tagged) { - throwAttestationInvalid('apple attestation: nonce extension SEQUENCE missing [1] EXPLICIT OCTET STRING'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: nonce extension SEQUENCE missing [1] EXPLICIT OCTET STRING', + ); } const inner = readTlv(tagged.contents); if (inner.tag !== TAG.OCTET_STRING) { - throwAttestationInvalid('apple attestation: nonce extension inner tag is not OCTET STRING'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: nonce extension inner tag is not OCTET STRING', + ); } if (inner.contents.byteLength !== 32) { - throwAttestationInvalid(`apple attestation: nonce must be 32 bytes (got ${inner.contents.byteLength})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `apple attestation: nonce must be 32 bytes (got ${inner.contents.byteLength})`, + ); } return inner.contents; } @@ -66,12 +78,15 @@ function readAppleNonce(certDer) { */ function coseEc2ToUncompressed(coseKey) { if (coseKey.get(1) !== 2 || coseKey.get(-1) !== 1) { - throwAttestationInvalid('apple attestation: credential key must be EC2 / P-256'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: credential key must be EC2 / P-256'); } const x = coseKey.get(-2); const y = coseKey.get(-3); if (!(x instanceof Uint8Array) || x.byteLength !== 32 || !(y instanceof Uint8Array) || y.byteLength !== 32) { - throwAttestationInvalid('apple attestation: credential key x / y must be 32-byte Uint8Arrays'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: credential key x / y must be 32-byte Uint8Arrays', + ); } const out = new Uint8Array(65); out[0] = 0x04; @@ -90,11 +105,12 @@ function coseEc2ToUncompressed(coseKey) { */ function leafPublicKeyUncompressed(leaf) { if (leaf.publicKey.asymmetricKeyType !== 'ec') { - throwAttestationInvalid('apple attestation: leaf public key is not EC'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: leaf public key is not EC'); } const details = leaf.publicKey.asymmetricKeyDetails ?? {}; if (details.namedCurve !== 'prime256v1') { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `apple attestation: leaf public key curve must be P-256 (got ${details.namedCurve ?? 'unknown'})`, ); } @@ -102,7 +118,7 @@ function leafPublicKeyUncompressed(leaf) { const x = Buffer.from(jwk.x, 'base64url'); const y = Buffer.from(jwk.y, 'base64url'); if (x.byteLength !== 32 || y.byteLength !== 32) { - throwAttestationInvalid('apple attestation: leaf EC coordinates are not 32 bytes'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: leaf EC coordinates are not 32 bytes'); } const out = new Uint8Array(65); out[0] = 0x04; @@ -122,19 +138,22 @@ function leafPublicKeyUncompressed(leaf) { */ export function verifyApple({ attStmt, authDataBytes, clientDataHash, attestedCredentialData, trustAnchors }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('apple attestation: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: attStmt is not a CBOR map'); } const x5cRaw = attStmt.get('x5c'); if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { - throwAttestationInvalid('apple attestation: x5c must be a non-empty array'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: x5c must be a non-empty array'); } for (const c of x5cRaw) { if (!(c instanceof Uint8Array)) { - throwAttestationInvalid('apple attestation: x5c entries must be byte strings'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: x5c entries must be byte strings'); } } if (attStmt.has('sig')) { - throwAttestationInvalid('apple attestation: attStmt must not carry a sig field (§8.8 has no signature)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: attStmt must not carry a sig field (§8.8 has no signature)', + ); } // Nonce check: SHA-256(authData || clientDataHash) must equal the @@ -142,7 +161,8 @@ export function verifyApple({ attStmt, authDataBytes, clientDataHash, attestedCr const expectedNonce = new Uint8Array(createHash('sha256').update(concat(authDataBytes, clientDataHash)).digest()); const certNonce = readAppleNonce(x5cRaw[0]); if (!bytesEqual(expectedNonce, certNonce)) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, 'apple attestation: nonce in leaf extension does not equal SHA-256(authData || clientDataHash)', ); } @@ -154,7 +174,10 @@ export function verifyApple({ attStmt, authDataBytes, clientDataHash, attestedCr const leafKey = leafPublicKeyUncompressed(leaf); const credKey = coseEc2ToUncompressed(attestedCredentialData.credentialPublicKey); if (!bytesEqual(leafKey, credKey)) { - throwAttestationInvalid('apple attestation: leaf public key does not match credential public key'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'apple attestation: leaf public key does not match credential public key', + ); } let trustPath = 'no-anchor'; @@ -163,7 +186,7 @@ export function verifyApple({ attStmt, authDataBytes, clientDataHash, attestedCr verifyChain({ x5c: chain, trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`apple attestation: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `apple attestation: ${err.message}`); } } diff --git a/packages/passkey/src/attestation/fidoU2f.js b/packages/passkey/src/attestation/fidoU2f.js index 83a3fa5..a1f9c2a 100644 --- a/packages/passkey/src/attestation/fidoU2f.js +++ b/packages/passkey/src/attestation/fidoU2f.js @@ -24,7 +24,7 @@ import { createVerify, X509Certificate } from 'node:crypto'; import { verifyChain, toCertificates } from '../x509/chain.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSignatureInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * @param {object} params @@ -37,16 +37,19 @@ import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSigna */ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attestedCredentialData, trustAnchors }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('fido-u2f attestation: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'fido-u2f attestation: attStmt is not a CBOR map'); } const x5cRaw = attStmt.get('x5c'); const sig = attStmt.get('sig'); if (!Array.isArray(x5cRaw) || x5cRaw.length !== 1 || !(x5cRaw[0] instanceof Uint8Array)) { - throwAttestationInvalid('fido-u2f attestation: x5c must be a single-element array of bytes (§8.6 step 1)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'fido-u2f attestation: x5c must be a single-element array of bytes (§8.6 step 1)', + ); } if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { - throwAttestationInvalid('fido-u2f attestation: attStmt.sig missing or empty'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'fido-u2f attestation: attStmt.sig missing or empty'); } // Credential key must be ES256 / P-256 — the format has no alg @@ -58,12 +61,16 @@ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attested const x = coseKey.get(-2); const y = coseKey.get(-3); if (kty !== 2 || alg !== -7 || crv !== 1) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `fido-u2f attestation: credential public key must be EC2 / ES256 / P-256 (got kty=${kty}, alg=${alg}, crv=${crv})`, ); } if (!(x instanceof Uint8Array) || x.byteLength !== 32 || !(y instanceof Uint8Array) || y.byteLength !== 32) { - throwAttestationInvalid('fido-u2f attestation: credential public key x / y must be 32-byte coordinates'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'fido-u2f attestation: credential public key x / y must be 32-byte coordinates', + ); } // Build the U2F Raw Message: 0x00 || rpIdHash || clientDataHash || @@ -96,7 +103,8 @@ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attested // `.publicKey.asymmetricKeyDetails.namedCurve`. const details = leaf.publicKey.asymmetricKeyDetails ?? {}; if (leaf.publicKey.asymmetricKeyType !== 'ec' || details.namedCurve !== 'prime256v1') { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `fido-u2f attestation: attestation certificate must carry an EC P-256 public key (got ${leaf.publicKey.asymmetricKeyType} / ${details.namedCurve ?? 'unknown curve'})`, ); } @@ -105,7 +113,10 @@ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attested v.update(signed); const ok = v.verify({ key: leaf.publicKey, dsaEncoding: 'der' }, sig); if (!ok) { - throwSignatureInvalid('fido-u2f attestation: signature does not verify against leaf certificate'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'fido-u2f attestation: signature does not verify against leaf certificate', + ); } let trustPath = 'no-anchor'; @@ -114,7 +125,7 @@ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attested verifyChain({ x5c: [leaf], trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`fido-u2f attestation: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `fido-u2f attestation: ${err.message}`); } } diff --git a/packages/passkey/src/attestation/index.js b/packages/passkey/src/attestation/index.js index f11aeb2..575ca06 100644 --- a/packages/passkey/src/attestation/index.js +++ b/packages/passkey/src/attestation/index.js @@ -10,7 +10,7 @@ * throw `UNSUPPORTED_ATTESTATION_FORMAT` with a clear message. */ -import { throwUnsupportedAttestationFormat } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; import { verifyNone } from './none.js'; import { verifyPacked } from './packed.js'; import { verifyFidoU2f } from './fidoU2f.js'; @@ -39,6 +39,9 @@ export function getVerifier(fmt) { case 'tpm': return verifyTpm; default: - throwUnsupportedAttestationFormat(`attestation format "${fmt}" is not supported yet`); + throw new PasskeyError( + ErrorCode.UNSUPPORTED_ATTESTATION_FORMAT, + `attestation format "${fmt}" is not supported yet`, + ); } } diff --git a/packages/passkey/src/attestation/none.js b/packages/passkey/src/attestation/none.js index 139adf2..b7fe97b 100644 --- a/packages/passkey/src/attestation/none.js +++ b/packages/passkey/src/attestation/none.js @@ -4,7 +4,7 @@ * (`a0`); anything else violates the spec and is rejected. */ -import { throwAttestationInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * @param {object} params @@ -13,10 +13,13 @@ import { throwAttestationInvalid } from '../errors.js'; */ export function verifyNone({ attStmt }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('none attestation: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'none attestation: attStmt is not a CBOR map'); } if (attStmt.size !== 0) { - throwAttestationInvalid(`none attestation: attStmt must be empty, got ${attStmt.size} field(s)`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `none attestation: attStmt must be empty, got ${attStmt.size} field(s)`, + ); } return { format: 'none', trustPath: 'no-anchor' }; } diff --git a/packages/passkey/src/attestation/packed.js b/packages/passkey/src/attestation/packed.js index 931d311..c602fcb 100644 --- a/packages/passkey/src/attestation/packed.js +++ b/packages/passkey/src/attestation/packed.js @@ -21,7 +21,7 @@ import { importCoseKey, algorithmForId } from '../cose/key.js'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSignatureInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const AAGUID_OID = '1.3.6.1.4.1.45724.1.1.4'; @@ -40,7 +40,10 @@ function readCertAaguid(certDer) { } const inner = readTlv(outer); if (inner.tag !== TAG.OCTET_STRING || inner.contents.byteLength !== 16) { - throwAttestationInvalid('packed attestation: AAGUID extension is not a 16-byte OCTET STRING'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'packed attestation: AAGUID extension is not a 16-byte OCTET STRING', + ); } return inner.contents; } @@ -61,17 +64,17 @@ function readCertAaguid(certDer) { */ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedCredentialData, trustAnchors }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('packed attestation: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: attStmt is not a CBOR map'); } const alg = attStmt.get('alg'); const sig = attStmt.get('sig'); const x5cRaw = attStmt.get('x5c'); if (typeof alg !== 'number') { - throwAttestationInvalid('packed attestation: attStmt.alg missing or not an integer'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: attStmt.alg missing or not an integer'); } if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { - throwAttestationInvalid('packed attestation: attStmt.sig missing or empty'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: attStmt.sig missing or empty'); } const algParams = algorithmForId(alg); @@ -82,24 +85,28 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC // alg MUST match credentialPublicKey.alg (WebAuthn L3 §8.2 step 3.1). const credAlg = attestedCredentialData.credentialPublicKey.get(3); if (credAlg !== alg) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `packed self-attestation: attStmt.alg (${alg}) does not match credentialPublicKey.alg (${credAlg})`, ); } const { publicKey } = importCoseKey(attestedCredentialData.credentialPublicKey); if (!verifySignature(publicKey, algParams, signed, sig)) { - throwSignatureInvalid('packed self-attestation: signature does not verify against credential public key'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'packed self-attestation: signature does not verify against credential public key', + ); } return { format: 'packed', trustPath: 'self' }; } // Full attestation with x5c. if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { - throwAttestationInvalid('packed attestation: attStmt.x5c must be a non-empty array'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { if (!(c instanceof Uint8Array)) { - throwAttestationInvalid('packed attestation: x5c entries must be byte strings'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: x5c entries must be byte strings'); } } const chain = x5cRaw.map(der => new X509Certificate(der)); @@ -107,7 +114,10 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC // Signature verifies with the leaf certificate's public key. if (!verifySignature(leaf.publicKey, algParams, signed, sig)) { - throwSignatureInvalid('packed attestation: signature does not verify against leaf certificate'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'packed attestation: signature does not verify against leaf certificate', + ); } // AAGUID extension: if present, must match authData.aaguid. @@ -116,7 +126,10 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC if (certAaguid !== null) { aaguidExtensionOk = bytesEqual(certAaguid, attestedCredentialData.aaguid); if (!aaguidExtensionOk) { - throwAttestationInvalid('packed attestation: cert AAGUID extension does not match authData AAGUID'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'packed attestation: cert AAGUID extension does not match authData AAGUID', + ); } } @@ -127,12 +140,16 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC // rejecting minor formatting differences. const subject = leaf.subject.replace(/\r/g, ''); if (!/OU=Authenticator Attestation/i.test(subject)) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `packed attestation: leaf subject missing "OU=Authenticator Attestation" per §8.2 step 2.3 (got: ${subject.replace(/\n/g, ' ')})`, ); } if (leaf.ca === true) { - throwAttestationInvalid('packed attestation: leaf basicConstraints CA must be FALSE (§8.2 step 2.3)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'packed attestation: leaf basicConstraints CA must be FALSE (§8.2 step 2.3)', + ); } // Chain verification against RP-supplied anchors. @@ -142,7 +159,7 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC verifyChain({ x5c: chain, trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`packed attestation: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `packed attestation: ${err.message}`); } } diff --git a/packages/passkey/src/attestation/tpm.js b/packages/passkey/src/attestation/tpm.js index b4a487c..16f913c 100644 --- a/packages/passkey/src/attestation/tpm.js +++ b/packages/passkey/src/attestation/tpm.js @@ -48,7 +48,7 @@ import { algorithmForId } from '../cose/key.js'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, decodeOid, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; -import { throwAttestationInvalid, throwAttestationTrustAnchorMissing, throwSignatureInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const TPM_GENERATED_VALUE = 0xff544347; const TPM_ST_ATTEST_CERTIFY = 0x8017; @@ -81,7 +81,10 @@ class Reader { } need(n) { if (this.pos + n > this.bytes.byteLength) { - throwAttestationInvalid(`tpm: TPM struct truncated (need ${n} at offset ${this.pos})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: TPM struct truncated (need ${n} at offset ${this.pos})`, + ); } } u16() { @@ -123,7 +126,10 @@ function parsePubArea(bytes) { const nameAlgTpm = r.u16(); const nameAlg = TPM_HASH_ALG[nameAlgTpm]; if (!nameAlg) { - throwAttestationInvalid(`tpm: pubArea nameAlg 0x${nameAlgTpm.toString(16)} not supported`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: pubArea nameAlg 0x${nameAlgTpm.toString(16)} not supported`, + ); } r.u32(); // objectAttributes — we don't use it beyond parsing r.b16(); // authPolicy @@ -132,10 +138,10 @@ function parsePubArea(bytes) { if (type === TPM_ALG_RSA) { // TPMS_RSA_PARMS if (r.u16() !== TPM_ALG_NULL) { - throwAttestationInvalid('tpm: pubArea RSA symmetric alg must be TPM_ALG_NULL'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea RSA symmetric alg must be TPM_ALG_NULL'); } if (r.u16() !== TPM_ALG_NULL) { - throwAttestationInvalid('tpm: pubArea RSA scheme must be TPM_ALG_NULL'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea RSA scheme must be TPM_ALG_NULL'); } const keyBits = r.u16(); let exponent = r.u32(); @@ -144,39 +150,49 @@ function parsePubArea(bytes) { } const modulus = r.b16(); if (modulus.byteLength * 8 !== keyBits) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `tpm: pubArea RSA keyBits (${keyBits}) does not match modulus length (${modulus.byteLength * 8})`, ); } if (modulus.byteLength < 256) { // 2048-bit floor — matches @exortek/passkey COSE key import. - throwAttestationInvalid(`tpm: pubArea RSA modulus is ${modulus.byteLength * 8} bits, minimum 2048`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: pubArea RSA modulus is ${modulus.byteLength * 8} bits, minimum 2048`, + ); } key = { kind: 'rsa', modulus, exponent }; } else if (type === TPM_ALG_ECC) { if (r.u16() !== TPM_ALG_NULL) { - throwAttestationInvalid('tpm: pubArea ECC symmetric alg must be TPM_ALG_NULL'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea ECC symmetric alg must be TPM_ALG_NULL'); } if (r.u16() !== TPM_ALG_NULL) { - throwAttestationInvalid('tpm: pubArea ECC scheme must be TPM_ALG_NULL'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea ECC scheme must be TPM_ALG_NULL'); } const curveId = r.u16(); const curveName = TPM_ECC_CURVE[curveId]; if (!curveName) { - throwAttestationInvalid(`tpm: pubArea ECC curveID 0x${curveId.toString(16)} not supported`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: pubArea ECC curveID 0x${curveId.toString(16)} not supported`, + ); } if (r.u16() !== TPM_ALG_NULL) { - throwAttestationInvalid('tpm: pubArea ECC KDF scheme must be TPM_ALG_NULL'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea ECC KDF scheme must be TPM_ALG_NULL'); } const x = r.b16(); const y = r.b16(); key = { kind: 'ec', curve: curveName, x, y }; } else { - throwAttestationInvalid(`tpm: pubArea type 0x${type.toString(16)} not supported (want RSA 0x0001 or ECC 0x0023)`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: pubArea type 0x${type.toString(16)} not supported (want RSA 0x0001 or ECC 0x0023)`, + ); } if (r.remaining() !== 0) { - throwAttestationInvalid(`tpm: pubArea has ${r.remaining()} trailing byte(s)`); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, `tpm: pubArea has ${r.remaining()} trailing byte(s)`); } return { type, nameAlgTpm, nameAlg, key }; @@ -189,13 +205,17 @@ function parseCertInfo(bytes) { const r = new Reader(bytes); const magic = r.u32(); if (magic !== TPM_GENERATED_VALUE) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `tpm: certInfo magic must be TPM_GENERATED_VALUE (0xff544347), got 0x${magic.toString(16)}`, ); } const type = r.u16(); if (type !== TPM_ST_ATTEST_CERTIFY) { - throwAttestationInvalid(`tpm: certInfo type must be TPM_ST_ATTEST_CERTIFY (0x8017), got 0x${type.toString(16)}`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: certInfo type must be TPM_ST_ATTEST_CERTIFY (0x8017), got 0x${type.toString(16)}`, + ); } const qualifiedSigner = r.b16(); const extraData = r.b16(); @@ -206,7 +226,7 @@ function parseCertInfo(bytes) { const name = r.b16(); const qualifiedName = r.b16(); if (r.remaining() !== 0) { - throwAttestationInvalid(`tpm: certInfo has ${r.remaining()} trailing byte(s)`); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, `tpm: certInfo has ${r.remaining()} trailing byte(s)`); } return { magic, type, qualifiedSigner, extraData, name, qualifiedName }; } @@ -232,15 +252,21 @@ function comparePubAreaToCose(pubKey, coseKey) { const kty = coseKey.get(1); if (pubKey.kind === 'rsa') { if (kty !== 3) { - throwAttestationInvalid('tpm: pubArea is RSA but credentialPublicKey is not (COSE kty != 3)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: pubArea is RSA but credentialPublicKey is not (COSE kty != 3)', + ); } const n = coseKey.get(-1); const e = coseKey.get(-2); if (!(n instanceof Uint8Array) || !(e instanceof Uint8Array)) { - throwAttestationInvalid('tpm: credentialPublicKey RSA n/e must be byte strings'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: credentialPublicKey RSA n/e must be byte strings'); } if (!bytesEqual(n, pubKey.modulus)) { - throwAttestationInvalid('tpm: pubArea RSA modulus does not match credentialPublicKey'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: pubArea RSA modulus does not match credentialPublicKey', + ); } // COSE exponent is big-endian bytes; TPM parsed exponent as u32. let coseExp = 0; @@ -248,24 +274,31 @@ function comparePubAreaToCose(pubKey, coseKey) { coseExp = (coseExp << 8) | e[i]; } if (coseExp !== pubKey.exponent) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `tpm: pubArea RSA exponent (${pubKey.exponent}) does not match credentialPublicKey (${coseExp})`, ); } } else { // EC if (kty !== 2) { - throwAttestationInvalid('tpm: pubArea is ECC but credentialPublicKey is not (COSE kty != 2)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: pubArea is ECC but credentialPublicKey is not (COSE kty != 2)', + ); } const crvId = coseKey.get(-1); const crv = { 1: 'P-256', 2: 'P-384', 3: 'P-521' }[crvId]; if (crv !== pubKey.curve) { - throwAttestationInvalid(`tpm: pubArea ECC curve ${pubKey.curve} does not match credentialPublicKey (${crv})`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: pubArea ECC curve ${pubKey.curve} does not match credentialPublicKey (${crv})`, + ); } const x = coseKey.get(-2); const y = coseKey.get(-3); if (!bytesEqual(x, pubKey.x) || !bytesEqual(y, pubKey.y)) { - throwAttestationInvalid('tpm: pubArea ECC x/y do not match credentialPublicKey'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: pubArea ECC x/y do not match credentialPublicKey'); } } } @@ -283,7 +316,7 @@ function assertAikProfile(leaf, leafDer) { // basicConstraints CA:FALSE — Node exposes this via // `.ca` (boolean) since Node 20+. if (leaf.ca === true) { - throwAttestationInvalid('tpm: AIK cert basicConstraints CA must be FALSE'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: AIK cert basicConstraints CA must be FALSE'); } // extendedKeyUsage MUST include TCG-KP-AIK (2.23.133.8.3). // Node's `.extKeyUsage` is unreliable across versions for non- @@ -292,22 +325,26 @@ function assertAikProfile(leaf, leafDer) { // SEQUENCE OF OBJECT IDENTIFIER. const ekuExt = findExtension(leafDer, '2.5.29.37'); if (ekuExt === null) { - throwAttestationInvalid('tpm: AIK cert missing extendedKeyUsage extension'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: AIK cert missing extendedKeyUsage extension'); } const ekuSeq = readTlv(ekuExt); if (ekuSeq.tag !== TAG.SEQUENCE) { - throwAttestationInvalid('tpm: AIK cert extendedKeyUsage is not a SEQUENCE'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: AIK cert extendedKeyUsage is not a SEQUENCE'); } const purposes = readChildren(ekuSeq.contents) .filter(t => t.tag === TAG.OBJECT_IDENTIFIER) .map(t => decodeOid(t.contents)); if (!purposes.includes(AIK_EKU_OID)) { - throwAttestationInvalid(`tpm: AIK cert extendedKeyUsage must include ${AIK_EKU_OID} (TCG-KP-AIK)`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: AIK cert extendedKeyUsage must include ${AIK_EKU_OID} (TCG-KP-AIK)`, + ); } // Subject sequence MUST be empty (TCG data lives in SAN). Node's // .subject returns an empty string when the RDN sequence is empty. if (leaf.subject && leaf.subject.replace(/\s+/g, '').length > 0) { - throwAttestationInvalid( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `tpm: AIK cert subject must be empty per TCG profile (got "${leaf.subject.replace(/\n/g, ' ')}")`, ); } @@ -316,7 +353,10 @@ function assertAikProfile(leaf, leafDer) { // OID is 2.5.29.17. const san = findExtension(leafDer, '2.5.29.17'); if (san === null) { - throwAttestationInvalid('tpm: AIK cert missing subjectAlternativeName (TCG vendor info)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: AIK cert missing subjectAlternativeName (TCG vendor info)', + ); } // Deep parse of the SAN's directoryName TCG attributes (manufacturer, // model, firmwareVersion) is a follow-up — we accept presence for now. @@ -347,7 +387,7 @@ function verifySignature(publicKey, algParams, data, signature) { */ export function verifyTpm({ attStmt, authDataBytes, clientDataHash, attestedCredentialData, trustAnchors }) { if (!(attStmt instanceof Map)) { - throwAttestationInvalid('tpm: attStmt is not a CBOR map'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt is not a CBOR map'); } const ver = attStmt.get('ver'); const alg = attStmt.get('alg'); @@ -357,24 +397,27 @@ export function verifyTpm({ attStmt, authDataBytes, clientDataHash, attestedCred const certInfo = attStmt.get('certInfo'); if (ver !== '2.0') { - throwAttestationInvalid(`tpm: attStmt.ver must be "2.0" (got "${ver}")`); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, `tpm: attStmt.ver must be "2.0" (got "${ver}")`); } if (typeof alg !== 'number') { - throwAttestationInvalid('tpm: attStmt.alg missing or not an integer'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.alg missing or not an integer'); } if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { - throwAttestationInvalid('tpm: attStmt.sig missing or empty'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.sig missing or empty'); } if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { - throwAttestationInvalid('tpm: attStmt.x5c must be a non-empty array'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { if (!(c instanceof Uint8Array)) { - throwAttestationInvalid('tpm: attStmt.x5c entries must be byte strings'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.x5c entries must be byte strings'); } } if (!(pubArea instanceof Uint8Array) || !(certInfo instanceof Uint8Array)) { - throwAttestationInvalid('tpm: attStmt.pubArea and attStmt.certInfo must be byte strings'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: attStmt.pubArea and attStmt.certInfo must be byte strings', + ); } const algParams = algorithmForId(alg); @@ -392,24 +435,33 @@ export function verifyTpm({ attStmt, authDataBytes, clientDataHash, attestedCred const attToBeSigned = concat(authDataBytes, clientDataHash); const digestAlg = (algParams.nodeAlgorithm ?? '').replace(/^RSA-/, '').toLowerCase(); if (!digestAlg) { - throwAttestationInvalid(`tpm: signature alg ${algParams.name} has no separate digest for extraData check`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `tpm: signature alg ${algParams.name} has no separate digest for extraData check`, + ); } const expectedExtraData = createHash(digestAlg).update(attToBeSigned).digest(); if (!bytesEqual(new Uint8Array(expectedExtraData), parsedCert.extraData)) { - throwAttestationInvalid('tpm: certInfo.extraData does not equal hash(authData || clientDataHash)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: certInfo.extraData does not equal hash(authData || clientDataHash)', + ); } // 4. attested.name check. const expectedName = computeName(pubArea, parsedPub.nameAlg, parsedPub.nameAlgTpm); if (!bytesEqual(expectedName, parsedCert.name)) { - throwAttestationInvalid('tpm: certInfo.attested.name does not equal nameAlg || Hash_nameAlg(pubArea)'); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + 'tpm: certInfo.attested.name does not equal nameAlg || Hash_nameAlg(pubArea)', + ); } // 5. Signature verifies with AIK leaf public key over certInfo bytes. const chain = x5cRaw.map(der => new X509Certificate(der)); const leaf = chain[0]; if (!verifySignature(leaf.publicKey, algParams, certInfo, sig)) { - throwSignatureInvalid('tpm: signature does not verify against AIK leaf certificate'); + throw new PasskeyError(ErrorCode.SIGNATURE_INVALID, 'tpm: signature does not verify against AIK leaf certificate'); } // 6. AIK cert profile. @@ -422,7 +474,7 @@ export function verifyTpm({ attStmt, authDataBytes, clientDataHash, attestedCred verifyChain({ x5c: chain, trustAnchors: toCertificates(trustAnchors) }); trustPath = 'trust-anchor'; } catch (err) { - throwAttestationTrustAnchorMissing(`tpm: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `tpm: ${err.message}`); } } diff --git a/packages/passkey/src/authentication/begin.js b/packages/passkey/src/authentication/begin.js index 4132ff1..b7d3a55 100644 --- a/packages/passkey/src/authentication/begin.js +++ b/packages/passkey/src/authentication/begin.js @@ -7,7 +7,7 @@ import { base64url } from '@exortek/crypto/encode'; import { issuePasskeyChallenge } from '../internal/challenge.js'; import { buildAuthenticationExtensions } from '../webauthn/extensions.js'; -import { throwInvalidArgument } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const HINT_VALUES = new Set(['security-key', 'client-device', 'hybrid']); const UV_VALUES = new Set(['required', 'preferred', 'discouraged']); @@ -33,30 +33,37 @@ const UV_VALUES = new Set(['required', 'preferred', 'discouraged']); */ export async function begin(params) { if (!params || typeof params !== 'object') { - throwInvalidArgument('authentication.begin: options object required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: options object required'); } const rpId = params.rpId; if (!rpId || (typeof rpId !== 'string' && !Array.isArray(rpId))) { - throwInvalidArgument('authentication.begin: rpId (string or string[]) is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: rpId (string or string[]) is required'); } if (!params.challengeSecret) { - throwInvalidArgument('authentication.begin: challengeSecret is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: challengeSecret is required'); } if (!params.challengeStore || typeof params.challengeStore.incr !== 'function') { - throwInvalidArgument('authentication.begin: challengeStore (an IncrStore) is required'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'authentication.begin: challengeStore (an IncrStore) is required', + ); } if (params.userVerification !== undefined && !UV_VALUES.has(params.userVerification)) { - throwInvalidArgument( + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, `authentication.begin: userVerification must be 'required' | 'preferred' | 'discouraged' (got "${params.userVerification}")`, ); } if (params.hints !== undefined) { if (!Array.isArray(params.hints)) { - throwInvalidArgument('authentication.begin: hints must be an array'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: hints must be an array'); } for (const h of params.hints) { if (!HINT_VALUES.has(h)) { - throwInvalidArgument(`authentication.begin: hint "${h}" not one of security-key | client-device | hybrid`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `authentication.begin: hint "${h}" not one of security-key | client-device | hybrid`, + ); } } } diff --git a/packages/passkey/src/authentication/finish.js b/packages/passkey/src/authentication/finish.js index 80b562f..9a235b2 100644 --- a/packages/passkey/src/authentication/finish.js +++ b/packages/passkey/src/authentication/finish.js @@ -25,24 +25,22 @@ import { readClientExtensionResults, readAuthenticatorExtensions } from '../weba import { importCoseKey, algorithmForId } from '../cose/key.js'; import { consumePasskeyChallenge } from '../internal/challenge.js'; import { concat } from '../internal/bytes.js'; -import { - throwInvalidArgument, - throwClientDataInvalid, - throwOriginMismatch, - throwRpIdMismatch, - throwSignatureInvalid, - throwCounterRollback, - throwPublicKeyUnsupported, -} from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; function decodeB64uField(value, field) { if (typeof value !== 'string' || value.length === 0) { - throwInvalidArgument(`authentication.finish: response.${field} must be a base64url string`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `authentication.finish: response.${field} must be a base64url string`, + ); } try { return new Uint8Array(base64url.decode(value)); } catch (err) { - throwInvalidArgument(`authentication.finish: response.${field} is not valid base64url (${err.message})`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `authentication.finish: response.${field} is not valid base64url (${err.message})`, + ); } } @@ -59,11 +57,15 @@ function importCredentialKey(credential) { try { imported = importCoseKey(credential.publicKeyCose); } catch (err) { - throwPublicKeyUnsupported(`authentication.finish: stored credential public key is unusable (${err.message})`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `authentication.finish: stored credential public key is unusable (${err.message})`, + ); } return { publicKey: imported.publicKey, algorithm: imported.algorithm }; } - throwInvalidArgument( + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, 'authentication.finish: credential must expose either `publicKey` (Node KeyObject) or `publicKeyCose` (COSE Map)', ); } @@ -105,7 +107,7 @@ function importCredentialKey(credential) { */ export async function finish(params) { if (!params || typeof params !== 'object') { - throwInvalidArgument('authentication.finish: options object required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.finish: options object required'); } const { response, @@ -124,21 +126,33 @@ export async function finish(params) { } = params; if (!response || typeof response !== 'object' || !response.response) { - throwInvalidArgument('authentication.finish: response must be a WebAuthn PublicKeyCredential-shaped object'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'authentication.finish: response must be a WebAuthn PublicKeyCredential-shaped object', + ); } if (response.type !== undefined && response.type !== 'public-key') { - throwInvalidArgument(`authentication.finish: response.type must be 'public-key' (got "${response.type}")`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `authentication.finish: response.type must be 'public-key' (got "${response.type}")`, + ); } // `id` is the base64url of `rawId`; reject a client that disagrees // with itself (matches SimpleWebAuthn). Only when both are present. if (typeof response.id === 'string' && typeof response.rawId === 'string' && response.id !== response.rawId) { - throwInvalidArgument('authentication.finish: response.id must equal response.rawId (base64url mismatch)'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'authentication.finish: response.id must equal response.rawId (base64url mismatch)', + ); } if (!challengeToken) { - throwInvalidArgument('authentication.finish: challengeToken is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.finish: challengeToken is required'); } if (!credential || typeof credential !== 'object' || typeof credential.counter !== 'number') { - throwInvalidArgument('authentication.finish: credential must include the stored counter (number)'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'authentication.finish: credential must include the stored counter (number)', + ); } const clientDataJSON = decodeB64uField(response.response.clientDataJSON, 'response.clientDataJSON'); @@ -152,16 +166,23 @@ export async function finish(params) { try { clientData = parseClientData(clientDataJSON); } catch (err) { - throwClientDataInvalid(`authentication.finish: ${err.message}`); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, `authentication.finish: ${err.message}`); } if (clientData.type !== 'webauthn.get') { - throwClientDataInvalid(`authentication.finish: clientData.type must be "webauthn.get" (got "${clientData.type}")`); + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, + `authentication.finish: clientData.type must be "webauthn.get" (got "${clientData.type}")`, + ); } if (!matchesOrigin(clientData.origin, expectedOrigin)) { - throwOriginMismatch(`authentication.finish: origin "${clientData.origin}" not in expectedOrigin`); + throw new PasskeyError( + ErrorCode.ORIGIN_MISMATCH, + `authentication.finish: origin "${clientData.origin}" not in expectedOrigin`, + ); } if (clientData.crossOrigin && !allowCrossOriginCeremony) { - throwClientDataInvalid( + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, 'authentication.finish: clientDataJSON.crossOrigin=true — set allowCrossOriginCeremony to accept cross-origin ceremonies', ); } @@ -180,7 +201,10 @@ export async function finish(params) { const rpMatch = matchRpId(authData.rpIdHash, expectedRpId); if (!rpMatch) { - throwRpIdMismatch('authentication.finish: rpIdHash does not match any expected RP ID'); + throw new PasskeyError( + ErrorCode.RP_ID_MISMATCH, + 'authentication.finish: rpIdHash does not match any expected RP ID', + ); } enforceFlags(authData.flags, { requireUserVerification, requireBackupEligible, requireBackedUp }); @@ -201,7 +225,10 @@ export async function finish(params) { ok = v.verify({ key: publicKey, ...algParams.verifyOptions }, signature); } if (!ok) { - throwSignatureInvalid('authentication.finish: signature does not verify against stored credential public key'); + throw new PasskeyError( + ErrorCode.SIGNATURE_INVALID, + 'authentication.finish: signature does not verify against stored credential public key', + ); } // Counter monotonicity (WebAuthn L3 §7.2 step 21). Some @@ -211,7 +238,8 @@ export async function finish(params) { if (authData.signCount === 0 && credential.counter === 0) { // OK — permitted no-counter case. } else if (authData.signCount <= credential.counter) { - throwCounterRollback( + throw new PasskeyError( + ErrorCode.COUNTER_ROLLBACK, `authentication.finish: counter did not increase (stored=${credential.counter}, received=${authData.signCount}) — possible cloned authenticator`, ); } diff --git a/packages/passkey/src/cbor/decode.js b/packages/passkey/src/cbor/decode.js index 4f2d088..9b649a3 100644 --- a/packages/passkey/src/cbor/decode.js +++ b/packages/passkey/src/cbor/decode.js @@ -31,6 +31,8 @@ * - half / single / double floats → `number` */ +import { PasskeyError, ErrorCode } from '../errors.js'; + class Cursor { /** * @param {Uint8Array} bytes @@ -43,7 +45,10 @@ class Cursor { need(n) { if (this.pos + n > this.bytes.byteLength) { - throw new Error(`cbor: unexpected end of input (want ${n} bytes at offset ${this.pos})`); + throw new PasskeyError( + ErrorCode.DECODE_ERROR, + `cbor: unexpected end of input (want ${n} bytes at offset ${this.pos})`, + ); } } @@ -143,9 +148,9 @@ function readArgument(c, info) { return c.readU64(); } if (info === 31) { - throw new Error('cbor: indefinite-length items are not supported'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'cbor: indefinite-length items are not supported'); } - throw new Error(`cbor: reserved additional-info value ${info}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: reserved additional-info value ${info}`); } /** @@ -177,7 +182,7 @@ const MAX_DEPTH = 32; */ function readItem(c, depth = 0) { if (depth > MAX_DEPTH) { - throw new Error(`cbor: nesting depth exceeds ${MAX_DEPTH}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: nesting depth exceeds ${MAX_DEPTH}`); } const initial = c.readU8(); const major = initial >> 5; @@ -201,7 +206,7 @@ function readItem(c, depth = 0) { // Byte string. const len = readArgument(c, info); if (typeof len === 'bigint') { - throw new Error(`cbor: byte string length ${len} exceeds Number.MAX_SAFE_INTEGER`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: byte string length ${len} exceeds Number.MAX_SAFE_INTEGER`); } return c.readBytes(len); } @@ -210,7 +215,7 @@ function readItem(c, depth = 0) { // Text string — decoded strictly (invalid UTF-8 throws). const len = readArgument(c, info); if (typeof len === 'bigint') { - throw new Error(`cbor: text string length ${len} exceeds Number.MAX_SAFE_INTEGER`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: text string length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const raw = c.readBytes(len); return new TextDecoder('utf-8', { fatal: true }).decode(raw); @@ -220,7 +225,7 @@ function readItem(c, depth = 0) { // Array. const len = readArgument(c, info); if (typeof len === 'bigint') { - throw new Error(`cbor: array length ${len} exceeds Number.MAX_SAFE_INTEGER`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: array length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const out = new Array(len); for (let i = 0; i < len; i += 1) { @@ -233,7 +238,7 @@ function readItem(c, depth = 0) { // Map — return as `Map` so int keys survive. const len = readArgument(c, info); if (typeof len === 'bigint') { - throw new Error(`cbor: map length ${len} exceeds Number.MAX_SAFE_INTEGER`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: map length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const out = new Map(); for (let i = 0; i < len; i += 1) { @@ -243,7 +248,7 @@ function readItem(c, depth = 0) { // Duplicate keys are legal CBOR but not deterministic — for a // security-critical parser we reject rather than silently pick // one, matching the RFC 8949 §5.6 "strict decoder" guidance. - throw new Error(`cbor: duplicate map key ${String(key)}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: duplicate map key ${String(key)}`); } out.set(key, value); } @@ -251,12 +256,12 @@ function readItem(c, depth = 0) { } if (major === 6) { - throw new Error(`cbor: tags (major type 6) are not supported`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: tags (major type 6) are not supported`); } // Major type 7 — floats + simple values. if (info < 20) { - throw new Error(`cbor: reserved simple value ${info}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: reserved simple value ${info}`); } if (info === 20) { return false; @@ -274,7 +279,7 @@ function readItem(c, depth = 0) { const simple = c.readU8(); // Simple values 32–255 are unassigned; 0–19 and 32+ are legal // slots but WebAuthn never uses them. - throw new Error(`cbor: unsupported simple value ${simple}`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: unsupported simple value ${simple}`); } if (info === 25) { return c.readF16(); @@ -285,7 +290,7 @@ function readItem(c, depth = 0) { if (info === 27) { return c.readF64(); } - throw new Error(`cbor: reserved additional-info value ${info} in major type 7`); + throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: reserved additional-info value ${info} in major type 7`); } /** @@ -298,12 +303,15 @@ function readItem(c, depth = 0) { */ export function decode(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('cbor.decode: expected Uint8Array'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'cbor.decode: expected Uint8Array'); } const c = new Cursor(bytes); const value = readItem(c); if (c.pos !== bytes.byteLength) { - throw new Error(`cbor: ${bytes.byteLength - c.pos} trailing byte(s) after top-level item`); + throw new PasskeyError( + ErrorCode.DECODE_ERROR, + `cbor: ${bytes.byteLength - c.pos} trailing byte(s) after top-level item`, + ); } return value; } @@ -319,7 +327,7 @@ export function decode(bytes) { */ export function decodeWithLength(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('cbor.decodeWithLength: expected Uint8Array'); + throw new PasskeyError(ErrorCode.DECODE_ERROR, 'cbor.decodeWithLength: expected Uint8Array'); } const c = new Cursor(bytes); const value = readItem(c); diff --git a/packages/passkey/src/cose/key.js b/packages/passkey/src/cose/key.js index 55dcbe3..2ac0bab 100644 --- a/packages/passkey/src/cose/key.js +++ b/packages/passkey/src/cose/key.js @@ -13,6 +13,7 @@ import { createPublicKey } from 'node:crypto'; import { base64url } from '@exortek/crypto/encode'; +import { PasskeyError, ErrorCode } from '../errors.js'; // COSE common labels (RFC 8152 §7.1) const LABEL = /** @type {const} */ ({ @@ -157,18 +158,18 @@ function b64u(bytes) { */ export function importCoseKey(coseKey) { if (!(coseKey instanceof Map)) { - throw new Error('cose: expected a Map (decoded COSE Key)'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'cose: expected a Map (decoded COSE Key)'); } const kty = coseKey.get(LABEL.KTY); const alg = coseKey.get(LABEL.ALG); if (typeof alg !== 'number') { - throw new Error('cose: missing or non-integer alg (label 3)'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'cose: missing or non-integer alg (label 3)'); } const params = ALGORITHMS[String(alg)]; if (!params) { - throw new Error(`cose: unsupported algorithm ${alg}`); + throw new PasskeyError(ErrorCode.UNSUPPORTED_ALGORITHM, `cose: unsupported algorithm ${alg}`); } let jwk; @@ -179,7 +180,10 @@ export function importCoseKey(coseKey) { } else if (kty === KTY.RSA) { jwk = rsaToJwk(coseKey); } else { - throw new Error(`cose: unsupported kty ${String(kty)} (want 1=OKP, 2=EC2, 3=RSA)`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `cose: unsupported kty ${String(kty)} (want 1=OKP, 2=EC2, 3=RSA)`, + ); } // Node's JWK importer performs its own parameter checks — a @@ -194,19 +198,25 @@ function ec2ToJwk(coseKey, params) { const crvId = coseKey.get(EC2.CRV); const crv = EC_CURVE[crvId]; if (!crv) { - throw new Error(`cose EC2: unsupported curve ${String(crvId)}`); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, `cose EC2: unsupported curve ${String(crvId)}`); } if (params.curve && params.curve !== crv) { - throw new Error(`cose EC2: curve ${crv} does not match algorithm ${params.name} (expects ${params.curve})`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `cose EC2: curve ${crv} does not match algorithm ${params.name} (expects ${params.curve})`, + ); } const x = coseKey.get(EC2.X); const y = coseKey.get(EC2.Y); if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array)) { - throw new Error('cose EC2: x and y must be byte strings (compressed points not supported)'); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + 'cose EC2: x and y must be byte strings (compressed points not supported)', + ); } const size = crv === 'P-256' ? 32 : crv === 'P-384' ? 48 : 66; if (x.byteLength !== size || y.byteLength !== size) { - throw new Error(`cose EC2: ${crv} coordinates must be ${size} bytes each`); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, `cose EC2: ${crv} coordinates must be ${size} bytes each`); } return { kty: 'EC', crv, x: b64u(x), y: b64u(y) }; } @@ -215,23 +225,26 @@ function okpToJwk(coseKey, params) { const crvId = coseKey.get(OKP.CRV); const crv = OKP_CURVE[crvId]; if (!crv) { - throw new Error(`cose OKP: unsupported curve ${String(crvId)}`); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, `cose OKP: unsupported curve ${String(crvId)}`); } // Only signature curves land in WebAuthn credential keys. if (crv !== 'Ed25519' && crv !== 'Ed448') { - throw new Error(`cose OKP: curve ${crv} is not a signature curve`); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, `cose OKP: curve ${crv} is not a signature curve`); } if (params.curve && params.curve !== crv) { - throw new Error(`cose OKP: curve ${crv} does not match algorithm ${params.name} (expects ${params.curve})`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `cose OKP: curve ${crv} does not match algorithm ${params.name} (expects ${params.curve})`, + ); } const x = coseKey.get(OKP.X); if (!(x instanceof Uint8Array)) { - throw new Error('cose OKP: x must be a byte string'); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, 'cose OKP: x must be a byte string'); } // Ed25519 = 32 bytes; Ed448 = 57 bytes. const expected = crv === 'Ed25519' ? 32 : 57; if (x.byteLength !== expected) { - throw new Error(`cose OKP: ${crv} public key must be ${expected} bytes`); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, `cose OKP: ${crv} public key must be ${expected} bytes`); } return { kty: 'OKP', crv, x: b64u(x) }; } @@ -240,10 +253,13 @@ function rsaToJwk(coseKey) { const n = coseKey.get(RSA.N); const e = coseKey.get(RSA.E); if (!(n instanceof Uint8Array) || !(e instanceof Uint8Array)) { - throw new Error('cose RSA: n and e must be byte strings'); + throw new PasskeyError(ErrorCode.PUBLIC_KEY_UNSUPPORTED, 'cose RSA: n and e must be byte strings'); } if (n.byteLength < MIN_RSA_MODULUS_BYTES) { - throw new Error(`cose RSA: modulus is ${n.byteLength * 8} bits, minimum ${MIN_RSA_MODULUS_BYTES * 8}`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `cose RSA: modulus is ${n.byteLength * 8} bits, minimum ${MIN_RSA_MODULUS_BYTES * 8}`, + ); } // WebAuthn RSA keys are unsigned big-endian integers — Node's JWK // importer expects them stripped of a leading zero pad if present, @@ -260,7 +276,7 @@ function rsaToJwk(coseKey) { export function algorithmForId(algId) { const params = ALGORITHMS[String(algId)]; if (!params) { - throw new Error(`cose: unsupported algorithm ${algId}`); + throw new PasskeyError(ErrorCode.UNSUPPORTED_ALGORITHM, `cose: unsupported algorithm ${algId}`); } return params; } diff --git a/packages/passkey/src/errors.js b/packages/passkey/src/errors.js index 6b8db10..2673fc7 100644 --- a/packages/passkey/src/errors.js +++ b/packages/passkey/src/errors.js @@ -33,6 +33,9 @@ export const ErrorCode = Object.freeze({ // Extensions / MDS EXTENSION_INVALID: 'EXTENSION_INVALID', MDS_BLOB_INVALID: 'MDS_BLOB_INVALID', + + // Low-level decoding (CBOR / ASN.1 DER) + DECODE_ERROR: 'DECODE_ERROR', }); export class PasskeyError extends BaseError { @@ -59,40 +62,7 @@ export class PasskeyError extends BaseError { [ErrorCode.UNSUPPORTED_ATTESTATION_FORMAT]: 400, [ErrorCode.EXTENSION_INVALID]: 400, [ErrorCode.MDS_BLOB_INVALID]: 400, + [ErrorCode.DECODE_ERROR]: 400, }; static defaultStatus = 500; } - -// Factory helpers — every ErrorCode has one to enforce -// "no dead codes" per feedback_error_code_must_actually_throw. -const factory = code => (message, options) => new PasskeyError(code, message, options); -const throwFactory = code => (message, options) => { - throw new PasskeyError(code, message, options); -}; - -export const throwInvalidArgument = throwFactory(ErrorCode.INVALID_ARGUMENT); -export const throwChallengeMismatch = throwFactory(ErrorCode.CHALLENGE_MISMATCH); -export const throwChallengeExpired = throwFactory(ErrorCode.CHALLENGE_EXPIRED); -export const throwChallengeAlreadyUsed = throwFactory(ErrorCode.CHALLENGE_ALREADY_USED); -export const throwChallengeInvalid = throwFactory(ErrorCode.CHALLENGE_INVALID); -export const throwOriginMismatch = throwFactory(ErrorCode.ORIGIN_MISMATCH); -export const throwRpIdMismatch = throwFactory(ErrorCode.RP_ID_MISMATCH); -export const throwClientDataInvalid = throwFactory(ErrorCode.CLIENT_DATA_INVALID); -export const throwAuthDataInvalid = throwFactory(ErrorCode.AUTH_DATA_INVALID); -export const throwUserVerificationRequired = throwFactory(ErrorCode.USER_VERIFICATION_REQUIRED); -export const throwUserPresenceRequired = throwFactory(ErrorCode.USER_PRESENCE_REQUIRED); -export const throwBackupEligibleRequired = throwFactory(ErrorCode.BACKUP_ELIGIBLE_REQUIRED); -export const throwBackedUpRequired = throwFactory(ErrorCode.BACKED_UP_REQUIRED); -export const throwSignatureInvalid = throwFactory(ErrorCode.SIGNATURE_INVALID); -export const throwCounterRollback = throwFactory(ErrorCode.COUNTER_ROLLBACK); -export const throwPublicKeyUnsupported = throwFactory(ErrorCode.PUBLIC_KEY_UNSUPPORTED); -export const throwUnsupportedAlgorithm = throwFactory(ErrorCode.UNSUPPORTED_ALGORITHM); -export const throwAttestationInvalid = throwFactory(ErrorCode.ATTESTATION_INVALID); -export const throwAttestationTrustAnchorMissing = throwFactory(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING); -export const throwUnsupportedAttestationFormat = throwFactory(ErrorCode.UNSUPPORTED_ATTESTATION_FORMAT); -export const throwExtensionInvalid = throwFactory(ErrorCode.EXTENSION_INVALID); -export const throwMdsBlobInvalid = throwFactory(ErrorCode.MDS_BLOB_INVALID); - -// Non-throwing constructors, for cases where the caller needs to -// attach a `cause` / `details` before throwing. -export const invalidArgument = factory(ErrorCode.INVALID_ARGUMENT); diff --git a/packages/passkey/src/internal/challenge.js b/packages/passkey/src/internal/challenge.js index 761e654..f0412c0 100644 --- a/packages/passkey/src/internal/challenge.js +++ b/packages/passkey/src/internal/challenge.js @@ -14,12 +14,7 @@ import { base64url } from '@exortek/crypto/encode'; import { createChallenge, verifyChallenge } from '@exortek/challenge'; -import { - throwChallengeInvalid, - throwChallengeExpired, - throwChallengeAlreadyUsed, - throwChallengeMismatch, -} from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * Decode a challenge-lib token's payload without verifying the MAC. @@ -33,17 +28,23 @@ import { export function readIssuedJti(token) { const parts = token.split('.'); if (parts.length !== 3) { - throwChallengeInvalid('passkey: challenge token has wrong shape (expected ..)'); + throw new PasskeyError( + ErrorCode.CHALLENGE_INVALID, + 'passkey: challenge token has wrong shape (expected ..)', + ); } let payload; try { const raw = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(base64url.decode(parts[1]))); payload = JSON.parse(raw); } catch (err) { - throwChallengeInvalid(`passkey: challenge token payload not decodable (${err.message})`); + throw new PasskeyError( + ErrorCode.CHALLENGE_INVALID, + `passkey: challenge token payload not decodable (${err.message})`, + ); } if (!payload || typeof payload.jti !== 'string' || payload.jti.length === 0) { - throwChallengeInvalid('passkey: challenge token payload missing jti'); + throw new PasskeyError(ErrorCode.CHALLENGE_INVALID, 'passkey: challenge token payload missing jti'); } return { jti: payload.jti }; } @@ -105,14 +106,17 @@ export async function consumePasskeyChallenge(options) { }); if (!result.valid) { if (result.reason === 'expired') { - throwChallengeExpired(`passkey: challenge expired`); + throw new PasskeyError(ErrorCode.CHALLENGE_EXPIRED, `passkey: challenge expired`); } if (result.reason === 'replay') { - throwChallengeAlreadyUsed(`passkey: challenge already used`); + throw new PasskeyError(ErrorCode.CHALLENGE_ALREADY_USED, `passkey: challenge already used`); } - throwChallengeInvalid(`passkey: challenge verify failed (${result.reason})`); + throw new PasskeyError(ErrorCode.CHALLENGE_INVALID, `passkey: challenge verify failed (${result.reason})`); } if (result.payload.jti !== challengeBase64UrlFromClient) { - throwChallengeMismatch(`passkey: challenge in clientDataJSON does not match issued challenge`); + throw new PasskeyError( + ErrorCode.CHALLENGE_MISMATCH, + `passkey: challenge in clientDataJSON does not match issued challenge`, + ); } } diff --git a/packages/passkey/src/mds.js b/packages/passkey/src/mds.js index 4dae81c..84e76c6 100644 --- a/packages/passkey/src/mds.js +++ b/packages/passkey/src/mds.js @@ -22,13 +22,13 @@ import { createVerify, X509Certificate } from 'node:crypto'; import { base64url } from '@exortek/crypto/encode'; import { verifyChain, toCertificates } from './x509/chain.js'; -import { throwMdsBlobInvalid, throwAttestationTrustAnchorMissing } from './errors.js'; +import { PasskeyError, ErrorCode } from './errors.js'; function decodeBase64UrlBytes(str, label) { try { return new Uint8Array(base64url.decode(str)); } catch (err) { - throwMdsBlobInvalid(`mds: ${label} is not valid base64url (${err.message})`); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: ${label} is not valid base64url (${err.message})`); } } @@ -38,12 +38,12 @@ function decodeJsonSegment(str, label) { try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (err) { - throwMdsBlobInvalid(`mds: ${label} is not valid UTF-8 (${err.message})`); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: ${label} is not valid UTF-8 (${err.message})`); } try { return JSON.parse(text); } catch (err) { - throwMdsBlobInvalid(`mds: ${label} is not valid JSON (${err.message})`); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: ${label} is not valid JSON (${err.message})`); } } @@ -68,14 +68,14 @@ function decodeJsonSegment(str, label) { */ export function verifyMdsBlob(jwsCompact, options) { if (typeof jwsCompact !== 'string' || jwsCompact.length === 0) { - throwMdsBlobInvalid('mds: jwsCompact must be a non-empty string'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: jwsCompact must be a non-empty string'); } if (!options || !Array.isArray(options.rootAnchors) || options.rootAnchors.length === 0) { - throwMdsBlobInvalid('mds: rootAnchors (non-empty array) is required'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: rootAnchors (non-empty array) is required'); } const parts = jwsCompact.split('.'); if (parts.length !== 3) { - throwMdsBlobInvalid('mds: JWS must have three segments'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: JWS must have three segments'); } const [headerB64, payloadB64, sigB64] = parts; const header = decodeJsonSegment(headerB64, 'header'); @@ -83,16 +83,16 @@ export function verifyMdsBlob(jwsCompact, options) { const sig = decodeBase64UrlBytes(sigB64, 'signature'); if (header.alg !== 'RS256' && header.alg !== 'ES256') { - throwMdsBlobInvalid(`mds: header.alg must be RS256 or ES256 (got "${header.alg}")`); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: header.alg must be RS256 or ES256 (got "${header.alg}")`); } if (!Array.isArray(header.x5c) || header.x5c.length === 0) { - throwMdsBlobInvalid('mds: header.x5c must be a non-empty array'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: header.x5c must be a non-empty array'); } const chain = header.x5c.map((b64, i) => { try { return new X509Certificate(Buffer.from(b64, 'base64')); } catch (err) { - throwMdsBlobInvalid(`mds: x5c[${i}] not a valid certificate (${err.message})`); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: x5c[${i}] not a valid certificate (${err.message})`); return null; } }); @@ -105,7 +105,7 @@ export function verifyMdsBlob(jwsCompact, options) { v.update(signingInput); const opts = header.alg === 'ES256' ? { key: leaf.publicKey, dsaEncoding: 'der' } : leaf.publicKey; if (!v.verify(opts, sig)) { - throwMdsBlobInvalid('mds: JWS signature does not verify against leaf certificate'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: JWS signature does not verify against leaf certificate'); } // Chain terminates at an RP-supplied root. @@ -116,14 +116,14 @@ export function verifyMdsBlob(jwsCompact, options) { now: options.now ? new Date(options.now) : undefined, }); } catch (err) { - throwAttestationTrustAnchorMissing(`mds: ${err.message}`); + throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `mds: ${err.message}`); } if (!payload || typeof payload !== 'object' || !Array.isArray(payload.entries)) { - throwMdsBlobInvalid('mds: payload.entries must be an array'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: payload.entries must be an array'); } if (typeof payload.no !== 'number') { - throwMdsBlobInvalid('mds: payload.no must be a number'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: payload.no must be a number'); } // nextUpdate is a plain YYYY-MM-DD string. We do not enforce // freshness ourselves — callers who care compare Date.now() to it. @@ -139,7 +139,7 @@ export function verifyMdsBlob(jwsCompact, options) { */ export function buildAaguidIndex(mdsPayload) { if (!mdsPayload || !Array.isArray(mdsPayload.entries)) { - throwMdsBlobInvalid('mds: buildAaguidIndex requires a payload with .entries'); + throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: buildAaguidIndex requires a payload with .entries'); } const out = {}; for (const entry of mdsPayload.entries) { diff --git a/packages/passkey/src/registration/begin.js b/packages/passkey/src/registration/begin.js index a916126..2bdc857 100644 --- a/packages/passkey/src/registration/begin.js +++ b/packages/passkey/src/registration/begin.js @@ -15,7 +15,7 @@ import { base64url } from '@exortek/crypto/encode'; import { issuePasskeyChallenge } from '../internal/challenge.js'; import { buildRegistrationExtensions } from '../webauthn/extensions.js'; import { DEFAULT_SUPPORTED_ALGORITHMS } from '../cose/key.js'; -import { throwInvalidArgument } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; const HINT_VALUES = new Set(['security-key', 'client-device', 'hybrid']); const ATTESTATION_VALUES = new Set(['none', 'direct', 'enterprise']); @@ -42,26 +42,30 @@ const ATTESTATION_VALUES = new Set(['none', 'direct', 'enterprise']); */ export async function begin(params) { if (!params || typeof params !== 'object') { - throwInvalidArgument('registration.begin: options object required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: options object required'); } const rp = params.rp; const user = params.user; if (!rp || typeof rp.id !== 'string' || typeof rp.name !== 'string') { - throwInvalidArgument('registration.begin: rp.id and rp.name are required strings'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: rp.id and rp.name are required strings'); } if (!user || typeof user.id !== 'string' || typeof user.name !== 'string' || typeof user.displayName !== 'string') { - throwInvalidArgument('registration.begin: user.id / .name / .displayName are required strings'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'registration.begin: user.id / .name / .displayName are required strings', + ); } if (!params.challengeSecret) { - throwInvalidArgument('registration.begin: challengeSecret is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: challengeSecret is required'); } if (!params.challengeStore || typeof params.challengeStore.incr !== 'function') { - throwInvalidArgument('registration.begin: challengeStore (an IncrStore) is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: challengeStore (an IncrStore) is required'); } const attestation = params.attestation ?? 'none'; if (!ATTESTATION_VALUES.has(attestation)) { - throwInvalidArgument( + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, `registration.begin: attestation must be 'none' | 'direct' | 'enterprise' (got "${attestation}")`, ); } @@ -69,11 +73,14 @@ export async function begin(params) { const hints = params.hints; if (hints !== undefined) { if (!Array.isArray(hints)) { - throwInvalidArgument('registration.begin: hints must be an array'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: hints must be an array'); } for (const h of hints) { if (!HINT_VALUES.has(h)) { - throwInvalidArgument(`registration.begin: hint "${h}" is not one of security-key | client-device | hybrid`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `registration.begin: hint "${h}" is not one of security-key | client-device | hybrid`, + ); } } } diff --git a/packages/passkey/src/registration/finish.js b/packages/passkey/src/registration/finish.js index 2bba4c6..23d0dbb 100644 --- a/packages/passkey/src/registration/finish.js +++ b/packages/passkey/src/registration/finish.js @@ -25,16 +25,7 @@ import { readClientExtensionResults, readAuthenticatorExtensions } from '../weba import { importCoseKey, DEFAULT_SUPPORTED_ALGORITHMS } from '../cose/key.js'; import { consumePasskeyChallenge } from '../internal/challenge.js'; import { getVerifier } from '../attestation/index.js'; -import { - throwInvalidArgument, - throwClientDataInvalid, - throwOriginMismatch, - throwRpIdMismatch, - throwAuthDataInvalid, - throwUnsupportedAlgorithm, - throwPublicKeyUnsupported, - throwAttestationTrustAnchorMissing, -} from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * Resolve the per-format options blob a caller supplied via @@ -59,12 +50,18 @@ export function resolveAttestationOptions(fmt, attestationOptions) { function decodeB64uField(value, field) { if (typeof value !== 'string' || value.length === 0) { - throwInvalidArgument(`registration.finish: response.${field} must be a base64url string`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `registration.finish: response.${field} must be a base64url string`, + ); } try { return new Uint8Array(base64url.decode(value)); } catch (err) { - throwInvalidArgument(`registration.finish: response.${field} is not valid base64url (${err.message})`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `registration.finish: response.${field} is not valid base64url (${err.message})`, + ); } } @@ -134,7 +131,7 @@ function decodeB64uField(value, field) { */ export async function finish(params) { if (!params || typeof params !== 'object') { - throwInvalidArgument('registration.finish: options object required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: options object required'); } const { response, @@ -156,25 +153,34 @@ export async function finish(params) { } = params; if (!response || typeof response !== 'object' || !response.response) { - throwInvalidArgument('registration.finish: response must be a WebAuthn PublicKeyCredential-shaped object'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'registration.finish: response must be a WebAuthn PublicKeyCredential-shaped object', + ); } if (response.type !== undefined && response.type !== 'public-key') { - throwInvalidArgument(`registration.finish: response.type must be 'public-key' (got "${response.type}")`); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + `registration.finish: response.type must be 'public-key' (got "${response.type}")`, + ); } // WebAuthn transports `id` as the base64url of `rawId`; a client // that sends mismatched values is malformed (SimpleWebAuthn rejects // the same way). Only enforced when both are present. if (typeof response.id === 'string' && typeof response.rawId === 'string' && response.id !== response.rawId) { - throwInvalidArgument('registration.finish: response.id must equal response.rawId (base64url mismatch)'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'registration.finish: response.id must equal response.rawId (base64url mismatch)', + ); } if (typeof challengeToken !== 'string' || challengeToken.length === 0) { - throwInvalidArgument('registration.finish: challengeToken is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: challengeToken is required'); } if (!expectedRpId) { - throwInvalidArgument('registration.finish: expectedRpId is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: expectedRpId is required'); } if (!expectedOrigin) { - throwInvalidArgument('registration.finish: expectedOrigin is required'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: expectedOrigin is required'); } const clientDataJSON = decodeB64uField(response.response.clientDataJSON, 'response.clientDataJSON'); @@ -185,16 +191,23 @@ export async function finish(params) { try { clientData = parseClientData(clientDataJSON); } catch (err) { - throwClientDataInvalid(`registration.finish: ${err.message}`); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, `registration.finish: ${err.message}`); } if (clientData.type !== 'webauthn.create') { - throwClientDataInvalid(`registration.finish: clientData.type must be "webauthn.create", got "${clientData.type}"`); + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, + `registration.finish: clientData.type must be "webauthn.create", got "${clientData.type}"`, + ); } if (!matchesOrigin(clientData.origin, expectedOrigin)) { - throwOriginMismatch(`registration.finish: origin "${clientData.origin}" not in expectedOrigin`); + throw new PasskeyError( + ErrorCode.ORIGIN_MISMATCH, + `registration.finish: origin "${clientData.origin}" not in expectedOrigin`, + ); } if (clientData.crossOrigin && !allowCrossOriginCeremony) { - throwClientDataInvalid( + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, 'registration.finish: clientDataJSON.crossOrigin=true — set allowCrossOriginCeremony to accept cross-origin ceremonies', ); } @@ -216,33 +229,45 @@ export async function finish(params) { try { attestationObject = decode(attestationObjectBytes); } catch (err) { - throwAuthDataInvalid(`registration.finish: attestationObject CBOR: ${err.message}`); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, `registration.finish: attestationObject CBOR: ${err.message}`); } if (!(attestationObject instanceof Map)) { - throwAuthDataInvalid('registration.finish: attestationObject must be a CBOR map'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'registration.finish: attestationObject must be a CBOR map'); } const fmt = attestationObject.get('fmt'); const authDataBytes = attestationObject.get('authData'); const attStmt = attestationObject.get('attStmt'); if (typeof fmt !== 'string' || fmt.length === 0) { - throwAuthDataInvalid('registration.finish: attestationObject.fmt missing or not a string'); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + 'registration.finish: attestationObject.fmt missing or not a string', + ); } if (!(authDataBytes instanceof Uint8Array)) { - throwAuthDataInvalid('registration.finish: attestationObject.authData missing or not bytes'); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + 'registration.finish: attestationObject.authData missing or not bytes', + ); } if (!(attStmt instanceof Map)) { - throwAuthDataInvalid('registration.finish: attestationObject.attStmt missing or not a CBOR map'); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + 'registration.finish: attestationObject.attStmt missing or not a CBOR map', + ); } const authData = parseAuthData(authDataBytes); if (!authData.attestedCredentialData) { - throwAuthDataInvalid('registration.finish: authenticator data has no attested credential data (AT flag not set)'); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + 'registration.finish: authenticator data has no attested credential data (AT flag not set)', + ); } // RP ID check const rpMatch = matchRpId(authData.rpIdHash, expectedRpId); if (!rpMatch) { - throwRpIdMismatch(`registration.finish: rpIdHash does not match any expected RP ID`); + throw new PasskeyError(ErrorCode.RP_ID_MISMATCH, `registration.finish: rpIdHash does not match any expected RP ID`); } // Flag policy — enforceFlags throws PasskeyError with the specific @@ -253,7 +278,8 @@ export async function finish(params) { // Algorithm allowlist const credAlg = authData.attestedCredentialData.credentialPublicKey.get(3); if (!supportedAlgorithms.includes(credAlg)) { - throwUnsupportedAlgorithm( + throw new PasskeyError( + ErrorCode.UNSUPPORTED_ALGORITHM, `registration.finish: credential algorithm ${credAlg} is not in supportedAlgorithms [${supportedAlgorithms.join(', ')}]`, ); } @@ -280,7 +306,8 @@ export async function finish(params) { // never had a chain to anchor. Opt in with `requireTrustAnchor` to // turn that silent gap into a hard failure. if (requireTrustAnchor && fmt !== 'none' && attestationReport.trustPath === 'no-anchor') { - throwAttestationTrustAnchorMissing( + throw new PasskeyError( + ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `registration.finish: attestation format "${fmt}" was not verified against any trust anchor ` + `(pass trustAnchors["${fmt}"], or drop requireTrustAnchor to accept unanchored attestation)`, ); @@ -292,7 +319,10 @@ export async function finish(params) { try { importedKey = importCoseKey(authData.attestedCredentialData.credentialPublicKey); } catch (err) { - throwPublicKeyUnsupported(`registration.finish: credential public key could not be imported (${err.message})`); + throw new PasskeyError( + ErrorCode.PUBLIC_KEY_UNSUPPORTED, + `registration.finish: credential public key could not be imported (${err.message})`, + ); } return { diff --git a/packages/passkey/src/webauthn/authData.js b/packages/passkey/src/webauthn/authData.js index e2c9630..630c687 100644 --- a/packages/passkey/src/webauthn/authData.js +++ b/packages/passkey/src/webauthn/authData.js @@ -17,6 +17,7 @@ */ import { decodeWithLength } from '../cbor/decode.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; import { decodeFlags } from './flags.js'; const MIN_AUTH_DATA = 37; // rpIdHash 32 + flags 1 + counter 4 @@ -32,7 +33,10 @@ const MAX_CRED_ID_LEN = 1023; // CTAP2 §11.2.2 limit — anything larger is mal */ export function formatAaguid(aaguid) { if (!(aaguid instanceof Uint8Array) || aaguid.byteLength !== AAGUID_LEN) { - throw new Error(`authData: AAGUID must be a 16-byte Uint8Array (got ${aaguid?.byteLength})`); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + `authData: AAGUID must be a 16-byte Uint8Array (got ${aaguid?.byteLength})`, + ); } const hex = Array.from(aaguid, b => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; @@ -70,10 +74,13 @@ export function formatAaguid(aaguid) { */ export function parseAuthData(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('authData: expected Uint8Array'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'authData: expected Uint8Array'); } if (bytes.byteLength < MIN_AUTH_DATA) { - throw new Error(`authData: too short — need at least ${MIN_AUTH_DATA} bytes, got ${bytes.byteLength}`); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + `authData: too short — need at least ${MIN_AUTH_DATA} bytes, got ${bytes.byteLength}`, + ); } const rpIdHash = bytes.subarray(0, 32); @@ -88,7 +95,10 @@ export function parseAuthData(bytes) { if (flags.at) { if (bytes.byteLength < pos + AAGUID_LEN + 2) { - throw new Error('authData: AT flag set but attested credential data is truncated'); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + 'authData: AT flag set but attested credential data is truncated', + ); } const aaguid = bytes.subarray(pos, pos + AAGUID_LEN); pos += AAGUID_LEN; @@ -96,13 +106,19 @@ export function parseAuthData(bytes) { const credIdLen = new DataView(bytes.buffer, bytes.byteOffset + pos, 2).getUint16(0, false); pos += 2; if (credIdLen === 0) { - throw new Error('authData: attested credential data credentialIdLength is 0'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'authData: attested credential data credentialIdLength is 0'); } if (credIdLen > MAX_CRED_ID_LEN) { - throw new Error(`authData: credentialIdLength ${credIdLen} exceeds CTAP2 §11.2.2 max of ${MAX_CRED_ID_LEN}`); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + `authData: credentialIdLength ${credIdLen} exceeds CTAP2 §11.2.2 max of ${MAX_CRED_ID_LEN}`, + ); } if (bytes.byteLength < pos + credIdLen) { - throw new Error(`authData: credentialId declares ${credIdLen} bytes but only ${bytes.byteLength - pos} left`); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + `authData: credentialId declares ${credIdLen} bytes but only ${bytes.byteLength - pos} left`, + ); } const credentialId = bytes.subarray(pos, pos + credIdLen); @@ -112,7 +128,7 @@ export function parseAuthData(bytes) { // decodeWithLength reports how many bytes the key consumed. const { value: pubKeyValue, bytesRead: keyBytes } = decodeWithLength(bytes.subarray(pos)); if (!(pubKeyValue instanceof Map)) { - throw new Error('authData: credentialPublicKey is not a CBOR map'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'authData: credentialPublicKey is not a CBOR map'); } const credentialPublicKey = pubKeyValue; const credentialPublicKeyBytes = bytes.subarray(pos, pos + keyBytes); @@ -130,18 +146,21 @@ export function parseAuthData(bytes) { let extensions = null; if (flags.ed) { if (pos >= bytes.byteLength) { - throw new Error('authData: ED flag set but extensions block missing'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'authData: ED flag set but extensions block missing'); } const { value: extValue, bytesRead: extBytes } = decodeWithLength(bytes.subarray(pos)); if (!(extValue instanceof Map)) { - throw new Error('authData: extensions is not a CBOR map'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'authData: extensions is not a CBOR map'); } extensions = extValue; pos += extBytes; } if (pos !== bytes.byteLength) { - throw new Error(`authData: ${bytes.byteLength - pos} trailing byte(s) after parse`); + throw new PasskeyError( + ErrorCode.AUTH_DATA_INVALID, + `authData: ${bytes.byteLength - pos} trailing byte(s) after parse`, + ); } return { diff --git a/packages/passkey/src/webauthn/clientData.js b/packages/passkey/src/webauthn/clientData.js index fb3269f..cefb4bb 100644 --- a/packages/passkey/src/webauthn/clientData.js +++ b/packages/passkey/src/webauthn/clientData.js @@ -20,6 +20,7 @@ */ import { base64url } from '@exortek/crypto/encode'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * @typedef {'webauthn.create' | 'webauthn.get'} ClientDataType @@ -41,47 +42,53 @@ const VALID_TYPES = new Set(['webauthn.create', 'webauthn.get']); */ export function parseClientData(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('clientData: expected Uint8Array'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: expected Uint8Array'); } let text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (err) { - throw new Error(`clientData: not valid UTF-8 (${err.message})`); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, `clientData: not valid UTF-8 (${err.message})`); } let parsed; try { parsed = JSON.parse(text); } catch (err) { - throw new Error(`clientData: not valid JSON (${err.message})`); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, `clientData: not valid JSON (${err.message})`); } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('clientData: root must be a JSON object'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: root must be a JSON object'); } const { type, challenge, origin, crossOrigin } = parsed; if (!VALID_TYPES.has(type)) { - throw new Error(`clientData: type "${type}" is not "webauthn.create" or "webauthn.get"`); + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, + `clientData: type "${type}" is not "webauthn.create" or "webauthn.get"`, + ); } if (typeof challenge !== 'string' || challenge.length === 0) { - throw new Error('clientData: challenge missing or not a string'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: challenge missing or not a string'); } if (typeof origin !== 'string' || origin.length === 0) { - throw new Error('clientData: origin missing or not a string'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: origin missing or not a string'); } if (crossOrigin !== undefined && typeof crossOrigin !== 'boolean') { - throw new Error('clientData: crossOrigin must be a boolean when present'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: crossOrigin must be a boolean when present'); } let challengeBytes; try { challengeBytes = new Uint8Array(base64url.decode(challenge)); } catch (err) { - throw new Error(`clientData: challenge is not valid base64url (${err.message})`); + throw new PasskeyError( + ErrorCode.CLIENT_DATA_INVALID, + `clientData: challenge is not valid base64url (${err.message})`, + ); } if (challengeBytes.byteLength === 0) { - throw new Error('clientData: challenge decoded to zero bytes'); + throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: challenge decoded to zero bytes'); } return { diff --git a/packages/passkey/src/webauthn/extensions.js b/packages/passkey/src/webauthn/extensions.js index a2f660b..547152f 100644 --- a/packages/passkey/src/webauthn/extensions.js +++ b/packages/passkey/src/webauthn/extensions.js @@ -27,11 +27,11 @@ */ import { base64url } from '@exortek/crypto/encode'; -import { throwExtensionInvalid } from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; function b64u(bytes) { if (!(bytes instanceof Uint8Array)) { - throw new Error('extensions: expected Uint8Array to encode'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions: expected Uint8Array to encode'); } return base64url.encode(bytes); } @@ -68,14 +68,17 @@ export function buildRegistrationExtensions(input = {}) { const out = { ...input }; if (input.credProps !== undefined) { if (input.credProps !== true) { - throw new Error('extensions.credProps must be `true` when present'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.credProps must be `true` when present'); } out.credProps = true; } if (input.largeBlob !== undefined) { const { support } = input.largeBlob; if (support !== 'preferred' && support !== 'required') { - throw new Error("extensions.largeBlob.support must be 'preferred' or 'required'"); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + "extensions.largeBlob.support must be 'preferred' or 'required'", + ); } out.largeBlob = { support }; } @@ -84,21 +87,24 @@ export function buildRegistrationExtensions(input = {}) { } if (input.hmacCreateSecret !== undefined) { if (input.hmacCreateSecret !== true) { - throw new Error('extensions.hmacCreateSecret must be `true` when present'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.hmacCreateSecret must be `true` when present'); } out.hmacCreateSecret = true; } if (input.minPinLength !== undefined) { if (input.minPinLength !== true) { - throw new Error('extensions.minPinLength must be `true` when present'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.minPinLength must be `true` when present'); } out.minPinLength = true; } if (input.credentialProtectionPolicy !== undefined && ![1, 2, 3].includes(input.credentialProtectionPolicy)) { - throw new Error('extensions.credentialProtectionPolicy must be 1, 2, or 3 (CTAP2 §12.1)'); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + 'extensions.credentialProtectionPolicy must be 1, 2, or 3 (CTAP2 §12.1)', + ); } if (input.appidExclude !== undefined && typeof input.appidExclude !== 'string') { - throw new Error('extensions.appidExclude must be a string'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.appidExclude must be a string'); } return out; } @@ -120,7 +126,7 @@ export function buildAuthenticationExtensions(input = {}) { const lb = {}; if (input.largeBlob.read !== undefined) { if (input.largeBlob.read !== true) { - throw new Error('extensions.largeBlob.read must be `true` when present'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.largeBlob.read must be `true` when present'); } lb.read = true; } @@ -128,7 +134,10 @@ export function buildAuthenticationExtensions(input = {}) { lb.write = b64u(input.largeBlob.write); } if (lb.read && lb.write !== undefined) { - throw new Error('extensions.largeBlob: read and write are mutually exclusive per WebAuthn L3 §10.5'); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + 'extensions.largeBlob: read and write are mutually exclusive per WebAuthn L3 §10.5', + ); } out.largeBlob = lb; } @@ -138,19 +147,25 @@ export function buildAuthenticationExtensions(input = {}) { if (input.hmacGetSecret !== undefined) { const { salt1, salt2 } = input.hmacGetSecret; if (!(salt1 instanceof Uint8Array) || salt1.byteLength !== 32) { - throw new Error('extensions.hmacGetSecret.salt1 must be a 32-byte Uint8Array (CTAP2 §12.5)'); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + 'extensions.hmacGetSecret.salt1 must be a 32-byte Uint8Array (CTAP2 §12.5)', + ); } const out2 = { salt1: b64u(salt1) }; if (salt2 !== undefined) { if (!(salt2 instanceof Uint8Array) || salt2.byteLength !== 32) { - throw new Error('extensions.hmacGetSecret.salt2 must be a 32-byte Uint8Array'); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + 'extensions.hmacGetSecret.salt2 must be a 32-byte Uint8Array', + ); } out2.salt2 = b64u(salt2); } out.hmacGetSecret = out2; } if (input.appid !== undefined && typeof input.appid !== 'string') { - throw new Error('extensions.appid must be a string'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.appid must be a string'); } return out; } @@ -172,12 +187,15 @@ function encodePrfInput(prf) { function encodePrfValues({ first, second }) { if (!(first instanceof Uint8Array)) { - throw new Error('extensions.prf.eval.first must be a Uint8Array'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.prf.eval.first must be a Uint8Array'); } const out = { first: b64u(first) }; if (second !== undefined) { if (!(second instanceof Uint8Array)) { - throw new Error('extensions.prf.eval.second must be a Uint8Array when present'); + throw new PasskeyError( + ErrorCode.EXTENSION_INVALID, + 'extensions.prf.eval.second must be a Uint8Array when present', + ); } out.second = b64u(second); } @@ -205,7 +223,7 @@ export function readClientExtensionResults(results) { return {}; } if (typeof results !== 'object' || Array.isArray(results)) { - throwExtensionInvalid('extensions: clientExtensionResults must be an object'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions: clientExtensionResults must be an object'); } const out = {}; @@ -222,7 +240,7 @@ export function readClientExtensionResults(results) { if (typeof results.largeBlob.blob === 'string') { const decoded = decodeB64u(results.largeBlob.blob); if (!decoded) { - throwExtensionInvalid('extensions.largeBlob.blob is not valid base64url'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.largeBlob.blob is not valid base64url'); } lb.blob = decoded; } @@ -242,14 +260,14 @@ export function readClientExtensionResults(results) { if (typeof results.prf.results.first === 'string') { const first = decodeB64u(results.prf.results.first); if (!first) { - throwExtensionInvalid('extensions.prf.results.first is not valid base64url'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.prf.results.first is not valid base64url'); } r.first = first; } if (typeof results.prf.results.second === 'string') { const second = decodeB64u(results.prf.results.second); if (!second) { - throwExtensionInvalid('extensions.prf.results.second is not valid base64url'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.prf.results.second is not valid base64url'); } r.second = second; } @@ -281,7 +299,7 @@ export function readAuthenticatorExtensions(map) { return {}; } if (!(map instanceof Map)) { - throwExtensionInvalid('extensions: authenticator extensions must be a Map'); + throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions: authenticator extensions must be a Map'); } const out = {}; diff --git a/packages/passkey/src/webauthn/flags.js b/packages/passkey/src/webauthn/flags.js index 9db6f64..9690fda 100644 --- a/packages/passkey/src/webauthn/flags.js +++ b/packages/passkey/src/webauthn/flags.js @@ -14,13 +14,7 @@ * Bits 1 and 5 are reserved. */ -import { - throwAuthDataInvalid, - throwBackedUpRequired, - throwBackupEligibleRequired, - throwUserPresenceRequired, - throwUserVerificationRequired, -} from '../errors.js'; +import { PasskeyError, ErrorCode } from '../errors.js'; export const FLAG_MASK = Object.freeze({ UP: 0x01, @@ -50,7 +44,7 @@ export const FLAG_MASK = Object.freeze({ */ export function decodeFlags(byte) { if (!Number.isInteger(byte) || byte < 0 || byte > 0xff) { - throw new Error(`flags: expected a byte (0..255), got ${byte}`); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, `flags: expected a byte (0..255), got ${byte}`); } return { raw: byte, @@ -94,20 +88,29 @@ export function deviceTypeFromFlags(flags) { */ export function enforceFlags(flags, policy = {}) { if (!flags.up) { - throwUserPresenceRequired('flags: User Present bit (UP) is required and was not set'); + throw new PasskeyError( + ErrorCode.USER_PRESENCE_REQUIRED, + 'flags: User Present bit (UP) is required and was not set', + ); } if (policy.requireUserVerification && !flags.uv) { - throwUserVerificationRequired('flags: User Verification required but UV bit not set'); + throw new PasskeyError( + ErrorCode.USER_VERIFICATION_REQUIRED, + 'flags: User Verification required but UV bit not set', + ); } if (policy.requireBackupEligible && !flags.be) { - throwBackupEligibleRequired('flags: backup-eligible credential required but BE bit not set'); + throw new PasskeyError( + ErrorCode.BACKUP_ELIGIBLE_REQUIRED, + 'flags: backup-eligible credential required but BE bit not set', + ); } if (policy.requireBackedUp && !flags.bs) { - throwBackedUpRequired('flags: backed-up state required but BS bit not set'); + throw new PasskeyError(ErrorCode.BACKED_UP_REQUIRED, 'flags: backed-up state required but BS bit not set'); } // Spec sanity: BS=1 implies BE=1 (a credential can't be backed // up without being eligible for backup). WebAuthn L3 §6.1.3. if (flags.bs && !flags.be) { - throwAuthDataInvalid('flags: BS=1 with BE=0 is not a valid combination'); + throw new PasskeyError(ErrorCode.AUTH_DATA_INVALID, 'flags: BS=1 with BE=0 is not a valid combination'); } } diff --git a/packages/passkey/src/webauthn/originCheck.js b/packages/passkey/src/webauthn/originCheck.js index 24e5677..3311cf0 100644 --- a/packages/passkey/src/webauthn/originCheck.js +++ b/packages/passkey/src/webauthn/originCheck.js @@ -14,6 +14,8 @@ * strings — no URL parsing on those. */ +import { PasskeyError, ErrorCode } from '../errors.js'; + /** * @param {string} actual * @param {string | string[] | RegExp} expected @@ -21,7 +23,7 @@ */ export function matchesOrigin(actual, expected) { if (typeof actual !== 'string') { - throw new Error('originCheck: actual must be a string'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'originCheck: actual must be a string'); } if (typeof expected === 'string') { return actual === expected; @@ -29,7 +31,7 @@ export function matchesOrigin(actual, expected) { if (Array.isArray(expected)) { for (const candidate of expected) { if (typeof candidate !== 'string') { - throw new Error('originCheck: expected[] entries must all be strings'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'originCheck: expected[] entries must all be strings'); } if (actual === candidate) { return true; @@ -46,5 +48,5 @@ export function matchesOrigin(actual, expected) { expected.global || expected.sticky ? new RegExp(expected.source, expected.flags.replace(/[gy]/g, '')) : expected; return stateless.test(actual); } - throw new Error('originCheck: expected must be string, string[], or RegExp'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'originCheck: expected must be string, string[], or RegExp'); } diff --git a/packages/passkey/src/webauthn/rpIdMatch.js b/packages/passkey/src/webauthn/rpIdMatch.js index ecfb4ab..e74fca3 100644 --- a/packages/passkey/src/webauthn/rpIdMatch.js +++ b/packages/passkey/src/webauthn/rpIdMatch.js @@ -10,6 +10,7 @@ */ import { createHash } from 'node:crypto'; +import { PasskeyError, ErrorCode } from '../errors.js'; import { bytesEqual } from '../internal/bytes.js'; /** @@ -27,15 +28,15 @@ function sha256(rpId) { */ export function matchRpId(rpIdHash, expectedRpId) { if (!(rpIdHash instanceof Uint8Array) || rpIdHash.byteLength !== 32) { - throw new Error('rpIdMatch: rpIdHash must be a 32-byte Uint8Array'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: rpIdHash must be a 32-byte Uint8Array'); } const candidates = Array.isArray(expectedRpId) ? expectedRpId : [expectedRpId]; if (candidates.length === 0) { - throw new Error('rpIdMatch: expectedRpId list is empty'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: expectedRpId list is empty'); } for (const candidate of candidates) { if (typeof candidate !== 'string' || candidate.length === 0) { - throw new Error('rpIdMatch: expectedRpId entries must be non-empty strings'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: expectedRpId entries must be non-empty strings'); } if (bytesEqual(rpIdHash, sha256(candidate))) { return { matched: candidate }; diff --git a/packages/passkey/src/x509/chain.js b/packages/passkey/src/x509/chain.js index 139b839..686df0b 100644 --- a/packages/passkey/src/x509/chain.js +++ b/packages/passkey/src/x509/chain.js @@ -29,6 +29,7 @@ */ import { X509Certificate } from 'node:crypto'; +import { PasskeyError, ErrorCode } from '../errors.js'; /** * Coerce an input into an `X509Certificate`. Accepts: @@ -46,7 +47,10 @@ export function toCertificate(input) { if (typeof input === 'string' || input instanceof Uint8Array) { return new X509Certificate(input); } - throw new Error('x509.toCertificate: expected X509Certificate, Uint8Array, Buffer, or PEM string'); + throw new PasskeyError( + ErrorCode.INVALID_ARGUMENT, + 'x509.toCertificate: expected X509Certificate, Uint8Array, Buffer, or PEM string', + ); } /** @@ -57,7 +61,7 @@ export function toCertificate(input) { */ export function toCertificates(inputs) { if (!Array.isArray(inputs)) { - throw new Error('x509.toCertificates: expected array'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'x509.toCertificates: expected array'); } return inputs.map(toCertificate); } @@ -111,7 +115,8 @@ function isAnchor(cert, anchorFingerprints) { */ function assertIssuerIsCa(signer, position) { if (signer.ca !== true) { - throw new Error( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `x509.verifyChain: issuer of certificate ${position} is not a CA (basicConstraints cA must be TRUE): ${signer.subject.replace(/\n/g, ' ')}`, ); } @@ -149,13 +154,16 @@ export function verifyChain({ x5c, trustAnchors, now = new Date() }) { const anchors = toCertificates(trustAnchors); if (chain.length === 0) { - throw new Error('x509.verifyChain: x5c is empty'); + throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'x509.verifyChain: x5c is empty'); } if (anchors.length === 0) { - throw new Error('x509.verifyChain: trustAnchors is empty — no chain would ever terminate'); + throw new PasskeyError( + ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, + 'x509.verifyChain: trustAnchors is empty — no chain would ever terminate', + ); } if (!(now instanceof Date) || Number.isNaN(now.getTime())) { - throw new Error('x509.verifyChain: `now` must be a valid Date'); + throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'x509.verifyChain: `now` must be a valid Date'); } const anchorFingerprints = new Set(anchors.map(c => c.fingerprint256)); @@ -167,7 +175,8 @@ export function verifyChain({ x5c, trustAnchors, now = new Date() }) { // parse the certificate's ASN.1 GeneralizedTime / UTCTime for // us. We target Node 22+, so they're always available. if (now < current.validFromDate || now > current.validToDate) { - throw new Error( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `x509.verifyChain: certificate ${i} outside validity window at ${now.toISOString()} (${current.validFrom} – ${current.validTo})`, ); } @@ -187,7 +196,10 @@ export function verifyChain({ x5c, trustAnchors, now = new Date() }) { if (i + 1 < chain.length) { signer = chain[i + 1]; if (!current.checkIssued(signer)) { - throw new Error(`x509.verifyChain: certificate ${i} not issued by certificate ${i + 1} (DN mismatch)`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `x509.verifyChain: certificate ${i} not issued by certificate ${i + 1} (DN mismatch)`, + ); } assertIssuerIsCa(signer, i); } else { @@ -198,7 +210,8 @@ export function verifyChain({ x5c, trustAnchors, now = new Date() }) { // certificate is self-signed and not itself in `anchors`, // `findIssuerAnchor` cannot find any candidate (its issuer // DN only ever matches its own subject), and we throw here. - throw new Error( + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, `x509.verifyChain: no trust anchor terminates chain (leaf-most orphan subject: ${current.subject.replace(/\n/g, ' ')})`, ); } @@ -206,7 +219,10 @@ export function verifyChain({ x5c, trustAnchors, now = new Date() }) { } if (!current.verify(signer.publicKey)) { - throw new Error(`x509.verifyChain: certificate ${i} signature does not verify against its issuer`); + throw new PasskeyError( + ErrorCode.ATTESTATION_INVALID, + `x509.verifyChain: certificate ${i} signature does not verify against its issuer`, + ); } } From 9a12036e27bb921b6a1c4342d939a6486cbf833b Mon Sep 17 00:00:00 2001 From: Memet Date: Fri, 7 Aug 2026 02:07:31 +0300 Subject: [PATCH 2/3] refactor(passkey): adopt @exortek/shared predicates for argument checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline typeof / Array.isArray / Number.isInteger with the shared predicates (isString, isFunction, isBoolean, isBigInt, isArray, isInteger, isObject) across the package — the repo convention every other package already follows. Behaviour-preserving: each object check expects a non-array object so array-excluding isObject matches (clientData already rejected arrays explicitly), and every integer site is bounded so isInteger (isSafeInteger) is equivalent. `typeof x === 'number'` checks are left inline on purpose — shared isNumber additionally rejects NaN, which would be a behaviour change. 309 tests green. --- packages/passkey/src/asn1/der.js | 3 +- .../passkey/src/attestation/androidKey.js | 3 +- .../src/attestation/androidSafetynet.js | 5 +-- packages/passkey/src/attestation/apple.js | 3 +- packages/passkey/src/attestation/fidoU2f.js | 3 +- packages/passkey/src/attestation/packed.js | 3 +- packages/passkey/src/attestation/tpm.js | 3 +- packages/passkey/src/authentication/begin.js | 11 +++--- packages/passkey/src/authentication/finish.js | 13 +++---- packages/passkey/src/cbor/decode.js | 13 +++---- packages/passkey/src/internal/challenge.js | 3 +- packages/passkey/src/mds.js | 13 +++---- packages/passkey/src/registration/begin.js | 11 +++--- packages/passkey/src/registration/finish.js | 15 ++++---- packages/passkey/src/webauthn/clientData.js | 9 ++--- packages/passkey/src/webauthn/extensions.js | 35 ++++++++++--------- packages/passkey/src/webauthn/flags.js | 3 +- packages/passkey/src/webauthn/originCheck.js | 9 ++--- packages/passkey/src/webauthn/rpIdMatch.js | 5 +-- packages/passkey/src/x509/chain.js | 5 +-- 20 files changed, 94 insertions(+), 74 deletions(-) diff --git a/packages/passkey/src/asn1/der.js b/packages/passkey/src/asn1/der.js index 43cb845..e877608 100644 --- a/packages/passkey/src/asn1/der.js +++ b/packages/passkey/src/asn1/der.js @@ -20,6 +20,7 @@ */ import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString } from '@exortek/shared/predicates'; // Universal tag constants (X.680 §8.6). export const TAG = Object.freeze({ @@ -324,7 +325,7 @@ export function encodeOid(oid) { * @returns {Uint8Array | null} */ export function findExtension(certDer, oid) { - if (typeof oid !== 'string') { + if (!isString(oid)) { throw new PasskeyError(ErrorCode.DECODE_ERROR, 'der.findExtension: oid must be a string'); } diff --git a/packages/passkey/src/attestation/androidKey.js b/packages/passkey/src/attestation/androidKey.js index 5a24fb2..452efcb 100644 --- a/packages/passkey/src/attestation/androidKey.js +++ b/packages/passkey/src/attestation/androidKey.js @@ -45,6 +45,7 @@ import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isArray } from '@exortek/shared/predicates'; const ANDROID_KEY_OID = '1.3.6.1.4.1.11129.2.1.17'; @@ -170,7 +171,7 @@ export function verifyAndroidKey({ attStmt, authDataBytes, clientDataHash, attes if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.sig missing or empty'); } - if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { + if (!isArray(x5cRaw) || x5cRaw.length === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-key: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { diff --git a/packages/passkey/src/attestation/androidSafetynet.js b/packages/passkey/src/attestation/androidSafetynet.js index 04446f7..57ceee7 100644 --- a/packages/passkey/src/attestation/androidSafetynet.js +++ b/packages/passkey/src/attestation/androidSafetynet.js @@ -41,6 +41,7 @@ import { base64url } from '@exortek/crypto/encode'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isArray } from '@exortek/shared/predicates'; const LEAF_CN = 'attest.android.com'; const DEFAULT_TIMESTAMP_WINDOW_MS = 5 * 60_000; // 5 minutes either side @@ -112,7 +113,7 @@ export function verifyAndroidSafetynet(params) { } const ver = attStmt.get('ver'); const response = attStmt.get('response'); - if (typeof ver !== 'string' || ver.length === 0) { + if (!isString(ver) || ver.length === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'android-safetynet: attStmt.ver missing or not a string'); } if (!(response instanceof Uint8Array) || response.byteLength === 0) { @@ -141,7 +142,7 @@ export function verifyAndroidSafetynet(params) { `android-safetynet: header.alg must be "RS256" (got "${header.alg}")`, ); } - if (!Array.isArray(header.x5c) || header.x5c.length === 0) { + if (!isArray(header.x5c) || header.x5c.length === 0) { throw new PasskeyError( ErrorCode.ATTESTATION_INVALID, 'android-safetynet: header.x5c must be a non-empty cert array', diff --git a/packages/passkey/src/attestation/apple.js b/packages/passkey/src/attestation/apple.js index 001eaf3..cbcd98b 100644 --- a/packages/passkey/src/attestation/apple.js +++ b/packages/passkey/src/attestation/apple.js @@ -21,6 +21,7 @@ import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, TAG, contextTag } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isArray } from '@exortek/shared/predicates'; const APPLE_NONCE_OID = '1.2.840.113635.100.8.2'; @@ -141,7 +142,7 @@ export function verifyApple({ attStmt, authDataBytes, clientDataHash, attestedCr throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: attStmt is not a CBOR map'); } const x5cRaw = attStmt.get('x5c'); - if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { + if (!isArray(x5cRaw) || x5cRaw.length === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'apple attestation: x5c must be a non-empty array'); } for (const c of x5cRaw) { diff --git a/packages/passkey/src/attestation/fidoU2f.js b/packages/passkey/src/attestation/fidoU2f.js index a1f9c2a..8e269ec 100644 --- a/packages/passkey/src/attestation/fidoU2f.js +++ b/packages/passkey/src/attestation/fidoU2f.js @@ -25,6 +25,7 @@ import { createVerify, X509Certificate } from 'node:crypto'; import { verifyChain, toCertificates } from '../x509/chain.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isArray } from '@exortek/shared/predicates'; /** * @param {object} params @@ -42,7 +43,7 @@ export function verifyFidoU2f({ attStmt, authDataBytes, clientDataHash, attested const x5cRaw = attStmt.get('x5c'); const sig = attStmt.get('sig'); - if (!Array.isArray(x5cRaw) || x5cRaw.length !== 1 || !(x5cRaw[0] instanceof Uint8Array)) { + if (!isArray(x5cRaw) || x5cRaw.length !== 1 || !(x5cRaw[0] instanceof Uint8Array)) { throw new PasskeyError( ErrorCode.ATTESTATION_INVALID, 'fido-u2f attestation: x5c must be a single-element array of bytes (§8.6 step 1)', diff --git a/packages/passkey/src/attestation/packed.js b/packages/passkey/src/attestation/packed.js index c602fcb..c877fbc 100644 --- a/packages/passkey/src/attestation/packed.js +++ b/packages/passkey/src/attestation/packed.js @@ -22,6 +22,7 @@ import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isArray } from '@exortek/shared/predicates'; const AAGUID_OID = '1.3.6.1.4.1.45724.1.1.4'; @@ -101,7 +102,7 @@ export function verifyPacked({ attStmt, authDataBytes, clientDataHash, attestedC } // Full attestation with x5c. - if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { + if (!isArray(x5cRaw) || x5cRaw.length === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'packed attestation: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { diff --git a/packages/passkey/src/attestation/tpm.js b/packages/passkey/src/attestation/tpm.js index 16f913c..ce7cee2 100644 --- a/packages/passkey/src/attestation/tpm.js +++ b/packages/passkey/src/attestation/tpm.js @@ -49,6 +49,7 @@ import { verifyChain, toCertificates } from '../x509/chain.js'; import { findExtension, readTlv, readChildren, decodeOid, TAG } from '../asn1/der.js'; import { bytesEqual, concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isArray } from '@exortek/shared/predicates'; const TPM_GENERATED_VALUE = 0xff544347; const TPM_ST_ATTEST_CERTIFY = 0x8017; @@ -405,7 +406,7 @@ export function verifyTpm({ attStmt, authDataBytes, clientDataHash, attestedCred if (!(sig instanceof Uint8Array) || sig.byteLength === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.sig missing or empty'); } - if (!Array.isArray(x5cRaw) || x5cRaw.length === 0) { + if (!isArray(x5cRaw) || x5cRaw.length === 0) { throw new PasskeyError(ErrorCode.ATTESTATION_INVALID, 'tpm: attStmt.x5c must be a non-empty array'); } for (const c of x5cRaw) { diff --git a/packages/passkey/src/authentication/begin.js b/packages/passkey/src/authentication/begin.js index b7d3a55..d931f2f 100644 --- a/packages/passkey/src/authentication/begin.js +++ b/packages/passkey/src/authentication/begin.js @@ -8,6 +8,7 @@ import { base64url } from '@exortek/crypto/encode'; import { issuePasskeyChallenge } from '../internal/challenge.js'; import { buildAuthenticationExtensions } from '../webauthn/extensions.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isFunction, isArray, isObject } from '@exortek/shared/predicates'; const HINT_VALUES = new Set(['security-key', 'client-device', 'hybrid']); const UV_VALUES = new Set(['required', 'preferred', 'discouraged']); @@ -32,17 +33,17 @@ const UV_VALUES = new Set(['required', 'preferred', 'discouraged']); * }>} */ export async function begin(params) { - if (!params || typeof params !== 'object') { + if (!isObject(params)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: options object required'); } const rpId = params.rpId; - if (!rpId || (typeof rpId !== 'string' && !Array.isArray(rpId))) { + if (!rpId || (!isString(rpId) && !isArray(rpId))) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: rpId (string or string[]) is required'); } if (!params.challengeSecret) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: challengeSecret is required'); } - if (!params.challengeStore || typeof params.challengeStore.incr !== 'function') { + if (!params.challengeStore || !isFunction(params.challengeStore.incr)) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'authentication.begin: challengeStore (an IncrStore) is required', @@ -55,7 +56,7 @@ export async function begin(params) { ); } if (params.hints !== undefined) { - if (!Array.isArray(params.hints)) { + if (!isArray(params.hints)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.begin: hints must be an array'); } for (const h of params.hints) { @@ -68,7 +69,7 @@ export async function begin(params) { } } - const primaryRpId = Array.isArray(rpId) ? rpId[0] : rpId; + const primaryRpId = isArray(rpId) ? rpId[0] : rpId; const timeout = params.timeoutMs ?? 60_000; const extensions = buildAuthenticationExtensions(params.extensions ?? {}); diff --git a/packages/passkey/src/authentication/finish.js b/packages/passkey/src/authentication/finish.js index 9a235b2..f01b5e7 100644 --- a/packages/passkey/src/authentication/finish.js +++ b/packages/passkey/src/authentication/finish.js @@ -26,9 +26,10 @@ import { importCoseKey, algorithmForId } from '../cose/key.js'; import { consumePasskeyChallenge } from '../internal/challenge.js'; import { concat } from '../internal/bytes.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isObject } from '@exortek/shared/predicates'; function decodeB64uField(value, field) { - if (typeof value !== 'string' || value.length === 0) { + if (!isString(value) || value.length === 0) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, `authentication.finish: response.${field} must be a base64url string`, @@ -48,7 +49,7 @@ function importCredentialKey(credential) { // The RP stores whatever we handed back at register-time — // typically the COSE Map. Accept either the raw COSE map or a // pre-imported KeyObject (via credential.publicKey). - if (credential.publicKey && typeof credential.publicKey === 'object' && 'export' in credential.publicKey) { + if (isObject(credential.publicKey) && 'export' in credential.publicKey) { // Node's KeyObject exposes an `export` method — treat as ready. return { publicKey: credential.publicKey, algorithm: credential.algorithm }; } @@ -106,7 +107,7 @@ function importCredentialKey(credential) { * }>} */ export async function finish(params) { - if (!params || typeof params !== 'object') { + if (!isObject(params)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.finish: options object required'); } const { @@ -125,7 +126,7 @@ export async function finish(params) { challengePrefix, } = params; - if (!response || typeof response !== 'object' || !response.response) { + if (!isObject(response) || !response.response) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'authentication.finish: response must be a WebAuthn PublicKeyCredential-shaped object', @@ -139,7 +140,7 @@ export async function finish(params) { } // `id` is the base64url of `rawId`; reject a client that disagrees // with itself (matches SimpleWebAuthn). Only when both are present. - if (typeof response.id === 'string' && typeof response.rawId === 'string' && response.id !== response.rawId) { + if (isString(response.id) && isString(response.rawId) && response.id !== response.rawId) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'authentication.finish: response.id must equal response.rawId (base64url mismatch)', @@ -148,7 +149,7 @@ export async function finish(params) { if (!challengeToken) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'authentication.finish: challengeToken is required'); } - if (!credential || typeof credential !== 'object' || typeof credential.counter !== 'number') { + if (!isObject(credential) || typeof credential.counter !== 'number') { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'authentication.finish: credential must include the stored counter (number)', diff --git a/packages/passkey/src/cbor/decode.js b/packages/passkey/src/cbor/decode.js index 9b649a3..096fc41 100644 --- a/packages/passkey/src/cbor/decode.js +++ b/packages/passkey/src/cbor/decode.js @@ -32,6 +32,7 @@ */ import { PasskeyError, ErrorCode } from '../errors.js'; +import { isBigInt } from '@exortek/shared/predicates'; class Cursor { /** @@ -159,7 +160,7 @@ function readArgument(c, info) { * lose precision. */ function normaliseInt(v) { - if (typeof v === 'bigint') { + if (isBigInt(v)) { if (v <= BigInt(Number.MAX_SAFE_INTEGER) && v >= BigInt(Number.MIN_SAFE_INTEGER)) { return Number(v); } @@ -196,7 +197,7 @@ function readItem(c, depth = 0) { if (major === 1) { // Negative integer — value = -1 - argument. const arg = readArgument(c, info); - if (typeof arg === 'bigint') { + if (isBigInt(arg)) { return normaliseInt(-1n - arg); } return -1 - arg; @@ -205,7 +206,7 @@ function readItem(c, depth = 0) { if (major === 2) { // Byte string. const len = readArgument(c, info); - if (typeof len === 'bigint') { + if (isBigInt(len)) { throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: byte string length ${len} exceeds Number.MAX_SAFE_INTEGER`); } return c.readBytes(len); @@ -214,7 +215,7 @@ function readItem(c, depth = 0) { if (major === 3) { // Text string — decoded strictly (invalid UTF-8 throws). const len = readArgument(c, info); - if (typeof len === 'bigint') { + if (isBigInt(len)) { throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: text string length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const raw = c.readBytes(len); @@ -224,7 +225,7 @@ function readItem(c, depth = 0) { if (major === 4) { // Array. const len = readArgument(c, info); - if (typeof len === 'bigint') { + if (isBigInt(len)) { throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: array length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const out = new Array(len); @@ -237,7 +238,7 @@ function readItem(c, depth = 0) { if (major === 5) { // Map — return as `Map` so int keys survive. const len = readArgument(c, info); - if (typeof len === 'bigint') { + if (isBigInt(len)) { throw new PasskeyError(ErrorCode.DECODE_ERROR, `cbor: map length ${len} exceeds Number.MAX_SAFE_INTEGER`); } const out = new Map(); diff --git a/packages/passkey/src/internal/challenge.js b/packages/passkey/src/internal/challenge.js index f0412c0..e76d123 100644 --- a/packages/passkey/src/internal/challenge.js +++ b/packages/passkey/src/internal/challenge.js @@ -15,6 +15,7 @@ import { base64url } from '@exortek/crypto/encode'; import { createChallenge, verifyChallenge } from '@exortek/challenge'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString } from '@exortek/shared/predicates'; /** * Decode a challenge-lib token's payload without verifying the MAC. @@ -43,7 +44,7 @@ export function readIssuedJti(token) { `passkey: challenge token payload not decodable (${err.message})`, ); } - if (!payload || typeof payload.jti !== 'string' || payload.jti.length === 0) { + if (!payload || !isString(payload.jti) || payload.jti.length === 0) { throw new PasskeyError(ErrorCode.CHALLENGE_INVALID, 'passkey: challenge token payload missing jti'); } return { jti: payload.jti }; diff --git a/packages/passkey/src/mds.js b/packages/passkey/src/mds.js index 84e76c6..a759d2b 100644 --- a/packages/passkey/src/mds.js +++ b/packages/passkey/src/mds.js @@ -23,6 +23,7 @@ import { createVerify, X509Certificate } from 'node:crypto'; import { base64url } from '@exortek/crypto/encode'; import { verifyChain, toCertificates } from './x509/chain.js'; import { PasskeyError, ErrorCode } from './errors.js'; +import { isString, isArray, isObject } from '@exortek/shared/predicates'; function decodeBase64UrlBytes(str, label) { try { @@ -67,10 +68,10 @@ function decodeJsonSegment(str, label) { * }} */ export function verifyMdsBlob(jwsCompact, options) { - if (typeof jwsCompact !== 'string' || jwsCompact.length === 0) { + if (!isString(jwsCompact) || jwsCompact.length === 0) { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: jwsCompact must be a non-empty string'); } - if (!options || !Array.isArray(options.rootAnchors) || options.rootAnchors.length === 0) { + if (!options || !isArray(options.rootAnchors) || options.rootAnchors.length === 0) { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: rootAnchors (non-empty array) is required'); } const parts = jwsCompact.split('.'); @@ -85,7 +86,7 @@ export function verifyMdsBlob(jwsCompact, options) { if (header.alg !== 'RS256' && header.alg !== 'ES256') { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, `mds: header.alg must be RS256 or ES256 (got "${header.alg}")`); } - if (!Array.isArray(header.x5c) || header.x5c.length === 0) { + if (!isArray(header.x5c) || header.x5c.length === 0) { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: header.x5c must be a non-empty array'); } const chain = header.x5c.map((b64, i) => { @@ -119,7 +120,7 @@ export function verifyMdsBlob(jwsCompact, options) { throw new PasskeyError(ErrorCode.ATTESTATION_TRUST_ANCHOR_MISSING, `mds: ${err.message}`); } - if (!payload || typeof payload !== 'object' || !Array.isArray(payload.entries)) { + if (!isObject(payload) || !isArray(payload.entries)) { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: payload.entries must be an array'); } if (typeof payload.no !== 'number') { @@ -138,7 +139,7 @@ export function verifyMdsBlob(jwsCompact, options) { * @returns {Record} */ export function buildAaguidIndex(mdsPayload) { - if (!mdsPayload || !Array.isArray(mdsPayload.entries)) { + if (!mdsPayload || !isArray(mdsPayload.entries)) { throw new PasskeyError(ErrorCode.MDS_BLOB_INVALID, 'mds: buildAaguidIndex requires a payload with .entries'); } const out = {}; @@ -148,7 +149,7 @@ export function buildAaguidIndex(mdsPayload) { continue; } const aaguid = statement.aaguid || entry.aaguid; - if (typeof aaguid !== 'string' || aaguid.length === 0) { + if (!isString(aaguid) || aaguid.length === 0) { continue; } const name = statement.description ?? aaguid; diff --git a/packages/passkey/src/registration/begin.js b/packages/passkey/src/registration/begin.js index 2bdc857..c2d4bb8 100644 --- a/packages/passkey/src/registration/begin.js +++ b/packages/passkey/src/registration/begin.js @@ -16,6 +16,7 @@ import { issuePasskeyChallenge } from '../internal/challenge.js'; import { buildRegistrationExtensions } from '../webauthn/extensions.js'; import { DEFAULT_SUPPORTED_ALGORITHMS } from '../cose/key.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isFunction, isArray, isObject } from '@exortek/shared/predicates'; const HINT_VALUES = new Set(['security-key', 'client-device', 'hybrid']); const ATTESTATION_VALUES = new Set(['none', 'direct', 'enterprise']); @@ -41,15 +42,15 @@ const ATTESTATION_VALUES = new Set(['none', 'direct', 'enterprise']); * }>} */ export async function begin(params) { - if (!params || typeof params !== 'object') { + if (!isObject(params)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: options object required'); } const rp = params.rp; const user = params.user; - if (!rp || typeof rp.id !== 'string' || typeof rp.name !== 'string') { + if (!rp || !isString(rp.id) || !isString(rp.name)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: rp.id and rp.name are required strings'); } - if (!user || typeof user.id !== 'string' || typeof user.name !== 'string' || typeof user.displayName !== 'string') { + if (!user || !isString(user.id) || !isString(user.name) || !isString(user.displayName)) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'registration.begin: user.id / .name / .displayName are required strings', @@ -58,7 +59,7 @@ export async function begin(params) { if (!params.challengeSecret) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: challengeSecret is required'); } - if (!params.challengeStore || typeof params.challengeStore.incr !== 'function') { + if (!params.challengeStore || !isFunction(params.challengeStore.incr)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: challengeStore (an IncrStore) is required'); } @@ -72,7 +73,7 @@ export async function begin(params) { const hints = params.hints; if (hints !== undefined) { - if (!Array.isArray(hints)) { + if (!isArray(hints)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.begin: hints must be an array'); } for (const h of hints) { diff --git a/packages/passkey/src/registration/finish.js b/packages/passkey/src/registration/finish.js index 23d0dbb..5724c85 100644 --- a/packages/passkey/src/registration/finish.js +++ b/packages/passkey/src/registration/finish.js @@ -26,6 +26,7 @@ import { importCoseKey, DEFAULT_SUPPORTED_ALGORITHMS } from '../cose/key.js'; import { consumePasskeyChallenge } from '../internal/challenge.js'; import { getVerifier } from '../attestation/index.js'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isArray, isObject } from '@exortek/shared/predicates'; /** * Resolve the per-format options blob a caller supplied via @@ -49,7 +50,7 @@ export function resolveAttestationOptions(fmt, attestationOptions) { } function decodeB64uField(value, field) { - if (typeof value !== 'string' || value.length === 0) { + if (!isString(value) || value.length === 0) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, `registration.finish: response.${field} must be a base64url string`, @@ -130,7 +131,7 @@ function decodeB64uField(value, field) { * }>} */ export async function finish(params) { - if (!params || typeof params !== 'object') { + if (!isObject(params)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: options object required'); } const { @@ -152,7 +153,7 @@ export async function finish(params) { challengePrefix, } = params; - if (!response || typeof response !== 'object' || !response.response) { + if (!isObject(response) || !response.response) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'registration.finish: response must be a WebAuthn PublicKeyCredential-shaped object', @@ -167,13 +168,13 @@ export async function finish(params) { // WebAuthn transports `id` as the base64url of `rawId`; a client // that sends mismatched values is malformed (SimpleWebAuthn rejects // the same way). Only enforced when both are present. - if (typeof response.id === 'string' && typeof response.rawId === 'string' && response.id !== response.rawId) { + if (isString(response.id) && isString(response.rawId) && response.id !== response.rawId) { throw new PasskeyError( ErrorCode.INVALID_ARGUMENT, 'registration.finish: response.id must equal response.rawId (base64url mismatch)', ); } - if (typeof challengeToken !== 'string' || challengeToken.length === 0) { + if (!isString(challengeToken) || challengeToken.length === 0) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'registration.finish: challengeToken is required'); } if (!expectedRpId) { @@ -237,7 +238,7 @@ export async function finish(params) { const fmt = attestationObject.get('fmt'); const authDataBytes = attestationObject.get('authData'); const attStmt = attestationObject.get('attStmt'); - if (typeof fmt !== 'string' || fmt.length === 0) { + if (!isString(fmt) || fmt.length === 0) { throw new PasskeyError( ErrorCode.AUTH_DATA_INVALID, 'registration.finish: attestationObject.fmt missing or not a string', @@ -334,7 +335,7 @@ export async function finish(params) { publicKeyCose: authData.attestedCredentialData.credentialPublicKey, algorithm: credAlg, counter: authData.signCount, - transports: Array.isArray(response.response.transports) ? response.response.transports : undefined, + transports: isArray(response.response.transports) ? response.response.transports : undefined, }, aaguid: authData.attestedCredentialData.aaguidString, deviceType: deviceTypeFromFlags(authData.flags), diff --git a/packages/passkey/src/webauthn/clientData.js b/packages/passkey/src/webauthn/clientData.js index cefb4bb..b201347 100644 --- a/packages/passkey/src/webauthn/clientData.js +++ b/packages/passkey/src/webauthn/clientData.js @@ -21,6 +21,7 @@ import { base64url } from '@exortek/crypto/encode'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isBoolean, isObject } from '@exortek/shared/predicates'; /** * @typedef {'webauthn.create' | 'webauthn.get'} ClientDataType @@ -56,7 +57,7 @@ export function parseClientData(bytes) { } catch (err) { throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, `clientData: not valid JSON (${err.message})`); } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isObject(parsed)) { throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: root must be a JSON object'); } @@ -68,13 +69,13 @@ export function parseClientData(bytes) { `clientData: type "${type}" is not "webauthn.create" or "webauthn.get"`, ); } - if (typeof challenge !== 'string' || challenge.length === 0) { + if (!isString(challenge) || challenge.length === 0) { throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: challenge missing or not a string'); } - if (typeof origin !== 'string' || origin.length === 0) { + if (!isString(origin) || origin.length === 0) { throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: origin missing or not a string'); } - if (crossOrigin !== undefined && typeof crossOrigin !== 'boolean') { + if (crossOrigin !== undefined && !isBoolean(crossOrigin)) { throw new PasskeyError(ErrorCode.CLIENT_DATA_INVALID, 'clientData: crossOrigin must be a boolean when present'); } diff --git a/packages/passkey/src/webauthn/extensions.js b/packages/passkey/src/webauthn/extensions.js index 547152f..02fa686 100644 --- a/packages/passkey/src/webauthn/extensions.js +++ b/packages/passkey/src/webauthn/extensions.js @@ -28,6 +28,7 @@ import { base64url } from '@exortek/crypto/encode'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isBoolean, isInteger, isObject } from '@exortek/shared/predicates'; function b64u(bytes) { if (!(bytes instanceof Uint8Array)) { @@ -37,7 +38,7 @@ function b64u(bytes) { } function decodeB64u(str) { - if (typeof str !== 'string') { + if (!isString(str)) { return null; } try { @@ -103,7 +104,7 @@ export function buildRegistrationExtensions(input = {}) { 'extensions.credentialProtectionPolicy must be 1, 2, or 3 (CTAP2 §12.1)', ); } - if (input.appidExclude !== undefined && typeof input.appidExclude !== 'string') { + if (input.appidExclude !== undefined && !isString(input.appidExclude)) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.appidExclude must be a string'); } return out; @@ -164,7 +165,7 @@ export function buildAuthenticationExtensions(input = {}) { } out.hmacGetSecret = out2; } - if (input.appid !== undefined && typeof input.appid !== 'string') { + if (input.appid !== undefined && !isString(input.appid)) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.appid must be a string'); } return out; @@ -222,22 +223,22 @@ export function readClientExtensionResults(results) { if (results === undefined || results === null) { return {}; } - if (typeof results !== 'object' || Array.isArray(results)) { + if (!isObject(results)) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions: clientExtensionResults must be an object'); } const out = {}; - if (typeof results.credProps === 'object' && results.credProps !== null) { + if (isObject(results.credProps)) { out.credProps = { rk: results.credProps.rk === true }; } - if (typeof results.largeBlob === 'object' && results.largeBlob !== null) { + if (isObject(results.largeBlob)) { const lb = {}; - if (typeof results.largeBlob.supported === 'boolean') { + if (isBoolean(results.largeBlob.supported)) { lb.supported = results.largeBlob.supported; } - if (typeof results.largeBlob.blob === 'string') { + if (isString(results.largeBlob.blob)) { const decoded = decodeB64u(results.largeBlob.blob); if (!decoded) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.largeBlob.blob is not valid base64url'); @@ -250,21 +251,21 @@ export function readClientExtensionResults(results) { out.largeBlob = lb; } - if (typeof results.prf === 'object' && results.prf !== null) { + if (isObject(results.prf)) { const prf = {}; - if (typeof results.prf.enabled === 'boolean') { + if (isBoolean(results.prf.enabled)) { prf.enabled = results.prf.enabled; } - if (typeof results.prf.results === 'object' && results.prf.results !== null) { + if (isObject(results.prf.results)) { const r = {}; - if (typeof results.prf.results.first === 'string') { + if (isString(results.prf.results.first)) { const first = decodeB64u(results.prf.results.first); if (!first) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.prf.results.first is not valid base64url'); } r.first = first; } - if (typeof results.prf.results.second === 'string') { + if (isString(results.prf.results.second)) { const second = decodeB64u(results.prf.results.second); if (!second) { throw new PasskeyError(ErrorCode.EXTENSION_INVALID, 'extensions.prf.results.second is not valid base64url'); @@ -276,10 +277,10 @@ export function readClientExtensionResults(results) { out.prf = prf; } - if (typeof results.appid === 'boolean') { + if (isBoolean(results.appid)) { out.appid = results.appid; } - if (typeof results.appidExclude === 'boolean') { + if (isBoolean(results.appidExclude)) { out.appidExclude = results.appidExclude; } @@ -318,7 +319,7 @@ export function readAuthenticatorExtensions(map) { // CTAP2 §12.4 minPinLength: uint. if (map.has('minPinLength')) { const v = map.get('minPinLength'); - if (typeof v === 'number' && Number.isInteger(v) && v >= 0) { + if (typeof v === 'number' && isInteger(v) && v >= 0) { out.minPinLength = v; } } @@ -335,7 +336,7 @@ export function readAuthenticatorExtensions(map) { // extensions come back untouched under `raw`. const raw = {}; for (const [k, v] of map) { - raw[typeof k === 'string' ? k : String(k)] = v; + raw[isString(k) ? k : String(k)] = v; } out.raw = raw; diff --git a/packages/passkey/src/webauthn/flags.js b/packages/passkey/src/webauthn/flags.js index 9690fda..c861f42 100644 --- a/packages/passkey/src/webauthn/flags.js +++ b/packages/passkey/src/webauthn/flags.js @@ -15,6 +15,7 @@ */ import { PasskeyError, ErrorCode } from '../errors.js'; +import { isInteger } from '@exortek/shared/predicates'; export const FLAG_MASK = Object.freeze({ UP: 0x01, @@ -43,7 +44,7 @@ export const FLAG_MASK = Object.freeze({ * @returns {AuthFlags} */ export function decodeFlags(byte) { - if (!Number.isInteger(byte) || byte < 0 || byte > 0xff) { + if (!isInteger(byte) || byte < 0 || byte > 0xff) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, `flags: expected a byte (0..255), got ${byte}`); } return { diff --git a/packages/passkey/src/webauthn/originCheck.js b/packages/passkey/src/webauthn/originCheck.js index 3311cf0..cea7921 100644 --- a/packages/passkey/src/webauthn/originCheck.js +++ b/packages/passkey/src/webauthn/originCheck.js @@ -15,6 +15,7 @@ */ import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isArray } from '@exortek/shared/predicates'; /** * @param {string} actual @@ -22,15 +23,15 @@ import { PasskeyError, ErrorCode } from '../errors.js'; * @returns {boolean} */ export function matchesOrigin(actual, expected) { - if (typeof actual !== 'string') { + if (!isString(actual)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'originCheck: actual must be a string'); } - if (typeof expected === 'string') { + if (isString(expected)) { return actual === expected; } - if (Array.isArray(expected)) { + if (isArray(expected)) { for (const candidate of expected) { - if (typeof candidate !== 'string') { + if (!isString(candidate)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'originCheck: expected[] entries must all be strings'); } if (actual === candidate) { diff --git a/packages/passkey/src/webauthn/rpIdMatch.js b/packages/passkey/src/webauthn/rpIdMatch.js index e74fca3..c6c7ef7 100644 --- a/packages/passkey/src/webauthn/rpIdMatch.js +++ b/packages/passkey/src/webauthn/rpIdMatch.js @@ -12,6 +12,7 @@ import { createHash } from 'node:crypto'; import { PasskeyError, ErrorCode } from '../errors.js'; import { bytesEqual } from '../internal/bytes.js'; +import { isString, isArray } from '@exortek/shared/predicates'; /** * @param {string} rpId @@ -30,12 +31,12 @@ export function matchRpId(rpIdHash, expectedRpId) { if (!(rpIdHash instanceof Uint8Array) || rpIdHash.byteLength !== 32) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: rpIdHash must be a 32-byte Uint8Array'); } - const candidates = Array.isArray(expectedRpId) ? expectedRpId : [expectedRpId]; + const candidates = isArray(expectedRpId) ? expectedRpId : [expectedRpId]; if (candidates.length === 0) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: expectedRpId list is empty'); } for (const candidate of candidates) { - if (typeof candidate !== 'string' || candidate.length === 0) { + if (!isString(candidate) || candidate.length === 0) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'rpIdMatch: expectedRpId entries must be non-empty strings'); } if (bytesEqual(rpIdHash, sha256(candidate))) { diff --git a/packages/passkey/src/x509/chain.js b/packages/passkey/src/x509/chain.js index 686df0b..03e3f5f 100644 --- a/packages/passkey/src/x509/chain.js +++ b/packages/passkey/src/x509/chain.js @@ -30,6 +30,7 @@ import { X509Certificate } from 'node:crypto'; import { PasskeyError, ErrorCode } from '../errors.js'; +import { isString, isArray } from '@exortek/shared/predicates'; /** * Coerce an input into an `X509Certificate`. Accepts: @@ -44,7 +45,7 @@ export function toCertificate(input) { if (input instanceof X509Certificate) { return input; } - if (typeof input === 'string' || input instanceof Uint8Array) { + if (isString(input) || input instanceof Uint8Array) { return new X509Certificate(input); } throw new PasskeyError( @@ -60,7 +61,7 @@ export function toCertificate(input) { * @returns {X509Certificate[]} */ export function toCertificates(inputs) { - if (!Array.isArray(inputs)) { + if (!isArray(inputs)) { throw new PasskeyError(ErrorCode.INVALID_ARGUMENT, 'x509.toCertificates: expected array'); } return inputs.map(toCertificate); From b5afad1dfb50965040d8b22aab93cd738a8d4433 Mon Sep 17 00:00:00 2001 From: Memet Date: Fri, 7 Aug 2026 02:10:40 +0300 Subject: [PATCH 3/3] chore(passkey): changeset for typed errors + shared predicates --- .../passkey-typed-errors-shared-predicates.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/passkey-typed-errors-shared-predicates.md diff --git a/.changeset/passkey-typed-errors-shared-predicates.md b/.changeset/passkey-typed-errors-shared-predicates.md new file mode 100644 index 0000000..be0eb88 --- /dev/null +++ b/.changeset/passkey-typed-errors-shared-predicates.md @@ -0,0 +1,17 @@ +--- +'@exortek/passkey': minor +--- + +Every passkey failure now throws a typed `PasskeyError` with a branchable +`code`. The binary parsers (CBOR, ASN.1 DER, COSE, X.509, and the WebAuthn +authenticator/client-data readers) previously threw generic `Error`s, so a +malformed attestation or assertion surfaced from the public API with no `code` — +breaking the package's "branch on `err.code`" contract. They now carry proper +codes, including a new `ErrorCode.DECODE_ERROR` for low-level CBOR/DER decode +failures. Existing `try/catch` keeps working (`PasskeyError extends Error`); the +new capability is that `err.code` is now populated for parser failures too. + +Internally, the 24 per-code `throwXxx` factory exports were removed in favour of +constructing `PasskeyError` directly (matching jwt / apikey / session), and the +argument checks now use `@exortek/shared/predicates`. No public-API surface was +removed — `index` only ever re-exported `PasskeyError` and `ErrorCode`.