WebAuthn: verify registration attestation and assertions

Trust evaluation of a W3C WebAuthn (Level 3) / passkey attestation: parse the attestation object + authenticatorData, decode the COSE credential public key, and verify each defined attestation-statement format (packed, tpm, android-key, apple, fido-u2f, none, compound, and android-safetynet behind an opt-in): the attestation-statement signature and each format's structural bindings. The attestation CBOR is decoded by the strict, fail-closed pki.cbor codec (WebAuthn keys are CTAP2-canonical), the signature by pki.webcrypto. Chaining the returned x5c trust path to a caller-pinned root via pki.path.validate is the caller's step: this module verifies the statement, not the certificate chain. A verifier, not a ceremony client: the relying party supplies the clientDataHash + any trust anchors; this module never touches a socket. Fail-closed: every malformed shape or failed check throws a typed WebauthnError, never a partial verdict.

pki.webauthn.parseCoseKey

since 0.5.2 stable
pki.webauthn.parseCoseKey(bytes) -> object

Decode a bare COSE_Key, the credential public key a relying party stored at registration, back into the object verifyAssertion takes. pki.webauthn.verify returns that object, but the durable form is bytes: the object carries Buffer values, so a JSON round trip through a datastore yields {"type":"Buffer","data":[...]} in place of the object that went in, and existing credential stores already hold COSE bytes whoever wrote them. Without this the only routes into the decoder were parseAttestationObject and parseAuthenticatorData, both of which parse a containing structure, so recovering a stored key meant fabricating an authenticatorData that never existed.

The same validation the attestation path applies: the key type, the algorithm, the curve, and the coordinates are checked, and anything that is not a credential COSE key is refused with webauthn/bad-cose-key. verifyAssertion accepts either form for credentialPublicKey, so calling this first is a convenience, not a required step.

Example

async function example() {
  // requires: `attestationObject` / `clientDataHash` -- what a browser returns from a
  // registration ceremony
  var reg = await pki.webauthn.verify(attestationObject, clientDataHash, {});
  var stored = reg.credentialPublicKeyBytes;   // the form a credential row holds
  // ... at a login months later, read it back:
  var key = pki.webauthn.parseCoseKey(stored);
  key.alg;   // -> -7 for ES256
  // verifyAssertion takes either form, so this parse is a convenience, not a step:
  // pass `stored` straight as its credentialPublicKey.
}
example();

References

  • spec RFC 9052
  • spec W3C WebAuthn Level 3 sec. 6.5.1

pki.webauthn.parseAttestationObject

since 0.2.5 stable
pki.webauthn.parseAttestationObject(bytes) -> { fmt, attStmt, authData, authDataBytes }

Structurally decode a WebAuthn attestation object (the CBOR {fmt, attStmt, authData}) and its authenticatorData, fail-closed. authData carries the decoded rpIdHash / flags / signCount and, when the AT flag is set, the attestedCredentialData (aaguid, credentialId, and the decoded COSE credentialPublicKey). authDataBytes is the raw authenticatorData, the exact bytes an attestation signature covers. A malformed object throws webauthn/bad-attestation-object.

Example

// requires: `attestationObject` -- the CBOR bytes a browser returns from
// navigator.credentials.create(), i.e. credential.response.attestationObject
var att = pki.webauthn.parseAttestationObject(attestationObject);
att.fmt;                               // "packed"
att.authData.credentialPublicKey.kty;  // 2 (EC2)

References

  • spec W3C WebAuthn Level 3 sec. 6.5.4 / 6.1

pki.webauthn.verify

since 0.2.5 stable
pki.webauthn.verify(attestationObject, clientDataHash?, opts?) -> Promise<{ valid, attestationVerified, fmt, attestationType, trustPath, anchoredTo, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, signCount, flags, rpIdHash, extensions, bindingChecked, clientData }>

Verify a WebAuthn attestation statement: the attestation signature over authenticatorData || clientDataHash and (for the x5c formats) the format's certificate requirements. Resolves the attestation type + trust path or throws a typed webauthn/* error; a signature that does not verify is a webauthn/verify-failed verdict, never a silent pass.

