Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/passkey-typed-errors-shared-predicates.md
Original file line number Diff line number Diff line change
@@ -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`.
57 changes: 33 additions & 24 deletions packages/passkey/src/asn1/der.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
* is in `tagNumber`; universal low-tag TLVs are unaffected.
*/

import { PasskeyError, ErrorCode } from '../errors.js';
import { isString } from '@exortek/shared/predicates';

// Universal tag constants (X.680 §8.6).
export const TAG = Object.freeze({
BOOLEAN: 0x01,
Expand All @@ -44,7 +47,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;
}
Expand All @@ -70,10 +73,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;
Expand All @@ -86,49 +89,49 @@ 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
// (X.690 §8.1.3).
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 {
const nBytes = firstLen & 0x7f;
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
Expand All @@ -141,7 +144,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);
Expand All @@ -158,7 +164,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;
Expand All @@ -181,7 +187,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);
}
Expand All @@ -200,7 +209,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];
Expand All @@ -214,7 +223,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);
Expand All @@ -225,7 +234,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('.');
Expand All @@ -243,16 +252,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) {
Expand Down Expand Up @@ -316,13 +325,13 @@ export function encodeOid(oid) {
* @returns {Uint8Array | null}
*/
export function findExtension(certDer, oid) {
if (typeof oid !== 'string') {
throw new Error('der.findExtension: oid must be a string');
if (!isString(oid)) {
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);

Expand Down Expand Up @@ -353,7 +362,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;
}
Expand Down
57 changes: 40 additions & 17 deletions packages/passkey/src/attestation/androidKey.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ 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';
import { isArray } from '@exortek/shared/predicates';

const ANDROID_KEY_OID = '1.3.6.1.4.1.11129.2.1.17';

Expand All @@ -66,16 +67,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;
}
Expand All @@ -89,7 +96,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;
}
Expand All @@ -105,11 +115,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',
);
}
Expand Down Expand Up @@ -146,23 +160,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');
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) {
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');
}
}

Expand All @@ -173,21 +187,30 @@ 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,
// and allApplications [600] MUST be absent from both auth lists.
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);

Expand All @@ -198,7 +221,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}`);
}
}

Expand Down
Loading
Loading