Low-level primitives

CBOR, COSE keys, authenticator data. For tooling, not for websites.

import {
  parseAuthenticatorData, parseClientData,
  parseCOSEPublicKey, verifySignature, algorithmName,
  decodeCBOR, decodeCBORFirst,
  toBase64Url, fromBase64Url,
} from 'passkify';

CBOR

decodeCBORmethodadvanced

decodeCBOR(bytes: Uint8Array): CBORValue

Decode 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

What the parser refuses

It sits directly on attacker-controlled bytes, so it is deliberately strict:

Nesting deeper than 32 levelsrejected
Lengths that exceed the bufferrejected before allocating
Duplicate map keysrejected
Trailing bytesrejected by decodeCBOR
Malformed UTF-8 in text stringsrejected
Integers beyond 2^53surfaced as bigint

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): ParsedAuthenticatorData
interface 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

Client data

parseClientDatamethodadvanced

parseClientData(bytes: Uint8Array): ClientData
interface 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): ParsedCOSEKey
interface 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

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): boolean

Returns a boolean rather than throwing.

Why it works this way

algorithmNamemethodadvanced

algorithmName(alg: number): string | undefined

The printable name of a COSE algorithm, or undefined if unknown. Useful for logging what an authenticator actually used.

Encoding

toBase64Urlmethodadvanced

toBase64Url(input: Uint8Array | ArrayBuffer): string

fromBase64Urlmethodadvanced

fromBase64Url(input: string): Uint8Array

Unpadded 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

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 }