Give it the client data in exactly one of the two forms, neither inferred from the other's absence: the raw opts.clientDataJSON, or the SHA-256 digest of it as the second argument. Given the JSON, this reads it: the ceremony type is checked unconditionally, because which ceremony a response belongs to is fixed by the specification and never chosen by a caller, and a login response replayed into a registration is exactly what that check stops. The challenge, origin and top-level origin are checked when you supply what you issued, and clientData.checked reports which ran. Given only the digest, nothing reads it and clientData is null.

The verdict field is attestationVerified, and the name is the point: a sound attestation statement is not the same claim as an acceptable registration. The statement says nothing about which relying party asked for it, or whether a user was present, so an attestation naming another origin's RP ID, with user presence clear, is perfectly sound and must not be registered. Supply expectedRpId, requireUserPresence, requireUserVerification and allowedAlgorithms and those are checked here; bindingChecked reports which ran, so a check that passed can be told from one that never happened.

Four more fields appear only where they mean something, never as nulls on every verdict: metadata when the catalogue governed, anchoredElements when a trust path was anchored, compound for a sec. 8.9 statement's per-element results, and safetyNet with chainValidatedAt for an android-safetynet response.

The verdict also carries what a relying party must STORE to run a later login: credentialId, credentialPublicKey and the initial signCount. The credential key comes back in both forms: the decoded object, and credentialPublicKeyBytes, which is what a credential row should hold. The object carries Buffer values, so a JSON round trip through a datastore returns {"type":"Buffer","data":[...]} in place of the object that went in. pki.webauthn.parseCoseKey reads those bytes back, and verifyAssertion accepts either form.

Options

clientDataJSON    the raw clientDataJSON bytes; supply this or the digest argument
expectedChallenge -- the challenge bytes this ceremony issued (needs clientDataJSON)
expectedOrigin    -- the origin string, or an array of acceptable origins
expectedTopOrigin -- the acceptable top-level origin(s), or null to require an
                     unframed ceremony
expectedRpId      -- the RP ID whose SHA-256 the authenticatorData must carry
requireUserPresence / requireUserVerification -- the flags this registration requires
allowedAlgorithms -- the COSE algorithms this relying party accepts
metadata          -- a verifyMetadataBlob result; the model's own roots govern
rootCertificates  -- trust anchors you pin, for the models no catalogue lists
time              -- the instant certificate validity is judged at
tpmPolicy         -- required TPM key properties; refuses a non-TPM attestation
safetyNetRoots / verifySafetyNetJws / requireCtsProfileMatch -- the
                     android-safetynet opt-in, its Google roots, and the
                     device-integrity demand

Example

async function example() {
  // requires: `attestationObject` and `clientDataJSON` from
  // navigator.credentials.create(), and `issuedChallenge` -- the bytes you sent
  var res = await pki.webauthn.verify(attestationObject, {
    clientDataJSON: clientDataJSON,
    expectedChallenge: issuedChallenge,
    expectedOrigin: "https://example.com",
    expectedRpId: "example.com", requireUserPresence: true,
  });
  res.attestationVerified;      // true (statement signature + bindings hold)
  res.clientData.checked.type;  // true -- this really is a registration response
  res.bindingChecked.rpId;      // true -- this response names example.com
  res.attestationType;          // "Basic"
  // store res.credentialId / res.credentialPublicKeyBytes / res.signCount for logins,
  // and anchor res.trustPath to your pinned roots with pki.path.validate
}
example();

References

  • spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1

pki.webauthn.verifyMetadataBlob

since 0.4.11 stable
pki.webauthn.verifyMetadataBlob(blob, opts) -> Promise<{ no, legalHeader, nextUpdate, stale, allowStale, rollbackChecked, previousNo, entries, byAaguid, byKeyIdentifier, statusPolicy, rejectUnknownStatus }>

Verify a FIDO Metadata Service BLOB (the signed catalogue of every registered authenticator model, its attestation roots, and its certification status), and return its entries indexed by aaguid for lookup. blob is caller-supplied bytes or a string; retrieval is out of scope, so this never touches the network.

