discourse/lib/discourse_webauthn.rb
Keegan George 0626ec1a29
FIX: passkey registration failing when extension data included (#38266)
**Currently, passkey registration has two bugs:**
1. Registration fails with an HTTP 500 for authenticators that include
extension data (i.e. `hmac-secret` in their attestation response)
because we slice all bytes after the credential ID the public key.
2. Registration fails with `NotAllowedError` on some hardware keys (i.e.
Solo 2) because `pubKeyCredParams` includes invalid HMAC symmetric
algorithms from the `COSE` gem, which strict authenticator firmware
rejects.

**This fix:**
1. Uses `CBOR::Unpacker` streaming decode to read exactly one `CBOR`
object from the byte stream, stopping before any trailing extension
data. Also adds `COSE::MalformedKeyError` to the rescue block so future
failures return a proper error response.
2. Replaces the blanket `COSE::Algorithm.registered_algorithm_ids` with
an explicit list of asymmetric signature algorithms valid for
`WebAuthn`.

Meta bug report:
https://meta.discourse.org/t/cant-set-up-passkey-on-any-discourse/397642/
2026-03-05 09:51:04 -08:00

131 lines
3 KiB
Ruby

# frozen_string_literal: true
module DiscourseWebauthn
ACCEPTABLE_REGISTRATION_TYPE = "webauthn.create"
ACCEPTABLE_AUTHENTICATION_TYPE = "webauthn.get"
SUPPORTED_ALGORITHMS = [
-7, # ES256
-8, # EdDSA
-35, # ES384
-36, # ES512
-37, # PS256
-38, # PS384
-39, # PS512
-257, # RS256 (via freedom patch)
].freeze
VALID_ATTESTATION_FORMATS = %w[none packed fido-u2f].freeze
CHALLENGE_EXPIRY = 5.minutes
class SecurityKeyError < StandardError
end
class InvalidOriginError < SecurityKeyError
end
class InvalidRelyingPartyIdError < SecurityKeyError
end
class UserVerificationError < SecurityKeyError
end
class UserPresenceError < SecurityKeyError
end
class ChallengeMismatchError < SecurityKeyError
end
class InvalidTypeError < SecurityKeyError
end
class UnsupportedPublicKeyAlgorithmError < SecurityKeyError
end
class UnsupportedAttestationFormatError < SecurityKeyError
end
class CredentialIdInUseError < SecurityKeyError
end
class MalformedAttestationError < SecurityKeyError
end
class KeyNotFoundError < SecurityKeyError
end
class MalformedPublicKeyCredentialError < SecurityKeyError
end
class OwnershipError < SecurityKeyError
end
class PublicKeyError < SecurityKeyError
end
class UnknownCOSEAlgorithmError < SecurityKeyError
end
##
# Usage:
#
# These methods should be used in controllers where we
# are challenging the user that has a security key, and
# they must respond with a valid webauthn response and
# credentials.
#
# @param user [User] the user to stage the challenge for
# @param server_session [ServerSession] the session to store the challenge in
def self.stage_challenge(user, server_session)
::DiscourseWebauthn::ChallengeGenerator.generate.commit_to_session(
server_session,
user,
expires: CHALLENGE_EXPIRY,
)
end
##
# Clears the challenge from the user's server session.
#
# @param user [User] the user to clear the challenge for
# @param server_session [ServerSession] the session to clear the challenge from
def self.clear_challenge(user, server_session)
server_session.delete(session_challenge_key(user))
end
def self.allowed_credentials(user, server_session)
return {} if !user.security_keys_enabled?
{
allowed_credential_ids: user.second_factor_security_key_credential_ids,
challenge: challenge(user, server_session),
}
end
def self.challenge(user, server_session)
server_session[session_challenge_key(user)]
end
def self.rp_id
Rails.env.production? ? Discourse.current_hostname : "localhost"
end
def self.origin
case Rails.env
when "development"
# defaults to the Ember CLI local port
# you might need to change this and the rp_id above
# if you are using a non-default port/hostname locally
"http://localhost:4200"
else
Discourse.base_url_no_prefix
end
end
def self.rp_name
SiteSetting.title
end
def self.session_challenge_key(user)
"staged-webauthn-challenge-#{user&.id}"
end
end