Skip to content

fix(security): bound panics on hostile authenticator inputs#203

Merged
AlfioEmanueleFresta merged 6 commits into
masterfrom
fix/security-input-hardening
May 14, 2026
Merged

fix(security): bound panics on hostile authenticator inputs#203
AlfioEmanueleFresta merged 6 commits into
masterfrom
fix/security-input-hardening

Conversation

@AlfioEmanueleFresta
Copy link
Copy Markdown
Member

@AlfioEmanueleFresta AlfioEmanueleFresta commented May 10, 2026

A misbehaving or hostile authenticator can crash the platform process through five reachable code paths, all of which trust device-supplied lengths and indices that the spec does not actually guarantee. The crate-wide deny(clippy::panic) lint does not catch any of them, since the panics originate from assert! macros (which clippy::panic does not see) or from .into() and slice-indexing conversions inside transitive dependencies (generic-array, etc.).

Changes

  • PIN/UV ECDH. The peer's COSE EC2 key x/y are accepted by cosey at any length 0..=32, but the subsequent EncodedPoint::from_affine_coordinates requires exactly 32 each. Without this fix, a malformed getKeyAgreement response (e.g. a 31-byte x coordinate) panics the very first ECDH step of every PIN/UV ceremony. The fix validates length up front.
  • PIN/UV protocol 2 slicing. &key[..32] / &key[32..] panic when the key is shorter than 32 bytes. The relevant entry point is authenticate(uv_auth_token, clientDataHash) immediately after the platform decrypts the device-supplied pinUvAuthToken; an authenticator returning an IV-only ciphertext yields a zero-byte plaintext, which used to panic at the slicing step. The fix uses checked slicing and validates the token length (16 for PUAP1, 32 for PUAP2) at the decryption boundary.
  • uv_operation assertion. assert!(self.option_enabled("clientPin")) fired when an authenticator advertised pinUvAuthToken: true with no clientPin and no uv option. The new code returns OnlyForSharedSecret for that case rather than panicking.
  • caBLE decrypt_frame. The final byte of a Noise-decrypted frame is the padding length; on a zero-length plaintext the subtraction underflows and panics. The fix returns InvalidFraming on empty or under-padded frames.
  • U2F register response. A production assert!(cose_encoded_public_key.len() == 77) panicked if cosey produced a non-canonical length (a real risk on future cosey versions or unusual EC keys). Replaced with a typed error return.

Each fix has a regression test in the same module that constructs the malformed input and asserts the error variant.

Spec references

@AlfioEmanueleFresta AlfioEmanueleFresta force-pushed the fix/security-input-hardening branch 2 times, most recently from 6a8e588 to 9236103 Compare May 12, 2026 18:38
@AlfioEmanueleFresta AlfioEmanueleFresta marked this pull request as ready for review May 12, 2026 18:38
Copy link
Copy Markdown
Collaborator

@msirringhaus msirringhaus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a bug in the token length checks