The BLOB is a JWS. Its signature is checked under the certificate in its own header, that chain is validated to one of opts.rootCertificates, and only THEN is the payload read. A BLOB that does not verify never reaches the JSON parser. no must exceed opts.previousNo (rollback) and nextUpdate must not have passed (freshness). Every failure is a typed webauthn/metadata-* throw, never a partial result.

The result says which of those rules actually ran, so a catalogue held for a while can still answer for itself: stale and allowStale for freshness, rollbackChecked and the previousNo it was compared against for rollback, statusPolicy and rejectUnknownStatus for the status reading every later lookup will use. A rule that did not run reads as not-run, never as passed.

Options

- `rootCertificates` -- REQUIRED. The trust anchors the BLOB's own signing chain
  must reach. A certificate, PEM, or DER bytes.
- `time` -- the instant freshness is judged at. Defaults to now.
- `previousNo` -- the sequence number of the BLOB you already hold. A BLOB whose
  `no` is not greater is refused as a rollback.
- `requireRollbackCheck` -- require `previousNo`, so a caller cannot skip the
  rollback check by forgetting to pass it.
- `allowStale` -- accept a BLOB past its `nextUpdate`. Off by default.
- `statusPolicy` -- which status reports disqualify an authenticator: `"any"`
  (the default; any disqualifying report ever filed), `"latest-by-date"` (only the
  most recent report counts, so a later remediation clears an earlier revocation),
  or a function receiving the raw report array and returning true to deny.
- `rejectUnknownStatus` -- treat a status this toolkit does not recognize as
  disqualifying. Off by default: the specification requires an unknown status be
  ignored, never failed on.

Example

async function example() {
  // requires: `mdsBlobBytes` -- the signed BLOB from https://mds3.fidoalliance.org/
  // -- and `fidoRootDer`, the FIDO Alliance root certificate it chains to
  var md = await pki.webauthn.verifyMetadataBlob(mdsBlobBytes, {
    rootCertificates: [fidoRootDer],
    previousNo: 41,          // refuse a replay of a BLOB you have already superseded
  });
  md.no;                     // 42 (the sequence number this BLOB carries)
  md.entries.length;         // every authenticator model the catalogue lists
  // then bind an attestation to the roots its own model registered:
  // await pki.webauthn.verify(attestationObject, clientDataHash, { metadata: md });
}
example();

References

pki.webauthn.metadataFor

since 0.4.11 stable
pki.webauthn.metadataFor(metadata, identifier) -> entry | null

The verified metadata entry for an authenticator model, or null when the BLOB lists none. metadata is a verifyMetadataBlob result, never raw bytes, so a lookup can never be answered out of a BLOB nobody verified.

identifier is whichever of the catalogue's two key spaces names the authenticator: its aaguid, or, for a U2F authenticator which carries none, the key identifier of its attestation certificate (RFC 5280 sec. 4.2.1.2 method 1, 40 hex digits). The two are disjoint by shape, so the form is dispatched on and never guessed at, and anything matching neither is a miss. The all-zero aaguid means "this authenticator declares no model identity" and matches nothing.

Example

// requires: `mdsMetadata` -- a verifyMetadataBlob RESULT (never raw bytes, so a
// lookup cannot be answered out of an unverified BLOB) -- and the model's aaguid
var entry = pki.webauthn.metadataFor(mdsMetadata, mdsAaguid);
entry.statusReports[0].status;   // "FIDO_CERTIFIED_L1"
pki.webauthn.metadataFor(mdsMetadata, "00000000-0000-0000-0000-000000000000");   // null

References

pki.webauthn.metadataAnchors

since 0.4.11 stable
pki.webauthn.metadataAnchors(entry, opts?) -> [certificate]

The parsed attestation root certificates a metadata entry registers: the anchors an attestation from that model must chain to. An entry whose status reports disqualify the model registers none: the catalogue exists to say which authenticators are still trusted, so handing back the roots of one it has revoked would answer a different question than the caller asked. That refusal is webauthn/metadata-status.

