You almost certainly do not need these
The four ceremony methods are the API. These exports exist for people building something unusual: an authenticator emulator, a migration script off another library, a debugging tool, a conformance harness.
Using them to hand-roll a ceremony means re-implementing the checks that make a ceremony mean anything. Do not.
import {
parseAuthenticatorData, parseClientData,
parseCOSEPublicKey, verifySignature, algorithmName,
decodeCBOR, decodeCBORFirst,
toBase64Url, fromBase64Url,
} from 'passkify';CBOR
decodeCBORmethodadvanced
decodeCBOR(bytes: Uint8Array): CBORValueDecode exactly one CBOR item, rejecting trailing bytes.
decodeCBORFirstmethodadvanced
decodeCBORFirst(bytes: Uint8Array): { value: CBORValue; bytesRead: number }Decode the first item and report how many bytes it consumed.
Why this variant has to exist
A COSE public key sits inside authenticator data with optional extension data glued directly after it, and no length prefix separating them. The only way to find the boundary is to parse the key and see where it ended.
What the parser refuses
It sits directly on attacker-controlled bytes, so it is deliberately strict:
Nesting deeper than 32 levelsrejectedA recursive-descent parser on unbounded nesting is a stack overflow waiting to be triggered.
Lengths that exceed the bufferrejected before allocatingA three-byte header can claim a four-billion-element array. Checking the declared count against the bytes actually remaining turns that from an out-of-memory kill into a parse error.
Duplicate map keysrejectedA classic parser-differential: two implementations disagree about which value wins, and a signature checked over one reading is applied to the other.
Trailing bytesrejected by decodeCBORUnconsumed input means the sender and the parser disagree about the message, which is never benign here.
Malformed UTF-8 in text stringsrejectedDecoded with fatal: true rather than substituting replacement characters.
Integers beyond 2^53surfaced as bigintSilently losing precision on a length or an algorithm identifier would be worse than an awkward type.
Indefinite-length items are accepted, even though CTAP2 canonical CBOR forbids them, because a few real authenticators emit them and rejecting would lock those users out for no security gain.
Authenticator data
parseAuthenticatorDatamethodadvanced
parseAuthenticatorData(bytes: Uint8Array): ParsedAuthenticatorDatainterface ParsedAuthenticatorData {
rpIdHash: Uint8Array;
flags: {
userPresent: boolean;
userVerified: boolean;
backupEligible: boolean;
backedUp: boolean;
attestedCredentialData: boolean;
extensionData: boolean;
};
rawFlags: number;
signCount: number;
attestedCredentialData?: {
aaguid: Uint8Array;
credentialId: Uint8Array;
credentialPublicKey: Uint8Array;
};
extensions?: CBORMap;
bytes: Uint8Array;
}The byte layout it walks:
32 rpIdHash
1 flags
4 signCount (big-endian)
-- if the AT flag is set --
16 aaguid
2 credentialIdLength
L credentialId
? credentialPublicKey (CBOR, self-delimiting)
-- if the ED flag is set --
? extensions (CBOR map)Why bytes is returned alongside the parsed fields
The signature is computed over the raw authenticator data concatenated with the client data hash. Re-serialising the parsed struct would not reproduce those bytes exactly, and any difference makes every signature fail. Keeping the original is the only correct option.
Client data
parseClientDatamethodadvanced
parseClientData(bytes: Uint8Array): ClientDatainterface ClientData {
type: string;
challenge: Base64URLString;
origin: string;
crossOrigin?: boolean;
topOrigin?: string;
tokenBinding?: { status: string; id?: string };
}Parses and shape-checks only. It does not validate the origin or the challenge; that is the ceremony's job.
COSE keys
parseCOSEPublicKeymethodadvanced
parseCOSEPublicKey(coseBytes: Uint8Array): ParsedCOSEKeyinterface ParsedCOSEKey {
alg: number; // COSE identifier, -7 for ES256
algName: string; // 'ES256'
key: KeyObject; // Node public key, ready to verify with
}Why it converts to JWK rather than building SPKI DER by hand
Handing a JWK to crypto.createPublicKey puts curve membership and point
validation inside OpenSSL. Hand-assembling DER would mean writing that
validation in JavaScript, which is exactly the kind of code that looks correct
and accepts an invalid point.
Defensive details worth knowing if you are reading the source:
- EC coordinates shorter than the curve width are left-padded, because some authenticators trim leading zero bytes and JWK requires the full width. Anything longer than the curve is rejected.
- RSA moduli and exponents have leading zeros stripped, as JWK requires minimal-length big-endian integers.
- RSA moduli below 2048 bits are refused outright.
- The algorithm in the key must match the key type, and for EC algorithms the curve is pinned to the one the specification assigns.
verifySignaturemethodadvanced
verifySignature(key: ParsedCOSEKey, data: Uint8Array, signature: Uint8Array): booleanReturns a boolean rather than throwing.
Why it works this way
"Did not verify" is an expected outcome, not an exceptional one. OpenSSL throws
on a structurally invalid signature, such as malformed ECDSA DER; that is a
failed verification, not a server fault, so it is caught and returned as
false. Genuine problems, like an unusable key, still throw.
algorithmNamemethodadvanced
algorithmName(alg: number): string | undefinedThe printable name of a COSE algorithm, or undefined if unknown. Useful for
logging what an authenticator actually used.
Encoding
toBase64Urlmethodadvanced
toBase64Url(input: Uint8Array | ArrayBuffer): stringfromBase64Urlmethodadvanced
fromBase64Url(input: string): Uint8ArrayUnpadded base64url. Decoding accepts padding and the standard +/ alphabet,
because that is what real authenticators and browsers produce.
Why these are hand-rolled rather than using Buffer or atob
The same code runs in the browser, Node, Deno, Bun and edge runtimes.
Buffer does not exist in half of those, and atob mangles binary data unless
you are careful in a way that is easy to get wrong once and never notice.
A worked example
Inspecting a credential you already have stored:
import { parseCOSEPublicKey, fromBase64Url, algorithmName } from 'passkify';
const credential = await store.getCredentialById(id);
const key = parseCOSEPublicKey(fromBase64Url(credential.publicKey));
console.log(algorithmName(key.alg)); // 'ES256'
console.log(key.key.asymmetricKeyDetails); // { namedCurve: 'prime256v1' }
console.log(key.key.export({ format: 'jwk' })); // { kty: 'EC', crv: 'P-256', x, y }