Comment thread libwebauthn/src/transport/cable/tunnel.rs
// pinUvAuthToken is 16 bytes for PUAP1 and 32 bytes for PUAP2.
// Reject a shorter token before it is used as a key downstream.
let min_token_len = match uv_proto.version() {
Ctap2PinUvAuthProtocol::One => 16,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure this logic is correct.

Under section PUAP1, the spec says:

pinUvAuthToken, a random, opaque byte string that MUST be either 16 or 32 bytes long.

while under PUAP2, it says:

The length of the pinUvAuthToken for PIN/UV auth protocol two MUST be 32 bytes.

This code would allow under PUAP1 a token of 20 bytes length. I think it has to check for 32 bytes equality always and optionally for 16 bytes, if we are doing PUAP1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, good catch. Fixed in 605f30e, also added a test.

// We do here what the device would need to do, i.e. generate a new random
// pinUvAuthToken (here all 5's), then encrypt it using the shared_secret.
let token = [5; 32];
let token = [5; 16];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While puap1 would allow for 32 as well, it is probably not a bad idea to go for 16 here, just to test that both lengths works.

A malicious or buggy authenticator can return an `EcdhEsHkdf256PublicKey`
whose x or y coordinate is shorter than 32 bytes. `cosey` accepts any
length up to 32, but `EncodedPoint::from_affine_coordinates` requires
exactly 32 bytes per coordinate; the `.into()` calls invoke
`GenericArray::from_slice` which panics on length mismatch.

CTAP 2.2 §6.5.6 requires x and y to be 32 bytes (P-256 field-element
size). Validate explicitly via `try_into` and return
`Error::Ctap(CtapError::Other)` on mismatch. Add regression tests for
short and empty x, and short y.
`PinUvAuthProtocolTwo::{encrypt, decrypt}` use `&key[32..]` to discard
the HMAC-key portion of the shared secret, and `authenticate` uses
`&key[..32]`. Both panic with an out-of-bounds slice index if the key
is shorter than 32 bytes.

This is reachable from device-controlled data: in `user_verification`,
`uv_proto.decrypt(&shared_secret, &encrypted_pin_uv_auth_token)?`
yields a pinUvAuthToken of `encrypted_pin_uv_auth_token.len() - 16`
bytes; a malicious authenticator returning a 16-byte IV-only ciphertext
decrypts to an empty token, which then panics
`PinUvAuthProtocolTwo::authenticate(token, clientDataHash)`.

Replace raw slice indexing with `.get(..32).ok_or(...)` /
`.get(32..).ok_or(...)`, returning `Error::Ctap(CtapError::Other)` on
short keys. Validate decrypted pinUvAuthToken length at the boundary
(16 bytes for PUAP1 per CTAP 2.2 §6.5.5.7 step 3.7, 32 bytes for
PUAP2). Update PUAP1 mocks to use a spec-correct 16-byte token. Add
regression tests for empty and 16-byte keys.
…ation

A malicious or buggy authenticator can advertise `pinUvAuthToken=true`
without `clientPin` set (or supported). CTAP 2.2 §6.4 makes these
options independent and the platform must tolerate any combination.
The current `assert!(self.option_enabled("clientPin"))` panics on this
path, taking down the host process via every `make_credential` /
`get_assertion` / `change_pin` flow that calls into `select_uv_proto`.

Replace the assertion with a debug log + early return of
`Ctap2UserVerificationOperation::OnlyForSharedSecret`, matching the
existing handling for "pinUvAuthToken supported but PIN not set".
The caller can still establish a shared secret (e.g., for hmac-secret),
while not attempting a PIN-token ceremony that the device cannot
service. Add a regression test.
After Noise transport decryption, the last byte of the plaintext is
read as a 0..=255 padding length and the frame is truncated by
`padding_len + 1`. Two crash inputs are reachable from any
legitimate-but-malicious paired peer:

1. Empty plaintext (Noise transport accepts a 16-byte AEAD-tag-only
   ciphertext, decrypting to 0 bytes): reading `frame[len - 1]`
   underflows to `usize::MAX` and panics with `index out of bounds`.
2. Under-padded plaintext (e.g., `[0x05]`): `1 - 6` panics in debug
   builds and silently wraps in release, so the subsequent
   `truncate(huge)` no-ops and the malformed plaintext is parsed
   downstream.

Extract the padding-stripping into a `strip_frame_padding` helper that
uses `.last()` and `.checked_sub`, returning
`Error::Transport(TransportError::InvalidFraming)` on either edge
case. Add regression tests for the empty and overlong-padding inputs
plus a happy-path check.
`RegisterResponse::try_upgrade` asserts that the canonical CBOR
encoding of the synthesized COSE P-256 key is exactly 77 bytes. The
77-byte assumption holds for current `cosey 0.3` output, but is
implementation-defined: a future `cosey` revision adding an optional
field (e.g., `kid`) would round-trip to a slightly different size and
panic the host process. The recent panic-removal pass (commit 5df814b)
missed this site because `clippy::panic` does not lint `assert!`.

Replace the assertion with a typed length check that returns
`Error::Platform(PlatformError::CryptoError(...))` on mismatch.
The boundary check accepted any token of at least min_token_len bytes,
so protocol one accepted e.g. a 20-byte token and protocol two a
33-byte one. CTAP 2.1 requires the decrypted pinUvAuthToken to be
exactly 16 or 32 bytes for protocol one and exactly 32 bytes for
protocol two. Replace the minimum-length check with an exact-length
helper and unit-test it.
@AlfioEmanueleFresta AlfioEmanueleFresta force-pushed the fix/security-input-hardening branch from 605f30e to 14c0e5f Compare May 14, 2026 17:58
@AlfioEmanueleFresta AlfioEmanueleFresta merged commit a6a5939 into master May 14, 2026
4 checks passed
@AlfioEmanueleFresta AlfioEmanueleFresta deleted the fix/security-input-hardening branch May 14, 2026 18:04
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.

2 participants