The judgement uses whatever the caller supplies and the strictest reading of what it does not: pass the verified metadata and its own statusPolicy governs and its freshness is re-checked, pass time and reports are judged as of that instant, pass the certificate an attestation actually presented and a report naming a single certificate is judged against that one, so the entry does not deny every device it covers. With none of them: any disqualifying report denies, judged now.

Decoding is per entry, and deliberately not for the whole BLOB: a handful of certificates in the live metadata do not parse under a strict decoder, and decoding everything up front would let one vendor's malformed root refuse the entire catalogue for every other authenticator in it.

Options

metadata    -- the verifyMetadataBlob result the entry came from
time        -- the instant the status reports are judged at (default: now)
certificate -- the attestation certificate presented, for a report that names one

Example

async function example() {
  // requires: `mdsMetadata` -- a verifyMetadataBlob result; `mdsEntry` -- one of its
  // entries, as metadataFor returns; `mdsTime` -- the instant to judge at
  var anchors = pki.webauthn.metadataAnchors(mdsEntry, { metadata: mdsMetadata, time: mdsTime });
  anchors.length;              // the attestation roots this model registered
  anchors[0].subject;          // the decoded root DN
  // chain an attestation's trustPath to them:
  // await pki.path.validate(res.trustPath, { trustAnchors: anchors, time: mdsTime });
}
example();

References

pki.webauthn.parseClientData

since 0.5.0 stable
pki.webauthn.parseClientData(bytes, opts?) -> { type, challenge, origin, crossOrigin, topOrigin, checked }

Decode the clientDataJSON a ceremony returns, the half of a WebAuthn response the signature covers by digest but that no signature check ever looks inside. Parsed through the shared fail-closed JSON guard (bounded bytes and depth, fatal UTF-8, duplicate members refused, no prototype pollution), because these are bytes an attacker chose. challenge is returned DECODED from base64url as a Buffer, so a caller compares raw bytes and never two spellings of the same value; type, origin, crossOrigin and topOrigin come back as they were.

Supply expectedType, expectedChallenge, expectedOrigin and expectedTopOrigin and each is checked here: the challenge in constant time and by full value, the origins whole and case-sensitively. checked reports which ran, so a check that passed is distinguishable from one that never happened. expectedType is worth setting on every call: the ceremony a response belongs to is fixed, and accepting a webauthn.create where a webauthn.get was expected is a credential-registration response replayed as a login.

In a cross-origin ceremony origin is the framed document's and topOrigin is the page that framed it, so a relying party that allows framing at all should say which pages may do it. expectedTopOrigin: null requires an unframed ceremony, which an origin list cannot express. Whether a ceremony was framed is stated by both crossOrigin and topOrigin and is only usable when they agree: a response declaring itself cross-origin does not satisfy null by omitting the origin, and one that does not declare itself cross-origin makes no framing claim for an origin list to accept.

Options

expectedType      -- "webauthn.create" or "webauthn.get"
expectedChallenge -- the challenge bytes this ceremony issued (BufferSource)
expectedOrigin    -- the origin string, or an array of acceptable origins
expectedTopOrigin -- the acceptable top-level origin(s), or null to require an
                     unframed ceremony

Example

// requires: `clientDataJSON` -- credential.response.clientDataJSON;
// `issuedChallenge` -- the random bytes this server sent
var cd = pki.webauthn.parseClientData(clientDataJSON, {
  expectedType: "webauthn.get",
  expectedChallenge: issuedChallenge,
  expectedOrigin: "https://example.com",
});
cd.checked.challenge;   // true -- the issued challenge came back
cd.crossOrigin;         // false

References

  • spec W3C WebAuthn Level 3 sec. 5.8.1 / 7.1 / 7.2
  • defends webauthn-ceremony-confusion (CWE-345)

pki.webauthn.parseAuthenticatorData

since 0.5.0 stable
pki.webauthn.parseAuthenticatorData(bytes) -> { rpIdHash, flags, signCount, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, extensions }

