Skip to content

fix(webauthn): strip transports from CTAP2 PublicKeyCredentialDescriptor#192

Open
AlfioEmanueleFresta wants to merge 2 commits intomasterfrom
fix/issue-191-strip-transports
Open

fix(webauthn): strip transports from CTAP2 PublicKeyCredentialDescriptor#192
AlfioEmanueleFresta wants to merge 2 commits intomasterfrom
fix/issue-191-strip-transports

Conversation

@AlfioEmanueleFresta
Copy link
Copy Markdown
Member

@AlfioEmanueleFresta AlfioEmanueleFresta commented May 9, 2026

Summary

Strip the WebAuthn transports field from the CTAP2 PublicKeyCredentialDescriptor
that we send to authenticators. Fixes #191.

Why

transports is a WebAuthn-level hint for the client about which transport to
try (WebAuthn L3 §5.8.3,
§5.8.4). The CTAP spec
hyperlinks the WebAuthn dictionary, so the field is technically permitted on the
wire, but every reference platform omits it and at least one shipping
authenticator firmware breaks when it's present.

What other clients do

Chromium strips the field and the source has an explicit comment about it:

// device/fido/public/public_key_credential_descriptor.cc, AsCBOR()
cbor_descriptor_map[cbor::Value(kCredentialIdKey)]   = cbor::Value(desc.id);
cbor_descriptor_map[cbor::Value(kCredentialTypeKey)] =
    cbor::Value(CredentialTypeToString(desc.credential_type));
// Transports are omitted from CBOR serialization. They aren't useful for
// security keys to process. Some existing devices even refuse to parse them
// (see https://crbug.com/1270757).

Source: public_key_credential_descriptor.cc (HEAD as of 2026-05-09). Reached from both
ctap_get_assertion_request.cc
(allowList) and
ctap_make_credential_request.cc
(excludeList).

libfido2 physically can't include it. The descriptor encoder is hard-coded
as a definite-size-2 map:

