Rely solely on the challange signature for verification

The cookie approach seems like the more secure aproach because it ties the authentication or registration attempt to the user's browser session, but it doesn't work reliably on Chrome for Windows. Also, a simila problem pops up on Chrome for Linux if the session is used instead of a separate cookie. It looks like the browser doesn't propagate the state change through fast enough which results in some requests contaiining the new/updated cookie, and others don't, which results in sporadic failures. Since we use a signed and expiring challange we still get protection from replay attacks and tampering which enables us to omit the cookie entierly and rely on the challange's signature to prove expiration and authenticity. The only thing we lose is the ability to tie and attemp to a single browser session.

See: https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-challengeURL which proposes to add the same challange fetching logic as part of the standard

See: https://github.com/w3c/webauthn/issues/1856 which discusses issues that arise from having expiring challanges (which the spec recommends)

See: https://github.com/OneUptime/oneuptime/security/advisories/GHSA-gjjc-pcwp-c74m which is an explot that can happen if the server isn't able to verify the authenticity of challanges that are sent outside of a cookie
This commit is contained in:
Stanko K.R.
2026-03-25 11:15:18 +01:00
parent 4fb1cddb4c
commit 1983014be6
13 changed files with 89 additions and 95 deletions
+9 -10
View File
@@ -47,14 +47,13 @@ class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class
# Verifies the attestation response from the browser and persists a new passkey record.
# The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+
# as submitted by the registration form. The +challenge+ defaults to
# +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session
# by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed
# through to +create!+.
# as submitted by the registration form. The challenge is extracted from the authenticator's
# +clientDataJSON+ response and verified server-side. Any additional +attributes+ (e.g. +holder+)
# are passed through to +create!+.
#
# Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid.
def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes)
credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge)
def register(passkey, **attributes)
credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey)
create!(**credential.to_h, **attributes)
end
@@ -74,17 +73,17 @@ class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class
# Looks up a passkey by credential ID and verifies the assertion response from the browser.
# Returns the authenticated Passkey record, or +nil+ if the credential is not found or
# verification fails.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge)
def authenticate(passkey)
find_by(credential_id: passkey[:id])&.authenticate(passkey)
end
end
# Verifies the assertion response against this passkey's stored credential and updates the
# +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the
# response is invalid.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
def authenticate(passkey)
credential = to_public_key_credential
credential.authenticate(passkey, challenge: challenge)
credential.authenticate(passkey)
update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
self
rescue ActionPack::WebAuthn::InvalidResponseError
@@ -5,9 +5,10 @@
# authentication ceremony so that the challenge is issued just-in-time rather
# than embedded in the initial page load.
#
# The generated challenge is stored in an encrypted, HTTP-only, same-site
# cookie and simultaneously returned in the JSON response body. The cookie is
# consumed by ActionPack::Passkey::Request on the subsequent form submission.
# The generated challenge is returned in the JSON response body. The challenge
# is a signed, expiring token that the server can verify on the subsequent
# form submission without needing server-side state — the challenge is
# extracted from the authenticator's +clientDataJSON+ response.
#
# == Route
#
@@ -15,17 +16,11 @@
# via +config.action_pack.passkey.routes_prefix+).
#
class ActionPack::Passkey::ChallengesController < ActionController::Base
COOKIE_NAME = :action_pack_passkey_challenge
include ActionPack::Passkey::Request
# Generates a fresh challenge, stores it in an encrypted cookie, and returns
# it as JSON. The cookie is consumed on the next passkey form submission.
# Generates a fresh challenge and returns it as JSON.
def create
challenge = create_passkey_challenge
cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :lax, secure: !request.local? && request.ssl? }
render json: { challenge: challenge }
render json: { challenge: create_passkey_challenge }
end
private
+1 -5
View File
@@ -43,9 +43,7 @@
# == Before Action
#
# Automatically populates +ActionPack::WebAuthn::Current+ with the request
# host, origin, and challenge (read from the encrypted cookie set by
# ChallengesController). The cookie is deleted after being read to prevent
# replay.
# host and origin.
#
module ActionPack::Passkey::Request
extend ActiveSupport::Concern
@@ -54,8 +52,6 @@ module ActionPack::Passkey::Request
before_action do
ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)
end
end
@@ -17,7 +17,6 @@
# authenticator_data: params[:response][:authenticatorData],
# signature: params[:response][:signature],
# credential: credential.to_public_key_credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
@@ -9,7 +9,6 @@
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# attestation_object: params[:response][:attestationObject],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
@@ -23,7 +23,6 @@
# authenticator_data: authenticator_data,
# signature: signature,
# credential: credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com",
# user_verification: :required
# )
@@ -34,9 +33,9 @@ class ActionPack::WebAuthn::Authenticator::Response
include ActiveModel::Validations
attr_reader :client_data_json
attr_accessor :challenge, :origin, :user_verification
attr_accessor :origin, :user_verification
validate :challenge_must_match
validate :challenge_must_be_present
validate :challenge_must_not_be_expired
validate :origin_must_match
validate :must_not_be_cross_origin
@@ -45,9 +44,8 @@ class ActionPack::WebAuthn::Authenticator::Response
validate :user_must_be_present
validate :user_must_be_verified_when_required
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
def initialize(client_data_json:, origin: nil, user_verification: :preferred)
@client_data_json = client_data_json
@challenge = challenge
@origin = origin
@user_verification = user_verification.to_sym
end
@@ -76,20 +74,16 @@ class ActionPack::WebAuthn::Authenticator::Response
end
private
def challenge_must_match
if challenge.blank?
def challenge_must_be_present
if client_data["challenge"].blank?
errors.add(:base, "Challenge missing")
elsif client_data["challenge"].blank?
errors.add(:base, "Challenge missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data["challenge"].to_s)
errors.add(:base, "Challenge does not match")
end
end
def challenge_must_not_be_expired
return if errors.any? || challenge.blank?
return if errors.any?
signed_message = Base64.urlsafe_decode64(challenge)
signed_message = Base64.urlsafe_decode64(client_data["challenge"])
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
errors.add(:base, "Challenge has expired")
+1 -5
View File
@@ -14,10 +14,6 @@
# The expected origin (typically +request.base_url+). Validated against the
# +origin+ field in the authenticator's client data.
#
# [+challenge+]
# The Base64URL-encoded challenge read from the encrypted cookie. Validated
# against the +challenge+ field in the authenticator's client data.
#
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
attribute :host, :origin, :challenge
attribute :host, :origin
end
@@ -10,7 +10,6 @@
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
@@ -80,11 +79,10 @@ class ActionPack::WebAuthn::PublicKeyCredential
# PublicKeyCredential with the registered credential data.
#
# Raises +InvalidResponseError+ if the attestation is invalid.
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
def register(params, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
@@ -126,13 +124,12 @@ class ActionPack::WebAuthn::PublicKeyCredential
# Updates +sign_count+ and +backed_up+ on success.
#
# Raises +InvalidResponseError+ if the assertion is invalid.
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
def authenticate(params, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)