Decode a bare authenticatorData, fail-closed: the form an authentication assertion returns, with no attestation-object wrapper around it. Same parser the registration path uses: the 37-byte minimum, the reserved (RFU) flag bits, the Backup State / Backup Eligibility rule, the 1..1023 credentialId bound, a credential public key that must be one well-formed COSE_Key, and extensions that must be exactly one CBOR map when the ED flag is set and absent when it is clear. flags is decoded to { up, uv, be, bs, at, ed }. An assertion normally has the AT flag clear, so aaguid / credentialId / credentialPublicKey are null. Malformed input throws webauthn/bad-auth-data.

Example

// requires: `authenticatorData` -- credential.response.authenticatorData from
// navigator.credentials.get()
var ad = pki.webauthn.parseAuthenticatorData(authenticatorData);
ad.flags.up;      // true when the user was present
ad.signCount;     // the authenticator's counter for this credential

References

  • spec W3C WebAuthn Level 3 sec. 6.1

pki.webauthn.verifyAssertion

since 0.5.0 stable
pki.webauthn.verifyAssertion(input) -> Promise<{ valid, signatureVerified, signCount, signCountChecked, flags, rpIdHash, extensions, bindingChecked, clientData }>

Verify an authentication assertion's signature: the authenticator signs authenticatorData || SHA-256(clientDataJSON) as raw bytes with the credential key registered earlier: no COSE_Sign1 wrapper, so a COSE message verifier is the wrong tool and fails on structure before it ever reaches the signature. An ES256 assertion signature is an ASN.1 DER SEQUENCE { r, s }, converted here with the same order-aware reader the attestation path uses, so an r or s outside [1, n-1] is refused, never normalized.

signatureVerified, not verified: this establishes that the holder of the registered credential key produced this response. What makes the response ACCEPTABLE is the sec. 7.2 binding, and the caller owns most of it. Supply expectedRpId, requireUserPresence, requireUserVerification and allowedAlgorithms and they are checked here. bindingChecked reports which ones ran, so a check that passed is distinguishable from one that never happened. Supply expectedChallenge, expectedOrigin, or expectedTopOrigin together with clientDataJSON and the challenge and origin are checked here against it; omit them and they stay with the caller to compare against the clientDataJSON this call surfaces. Passing them with only clientDataHash throws, because there is no clientDataJSON to check them against.

Pass previousSignCount (the value stored at registration or the last login) and the sec. 7.2 step 21 counter rule is applied: a counter that fails to advance is a cloned authenticator and is refused, except for the 0/0 case an authenticator that does not implement a counter reports. Without it the counter is surfaced and not judged, and signCountChecked says so.

Options

authenticatorData -- the raw bytes from the assertion (Buffer)
clientDataJSON    -- the raw clientDataJSON bytes; its SHA-256 is what the signature covers
clientDataHash    -- the 32-byte digest instead, when the caller already has it
signature         -- the assertion signature bytes
credentialPublicKey -- the stored COSE key (as parseAttestationObject surfaced it)
previousSignCount -- the stored counter, enabling the sec. 7.2 step 21 rule
expectedRpId, requireUserPresence, requireUserVerification, allowedAlgorithms: the bindings above
expectedChallenge, expectedOrigin, expectedTopOrigin -- checked against clientDataJSON when supplied (needs clientDataJSON, not clientDataHash)

Example

async function example() {
  // requires: `assertion` -- credential.response from navigator.credentials.get();
  // `storedKey` -- the COSE credentialPublicKey kept at registration
  var res = await pki.webauthn.verifyAssertion({
    authenticatorData: assertion.authenticatorData,
    clientDataJSON: assertion.clientDataJSON,
    signature: assertion.signature,
    credentialPublicKey: storedKey,
    expectedRpId: "example.com", requireUserPresence: true,
  });
  res.signatureVerified;      // true
  res.bindingChecked.rpId;    // true -- the rpIdHash matched example.com
  // the challenge and origin in clientDataJSON are still yours to compare
}
example();

References

  • spec W3C WebAuthn Level 3 sec. 7.2
  • defends webauthn-assertion-forgery (CWE-347)