// src/cbor.c, cbor_encode_pubkey()
if ((cbor_key = cbor_new_definite_map(2)) == NULL ||
    cbor_add_bytestring(cbor_key, "id", pubkey->ptr, pubkey->len) < 0 ||
    cbor_add_string(cbor_key, "type", "public-key") < 0) {

Source: src/cbor.c (libfido2 1.17.0). Reached from
fido_dev_get_assert_tx
and fido_dev_make_cred_tx.

What goes wrong on Solo 2 specifically

Issue #191's bug log is from a Solo 2 firmware r964 (release 2.964.0); I
also reproduced on a Solo 2 r256 (firmware family 1.0.x). Both ship
fido-authenticator = 0.1.x
which pulls in
cbor-smol = 0.4.0.
That cbor-smol has a bug in deserialize_ignored_any that breaks when serde-derive
tries to skip an unknown map key whose value is non-empty:

// cbor-smol 0.4.0 src/de.rs
fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value>
where V: Visitor<'de>,
{
    // Ignore extra fields/options
    visitor.visit_none()  // does not advance the input cursor
}

The function returns visit_none() without consuming the value bytes, so the
next next_key_seed call reads the descriptor's leftover transports value as
the next map key. The misread fails with DeserializeBadMajor, which
ctap-types
then maps to CTAP2_ERR_INVALID_CBOR (0x12) and that is what the device puts on
the wire. Reproduced in isolation:

[1] outer + descriptor (no transports)         -> Ok(...)
[2] outer + descriptor (transports as 3rd key) -> Err(DeserializeBadMajor)

How libwebauthn was hitting this

In 0.2.x the only path that built a Ctap2PublicKeyCredentialDescriptor was
From<&AttestedCredentialData> (fido.rs:73), which hard-coded
transports: None. The 0.3.0 IDL/JSON parser at idl/get.rs and idl/create.rs
is the first code path that propagates a JSON-supplied transports into the
CTAP type, and the existing #[serde(skip_serializing_if = "Option::is_none")]
then emits it on the wire whenever the JSON included it. Firefox does include
it, which is how the bug surfaced.

Changes

  • proto/ctap2/model.rs: change the descriptor's transports field to
    #[serde(skip_serializing, default)]. Never emitted on the wire; still
    accepted on deserialize, since some authenticators include it in responses
    even though CTAP doesn't require it. The field stays in the in-memory type
    for U2F downgrade (make_credential.rs:620).
  • proto/ctap2/model.rs tests: keep the existing happy-path
    credential_descriptor_serialization; add
    credential_descriptor_serialization_strips_transports (proves a populated
    transports is dropped on the wire) and
    credential_descriptor_deserialization_accepts_transports (proves we still
    parse it from a response).
  • examples/webauthn_hid.rs and examples/webauthn_json_hid.rs: the example
    GetAssertion now includes the freshly-made credential in allowList with
    transports = [Usb] / "transports": ["usb"]. Without the fix these
    reproduce Invalid CBOR errors on libwebauthn 0.3.0 #191 on a Solo 2; with the fix they succeed.

Upstream status

The bug is in cbor-smol = 0.4.x's deserialize_ignored_any: it calls
visit_none() without advancing the input cursor when serde-derive skips an
unknown map key, so the next next_key_seed reads the leftover value bytes
as if they were the next key and fails. Already fixed upstream in
0326d75f
(Oct 2024, released as cbor-smol 0.4.1 / 0.5.0) and adopted by
fido-authenticator 0.2.0.

Reach by device:

Device Firmware (latest) cbor-smol Affected
SoloKeys Solo 2 r256 1.0.9 (Jan 2022) 0.4.0 yes
SoloKeys Solo 2 r964 2.964.0 (Aug 2022) 0.4.0 yes
Nitrokey 3 v1.8.3 (May 2025) 0.5.0 no
Yubikey / libfido2-style stacks n/a (permissive parser) n/a no

The SoloKeys project has been dormant since 2.964.0; no firmware update
appears to be forthcoming. Nitrokey 3 ships
Nitrokey/fido-authenticator@v0.1.1-nitrokey.27,
which forked fido-authenticator's deps and bumped to cbor-smol = "0.5",
transitively absorbing the fix.

The libwebauthn workaround in this PR is therefore the only fix that will
ever reach Solo 2 owners, and harmless on Nitrokey 3 / Yubikey (their
parsers already discarded the field). No code change is being requested
against cbor-smol, fido-authenticator, or ctap-types; all are correct
at HEAD.

Note: the parser bug affects every CTAP CBOR map the device parses where an
unknown key's value is more than zero bytes, not just transports. Other
clients sending other unknown keys would hit the same desync on a Solo 2.

Test plan

  • cargo test --lib 136 existing tests pass; 2 new tests added
  • Reproduced Invalid CBOR errors on libwebauthn 0.3.0 #191 on a Solo 2 r256 with the example patch and the fix
    reverted (InvalidCbor -> preflight filters all credentials -> NoCredentials)
  • Verified the fix on the same Solo 2 r256 examples succeed
  • No regression on a Yubikey Security Key NFC A (was tolerant before, still works)

Both webauthn_hid and webauthn_json_hid now exercise the regression: the
descriptor carries transports=[usb], which strict authenticators reject
with CTAP2_ERR_INVALID_CBOR (0x12).
The transports field is a WebAuthn-level hint for the client; sending it
inside the CBOR descriptor exposes a parser bug in cbor-smol 0.4.0 (used
by Solo 2 firmware) where deserialize_ignored_any does not consume the
value bytes, desyncing the next map read. The bug presents as
CTAP2_ERR_INVALID_CBOR (0x12) and breaks GetAssertion preflight on
affected devices, which then falls through to NoCredentials.

Use skip_serializing on the field so we never emit it on the wire, while
still tolerating it on deserialize. Matches Chromium and libfido2.

Fixes #191.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invalid CBOR errors on libwebauthn 0.3.0

1 participant