Files
fizzy/lib/action_pack/web_authn/authenticator/assertion_response.rb
T
Stanko K.R. 1983014be6 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
2026-03-25 17:26:49 +01:00

85 lines
3.0 KiB
Ruby

# = Action Pack WebAuthn Assertion Response
#
# Handles the authenticator response from a WebAuthn authentication ceremony.
# When a user authenticates with an existing credential, the authenticator
# returns an assertion response containing a signature that proves possession
# of the private key.
#
# == Usage
#
# # Look up the credential by ID
# credential = user.credentials.find_by!(
# credential_id: params[:id]
# )
#
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# authenticator_data: params[:response][:authenticatorData],
# signature: params[:response][:signature],
# credential: credential.to_public_key_credential,
# origin: "https://example.com"
# )
#
# response.validate!
#
# == Validation
#
# In addition to the base Response validations, this class verifies:
#
# * The client data type is "webauthn.get"
# * The signature is valid for the credential's public key
#
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :credential, :authenticator_data, :signature
validate :client_data_type_must_be_get
validate :signature_must_be_valid
validate :sign_count_must_increase
def initialize(credential:, authenticator_data:, signature:, **attributes)
super(**attributes)
@credential = credential
@signature = signature
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
end
private
def client_data_type_must_be_get
unless client_data["type"] == "webauthn.get"
errors.add(:base, "Client data type is not webauthn.get")
end
end
def signature_must_be_valid
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
unless credential.public_key.verify(digest, signature, signed_data)
errors.add(:base, "Invalid signature")
end
rescue OpenSSL::PKey::PKeyError
errors.add(:base, "Invalid signature")
end
def sign_count_must_increase
unless sign_count_increased?
errors.add(:base, "Sign count did not increase")
end
end
def sign_count_increased?
if authenticator_data.sign_count.zero? && credential.sign_count.zero?
# Some authenticators always return 0 for the sign count, even after multiple authentications.
# In that case, we have to check that both the stored and returned sign counts are 0,
# which indicates that the authenticator is likely not updating the sign count.
true
else
authenticator_data.sign_count > credential.sign_count
end
end
end