Polish up Passkey interface

- Fix indentation
- Split awkward initializer into separate methods
- Rename credentials to passkeys
- Simplify authenticator registry loading
- Flatten nested errors under ActionPack::WebAuthn
- Set passkey current params only requests that use it
- Inline anemic methods
- Replace custom validation with ActiveModel::Validation
- Rename identity to holder in Passkey
- Rename credentials to passkeys in JS
- Extract framework library out of controllers
- Pass params hashes down to ActionPack
- Attempt to simplify public interface
- Push data decoding down to the classes representing the data
- Introduce has_passkeys
- Add CBOR bigint support
- Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey
- Add create_passkey_button helper
- Rename public-key to creation-options
- Add sign_in_with_passkey_button helper
- Dispatch events for the whole Passkey lifecycle
- Add ED25519 support
- Prevent crash on missing meta tag
- Validate resident key options
- Don't clobber existing ActionPack config options
- Validate cryptographic params
- Move CurrentWebAuthnRequest into ActionPack::Passkey
- Use ActiveModel::Attributes for Options objects
- Implement expiring challanges
- Add lifecycle events
- Extract param helpers into Request
- Add passkey_creation_options and passkey_request_options helpers
- Add create_passkey_challenge to make it easier to override the create method if needed
- Prefix all view helpers with passkey_
- Auto-include ActionPack::Passkey::Holder
- Make the passkey challange url configurable
- Add a reminder about Passkeys to the magic link email
This commit is contained in:
Stanko K.R.
2026-03-04 14:12:40 +01:00
parent f4fbe17b22
commit 017bcc9ce1
73 changed files with 2326 additions and 1103 deletions
+104
View File
@@ -0,0 +1,104 @@
# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record.
#
# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the
# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.
#
# == Registration
#
# Generate options for the browser's +navigator.credentials.create()+ call, then register the
# response:
#
# options = ActionPack::Passkey.creation_options(holder: current_user)
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user)
#
# == Authentication
#
# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the
# response:
#
# options = ActionPack::Passkey.request_options
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.authenticate(params[:passkey])
#
# == Holder integration
#
# Call +has_passkeys+ in your model to set up the association and configure ceremony options
# per-holder. See ActionPack::Passkey::Holder for details.
class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize
self.table_name = "action_pack_passkeys"
belongs_to :holder, polymorphic: true
serialize :transports, coder: JSON, type: Array, default: []
class << self
# Returns a CreationOptions object for the given +holder+, suitable for passing to the
# browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
# configuration, holder-specific options from +holder.passkey_creation_options+, and any
# additional +options+ overrides.
def creation_options(holder:, **options)
ActionPack::WebAuthn::PublicKeyCredential.creation_options(
**Rails.configuration.action_pack.web_authn.default_creation_options.to_h,
**holder.passkey_creation_options.to_h,
**options
)
end
# 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!+.
#
# 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)
create!(**credential.to_h, **attributes)
end
# Returns a RequestOptions object suitable for passing to the browser's
# +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials
# are included so the browser can offer them for selection. Merges global defaults, holder
# options, and any additional +options+ overrides.
def request_options(holder: nil, **options)
ActionPack::WebAuthn::PublicKeyCredential.request_options(
**Rails.configuration.action_pack.web_authn.default_request_options.to_h,
**holder&.passkey_request_options.to_h,
**options
)
end
# 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)
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)
credential = to_public_key_credential
credential.authenticate(passkey, challenge: challenge)
update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
self
rescue ActionPack::WebAuthn::InvalidResponseError
nil
end
# Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored
# credential data.
def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
public_key: public_key,
sign_count: sign_count,
transports: transports
)
end
end
@@ -0,0 +1,37 @@
# = Action Pack Passkey Challenges Controller
#
# Generates fresh WebAuthn challenges for passkey ceremonies. The companion
# JavaScript calls this endpoint before initiating a registration or
# 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.
#
# == Route
#
# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
# 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.
def create
challenge = create_passkey_challenge
cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? }
render json: { challenge: challenge }
end
private
def create_passkey_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new(
challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration
).challenge
end
end
+87
View File
@@ -0,0 +1,87 @@
# View helpers for rendering passkey forms and meta tags.
#
# Include this module in your helper or ApplicationHelper to get access to:
#
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
# ceremony.
module ActionPack::Passkey::FormHelper
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
# +navigator.credentials.create()+.
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
end
# Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint
# URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to
# call +navigator.credentials.get()+.
def passkey_request_options_meta_tag(request_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-request-options", content: request_options.to_json)
end
# Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to
# +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and
# +transports+ — populated by the Stimulus controller after the browser credential API
# resolves. Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "create" }, **options)
end
end
# Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to
# +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and
# +signature+
# Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_data = {}
form_data[:passkey_mediation] = mediation if mediation
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
end
end
private
def passkey_challenge_url_meta_tag(challenge_url: nil)
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
end
def default_passkey_challenge_url
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
instance_exec(&challenge_url)
else
passkey_challenge_path
end
end
end
+143
View File
@@ -0,0 +1,143 @@
# Adds passkey support to an Active Record model (the "holder" of passkeys).
#
# == Usage
#
# class User < ApplicationRecord
# has_passkeys name: :email_address, display_name: :name
# end
#
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
# model that supply holder-specific options for the WebAuthn ceremonies:
#
# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options
#
# == Options
#
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
# or plain values:
#
# [+name+]
# A human-readable account identifier (typically an email or username) shown by the
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
#
# [+display_name+]
# A friendly label for the user (typically their full name) shown by the authenticator
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
#
# has_passkeys name: :email, display_name: :name
#
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
#
# has_passkeys do |config|
# config.creation_options { { name: email, display_name: name } }
# config.request_options { { user_verification: "required" } }
# end
module ActionPack::Passkey::Holder
extend ActiveSupport::Concern
class_methods do
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
# and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used
# by ActionPack::Passkey to build ceremony options.
#
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
# turned into holder-scoped option procs automatically. An optional block yields a Config
# for more complex setup.
def has_passkeys(**options, &block)
config = Config.new(**options)
block&.call(config)
has_many config.association_name,
as: :holder,
dependent: config.dependent,
class_name: "ActionPack::Passkey"
define_method(:passkey_creation_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_creation_options(self))
end
define_method(:passkey_request_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))
end
end
end
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
# custom association options and ceremony option blocks.
class Config
attr_accessor :association_name, :dependent
def initialize(**options)
@association_name = options.delete(:association_name) || :passkeys
@dependent = options.delete(:dependent) || :destroy
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
@creation_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@request_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional request options.
#
# config.request_options { { user_verification: "required" } }
def request_options(&block)
@request_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.creation_options { { name: email, display_name: name } }
def creation_options(&block)
@creation_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_request_options+ method defined on the holder.
def evaluate_request_options(record)
if @request_options
record.instance_exec(&@request_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_creation_options+ method defined on the holder.
def evaluate_creation_options(record)
if @creation_options
record.instance_exec(&@creation_options)
else
{}
end
end
private
def extract_options_for(klass, options)
keys = klass.attribute_names.map(&:to_sym)
extracted = options.slice(*keys)
options.except!(*keys)
extracted if extracted.any?
end
def options_to_proc(options)
proc do
options.transform_values do |value|
case value
when Symbol then send(value)
when Proc then instance_exec(&value)
else value
end
end
end
end
end
end
+81
View File
@@ -0,0 +1,81 @@
# = Action Pack Passkey Request
#
# Controller concern that sets up the WebAuthn request context and provides
# helper methods for passkey registration and authentication. Include this
# in any controller that handles passkey form submissions.
#
# == Registration example
#
# class PasskeysController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @creation_options = passkey_creation_options(holder: Current.user)
# end
#
# def create
# @passkey = ActionPack::Passkey.register(
# passkey_creation_params, holder: Current.user
# )
# redirect_to settings_path
# end
# end
#
# == Authentication example
#
# class SessionsController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @request_options = passkey_request_options
# end
#
# def create
# if passkey = ActionPack::Passkey.authenticate(passkey_request_params)
# sign_in passkey.holder
# redirect_to root_path
# else
# redirect_to new_session_path, alert: "Authentication failed"
# end
# end
# end
#
# == 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.
#
module ActionPack::Passkey::Request
extend ActiveSupport::Concern
included do
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
# Returns strong parameters for the passkey registration ceremony.
def passkey_creation_params(param: :passkey)
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
end
# Returns strong parameters for the passkey authentication ceremony.
def passkey_request_params(param: :passkey)
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
end
# Returns RequestOptions for the authentication ceremony.
def passkey_request_options(**options)
ActionPack::Passkey.request_options(**options)
end
# Returns CreationOptions for the registration ceremony.
def passkey_creation_options(**options)
ActionPack::Passkey.creation_options(**options)
end
end
+70
View File
@@ -0,0 +1,70 @@
require_relative "web_authn"
# = Action Pack Railtie
#
# Integrates the WebAuthn and Passkey subsystems into the Rails application.
# Configures default options for WebAuthn ceremonies and sets up the passkey
# challenge endpoint route.
#
# == Configuration
#
# # config/application.rb or config/initializers/passkeys.rb
# config.action_pack.web_authn.default_creation_options = { attestation: :none }
# config.action_pack.web_authn.default_request_options = { user_verification: :required }
# config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
# config.action_pack.web_authn.request_challenge_expiration = 5.minutes
#
# config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
# config.action_pack.passkey.draw_routes = true
#
class ActionPack::Railtie < Rails::Railtie
config.action_pack = ActiveSupport::OrderedOptions.new unless config.respond_to?(:action_pack)
config.action_pack.web_authn = ActiveSupport::OrderedOptions.new
config.action_pack.web_authn.default_request_options = {}
config.action_pack.web_authn.default_creation_options = {}
config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
config.action_pack.web_authn.request_challenge_expiration = 5.minutes
config.action_pack.passkey = ActiveSupport::OrderedOptions.new
config.action_pack.passkey.parent_class_name = "ApplicationRecord"
config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
config.action_pack.passkey.draw_routes = true
config.action_pack.passkey.challenge_url = nil
initializer "action_pack.passkey.routes" do |app|
passkey_config = config.action_pack.passkey
app.routes.prepend do
if passkey_config.draw_routes
scope passkey_config.routes_prefix, as: :passkey do
post "/challenge" => "action_pack/passkey/challenges#create", as: :challenge
end
end
end
end
initializer "action_pack.passkey.holder" do
ActiveSupport.on_load(:active_record) do
# We need this shim because Holder is namespaced under Passkey, which is an ActiveRecort
# and can't be required before ActiveRecord is loaded.
def self.has_passkeys(**options, &block)
include ActionPack::Passkey::Holder
has_passkeys(**options, &block)
end
end
end
initializer "action_pack.passkey.form_helper" do
ActiveSupport.on_load(:action_view) do
require_relative "passkey/form_helper"
include ActionPack::Passkey::FormHelper
end
end
initializer "action_pack.passkey.request" do
ActiveSupport.on_load(:action_controller) do
require_relative "passkey/request"
end
end
end
+47
View File
@@ -1,15 +1,62 @@
# = Action Pack WebAuthn
#
# Provides a pure-Ruby implementation of the WebAuthn (Web Authentication)
# specification for passkey registration and authentication. This module
# is the top-level namespace for all WebAuthn components and provides
# shared utilities used across ceremonies.
#
# == Components
#
# [ActionPack::WebAuthn::RelyingParty]
# Identifies your application to authenticators.
#
# [ActionPack::WebAuthn::PublicKeyCredential]
# Orchestrates registration and authentication ceremonies.
#
# [ActionPack::WebAuthn::Authenticator]
# Parses and validates authenticator responses.
#
# [ActionPack::WebAuthn::CborDecoder]
# Decodes CBOR-encoded data from authenticators.
#
# [ActionPack::WebAuthn::CoseKey]
# Parses COSE public keys into OpenSSL key objects.
#
# == Extending Attestation Formats
#
# By default only the "none" attestation format is supported. Register
# additional verifiers with:
#
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
#
module ActionPack::WebAuthn
class InvalidResponseError < StandardError; end
class InvalidCborError < StandardError; end
class InvalidKeyError < StandardError; end
class UnsupportedKeyTypeError < StandardError; end
class InvalidOptionsError < StandardError; end
class << self
# Returns a new RelyingParty configured from the current request context.
def relying_party
RelyingParty.new
end
# Returns the MessageVerifier used to sign and verify WebAuthn challenges.
def challenge_verifier
Rails.application.message_verifier("action_pack.webauthn.challenge")
end
# Returns the registry of attestation format verifiers, keyed by format
# string (e.g., "none", "packed"). Only "none" is registered by default.
def attestation_verifiers
@attestation_verifiers ||= {
"none" => Authenticator::AttestationVerifiers::None.new
}
end
# Registers a custom attestation verifier for the given +format+.
# The +verifier+ must respond to +verify!(attestation, client_data_json:)+.
def register_attestation_verifier(format, verifier)
attestation_verifiers[format.to_s] = verifier
end
@@ -9,21 +9,20 @@
#
# # Look up the credential by ID
# credential = user.credentials.find_by!(
# credentail_id: params[:id]
# 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
# )
#
# response.validate!(
# challenge: session[:authentication_challenge],
# credential: credential.to_public_key_credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# == Validation
#
# In addition to the base Response validations, this class verifies:
@@ -34,37 +33,43 @@
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)
end
def validate!(**args)
super(**args)
unless client_data["type"] == "webauthn.get"
raise InvalidResponseError, "Client data type is not webauthn.get"
end
unless valid_signature?
raise InvalidResponseError, "Invalid signature"
end
unless sign_count_increased?
raise InvalidResponseError, "Sign count did not increase"
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
end
private
def valid_signature?
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"
credential.public_key.verify("SHA256", signature, signed_data)
unless credential.public_key.verify(digest, signature, signed_data)
errors.add(:base, "Invalid signature")
end
rescue OpenSSL::PKey::PKeyError
false
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?
@@ -40,6 +40,21 @@ class ActionPack::WebAuthn::Authenticator::Attestation
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
# Wraps raw attestation data into an Attestation instance. Accepts an
# existing Attestation object (returned as-is), a Base64URL-encoded string,
# or raw binary.
def self.wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
end
# Decodes a CBOR-encoded attestation object into an Attestation instance.
def self.decode(bytes)
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
@@ -8,14 +8,13 @@
#
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# attestation_object: params[:response][:attestationObject]
# )
#
# response.validate!(
# challenge: session[:registration_challenge],
# attestation_object: params[:response][:attestationObject],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# # Store the credential
# credential_id = response.attestation.credential_id
# public_key = response.attestation.public_key
@@ -31,32 +30,39 @@
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :attestation_object
validate :client_data_type_must_be_create
validate :attestation_must_be_valid
def initialize(attestation_object:, **attributes)
super(**attributes)
@attestation_object = attestation_object
end
def validate!(**args)
super(**args)
unless client_data["type"] == "webauthn.create"
raise InvalidResponseError, "Client data type is not webauthn.create"
end
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
unless verifier
raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}"
end
verifier.verify!(attestation, client_data_json: client_data_json)
end
# Returns the decoded Attestation object, lazily parsed from the raw
# attestation object bytes.
def attestation
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.decode(attestation_object)
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
end
# Returns the authenticator data extracted from the attestation object.
def authenticator_data
attestation.authenticator_data
end
private
def client_data_type_must_be_create
unless client_data["type"] == "webauthn.create"
errors.add(:base, "Client data type is not webauthn.create")
end
end
def attestation_must_be_valid
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
if verifier
verifier.verify!(attestation, client_data_json: client_data_json)
else
errors.add(:base, "Unsupported attestation format: #{attestation.format}")
end
end
end
@@ -17,7 +17,7 @@
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
def verify!(attestation, client_data_json:)
if attestation.attestation_statement.present?
raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
raise ActionPack::WebAuthn::InvalidResponseError,
"Attestation statement must be empty for 'none' format"
end
end
@@ -60,16 +60,29 @@ class ActionPack::WebAuthn::Authenticator::Data
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
class << self
# Wraps raw authenticator data into a Data instance. Accepts an existing
# Data object (returned as-is), a Base64URL-encoded string, or raw binary.
def wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
end
# Decodes raw authenticator data bytes into a Data instance, parsing the
# RP ID hash, flags, sign count, and (if present) attested credential data.
def decode(bytes)
bytes = bytes.bytes if bytes.is_a?(String)
minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
if bytes.length < minimum_length
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short"
end
position = 0
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
@@ -86,6 +99,10 @@ class ActionPack::WebAuthn::Authenticator::Data
public_key_bytes = nil
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for attested credential data"
end
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
position += AAGUID_LENGTH
@@ -93,6 +110,10 @@ class ActionPack::WebAuthn::Authenticator::Data
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
position += CREDENTIAL_ID_LENGTH_BYTES
if bytes.length < position + credential_id_length + 1
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for credential ID and public key"
end
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
position += credential_id_length
@@ -121,10 +142,13 @@ class ActionPack::WebAuthn::Authenticator::Data
@public_key_bytes = public_key_bytes
end
# Returns true if the user performed a test of presence (e.g., touched the
# authenticator).
def user_present?
flags & USER_PRESENT_FLAG != 0
end
# Returns true if the user was verified via biometrics, PIN, or similar.
def user_verified?
flags & USER_VERIFIED_FLAG != 0
end
@@ -141,6 +165,9 @@ class ActionPack::WebAuthn::Authenticator::Data
flags & BACKUP_STATE_FLAG != 0
end
# Decodes the COSE public key bytes into an OpenSSL key object.
# Returns +nil+ when no attested credential data is present (authentication
# responses).
def public_key
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
end
@@ -18,65 +18,57 @@
#
# == Example
#
# response.validate!(
# challenge: session[:webauthn_challenge],
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: client_data_json,
# authenticator_data: authenticator_data,
# signature: signature,
# credential: credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com",
# user_verification: :required
# )
#
# response.validate!
#
class ActionPack::WebAuthn::Authenticator::Response
# Raised when response validation fails.
class InvalidResponseError < StandardError; end
include ActiveModel::Validations
attr_reader :client_data_json
attr_accessor :challenge, :origin, :user_verification
def initialize(client_data_json:)
validate :challenge_must_match
validate :challenge_must_not_be_expired
validate :origin_must_match
validate :must_not_be_cross_origin
validate :must_not_have_token_binding
validate :relying_party_id_must_match
validate :user_must_be_present
validate :user_must_be_verified_when_required
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
@client_data_json = client_data_json
@challenge = challenge
@origin = origin
@user_verification = user_verification.to_sym
end
def valid?(**args)
validate!(**args)
true
rescue InvalidResponseError
false
end
def validate!(challenge:, origin:, user_verification: :preferred)
unless challenge_matches?(challenge)
raise InvalidResponseError, "Challenge does not match"
end
unless origin_matches?(origin)
raise InvalidResponseError, "Origin does not match"
end
if cross_origin?
raise InvalidResponseError, "Cross-origin requests are not supported"
end
if token_binding_present?
raise InvalidResponseError, "Token binding is not supported"
end
unless relying_party_id_matches?
raise InvalidResponseError, "Relying party ID does not match"
end
unless user_present?
raise InvalidResponseError, "User presence is required"
end
if user_verification == :required && !user_verified?
raise InvalidResponseError, "User verification is required"
end
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
end
# Returns the RelyingParty used for RP ID validation.
def relying_party
ActionPack::WebAuthn.relying_party
end
# Parses the client data JSON string into a Hash. Raises
# +InvalidResponseError+ if the JSON is malformed.
def client_data
@client_data ||= JSON.parse(client_data_json)
rescue JSON::ParserError
raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
end
def authenticator_data
@@ -84,34 +76,68 @@ class ActionPack::WebAuthn::Authenticator::Response
end
private
def challenge_matches?(expected_challenge)
ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"])
def challenge_must_match
if 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 origin_matches?(expected_origin)
ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"])
def challenge_must_not_be_expired
return if errors.any? || challenge.blank?
signed_message = Base64.urlsafe_decode64(challenge)
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
errors.add(:base, "Challenge has expired")
end
rescue ArgumentError
errors.add(:base, "Challenge is invalid")
end
def relying_party_id_matches?
ActiveSupport::SecurityUtils.secure_compare(
def origin_must_match
if origin.blank?
errors.add(:base, "Origin missing")
elsif client_data["origin"].blank?
errors.add(:base, "Origin missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s)
errors.add(:base, "Origin does not match")
end
end
def must_not_be_cross_origin
if client_data["crossOrigin"] == true
errors.add(:base, "Cross-origin requests are not supported")
end
end
def must_not_have_token_binding
if client_data.dig("tokenBinding", "status") == "present"
errors.add(:base, "Token binding is not supported")
end
end
def relying_party_id_must_match
unless ActiveSupport::SecurityUtils.secure_compare(
Digest::SHA256.digest(relying_party.id),
authenticator_data&.relying_party_id_hash || ""
)
errors.add(:base, "Relying party ID does not match")
end
end
def cross_origin?
client_data["crossOrigin"] == true
def user_must_be_present
unless authenticator_data&.user_present?
errors.add(:base, "User presence is required")
end
end
def token_binding_present?
client_data.dig("tokenBinding", "status") == "present"
end
def user_present?
authenticator_data&.user_present?
end
def user_verified?
authenticator_data&.user_verified?
def user_must_be_verified_when_required
if user_verification == :required && !authenticator_data&.user_verified?
errors.add(:base, "User verification is required")
end
end
end
+21 -14
View File
@@ -49,11 +49,8 @@
#
# == Errors
#
# Raises +DecodeError+ when encountering malformed or unsupported CBOR data.
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
class ActionPack::WebAuthn::CborDecoder
# Raised when the decoder encounters invalid or unsupported CBOR data.
class DecodeError < StandardError; end
# Major types
UNSIGNED_INTEGER_TYPE = 0
NEGATIVE_INTEGER_TYPE = 1
@@ -86,6 +83,10 @@ class ActionPack::WebAuthn::CborDecoder
MAX_DEPTH = 16
MAX_SIZE = 10.megabytes
# Tags
POSITIVE_BIGNUM_TAG = 2
NEGATIVE_BIGNUM_TAG = 3
class << self
# Decodes a CBOR-encoded byte sequence into a Ruby object.
#
@@ -98,7 +99,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
raise DecodeError, "Input exceeds maximum size" if bytes.length > max_size
raise ActionPack::WebAuthn::InvalidCborError, "Input exceeds maximum size" if bytes.length > max_size
@bytes = bytes
@max_depth = max_depth
@@ -108,8 +109,8 @@ class ActionPack::WebAuthn::CborDecoder
# Decodes the next CBOR data item from the byte sequence.
def decode
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
raise DecodeError, "Maximum nesting depth exceeded" if @depth > @max_depth
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
@depth += 1
@@ -190,13 +191,19 @@ class ActionPack::WebAuthn::CborDecoder
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
else
raise DecodeError, "Invalid simple value: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
end
end
def decode_tag
read_argument
decode
tag = read_argument
value = decode
case tag
when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }
when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }
else value
end
end
def decode_half_float
@@ -225,9 +232,9 @@ class ActionPack::WebAuthn::CborDecoder
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
when RESERVED_VALUE_RANGE
raise DecodeError, "Reserved additional info: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
else
raise DecodeError, "Invalid additional info: #{info}"
raise ActionPack::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
end
end
@@ -245,7 +252,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def read_bytes(length)
raise DecodeError, "Unexpected end of input" if @position + length > @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position + length > @bytes.length
bytes = @bytes[@position, length]
@position += length
@@ -253,7 +260,7 @@ class ActionPack::WebAuthn::CborDecoder
end
def read_byte
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
byte = @bytes[@position]
@position += 1
+52 -10
View File
@@ -19,22 +19,25 @@
# [ES256]
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
#
# [EdDSA]
# EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.
#
# [RS256]
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
#
# == Attributes
#
# [+key_type+]
# The COSE key type (2 for EC2, 3 for RSA).
# The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).
#
# [+algorithm+]
# The COSE algorithm identifier (-7 for ES256, -257 for RS256).
# The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).
#
# [+parameters+]
# The full COSE key parameters map, including curve and coordinate data.
class ActionPack::WebAuthn::CoseKey
# Raised when the key type, algorithm, or curve is not supported.
class UnsupportedKeyTypeError < StandardError; end
P256_COORDINATE_LENGTH = 32
MINIMUM_RSA_KEY_BITS = 2048
# COSE key labels
KEY_TYPE_LABEL = 1
@@ -44,18 +47,25 @@ class ActionPack::WebAuthn::CoseKey
EC2_Y_LABEL = -3
RSA_N_LABEL = -1
RSA_E_LABEL = -2
OKP_CURVE_LABEL = -1
OKP_X_LABEL = -2
# COSE key types
OKP = 1
EC2 = 2
RSA = 3
# COSE algorithms
ES256 = -7
EDDSA = -8
RS256 = -257
# COSE EC2 curves
P256 = 1
# COSE OKP curves
ED25519 = 6
# OpenSSL types
UNCOMPRESSED_POINT_MARKER = 0x04
@@ -84,26 +94,30 @@ class ActionPack::WebAuthn::CoseKey
# Converts the COSE key to an OpenSSL public key object.
#
# Returns an +OpenSSL::PKey::EC+ for EC2 keys or +OpenSSL::PKey::RSA+ for
# RSA keys, suitable for use with +OpenSSL::PKey#verify+.
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
# RSA keys, or an Ed25519 key for OKP keys, suitable for use with
# +OpenSSL::PKey#verify+.
#
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
# is not supported.
def to_openssl_key
case [ key_type, algorithm ]
when [ EC2, ES256 ] then build_ec2_es256_key
when [ OKP, EDDSA ] then build_okp_eddsa_key
when [ RSA, RS256 ] then build_rsa_rs256_key
else raise UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
end
end
private
def build_ec2_es256_key
curve = parameters[EC2_CURVE_LABEL]
raise UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
x = parameters[EC2_X_LABEL]
y = parameters[EC2_Y_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH
# Uncompressed point format: 0x04 || x || y
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
@@ -117,11 +131,37 @@ class ActionPack::WebAuthn::CoseKey
])
OpenSSL::PKey::EC.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}"
end
def build_okp_eddsa_key
curve = parameters[OKP_CURVE_LABEL]
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519
x = parameters[OKP_X_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil?
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::ObjectId("ED25519")
]),
OpenSSL::ASN1::BitString(x)
])
OpenSSL::PKey.read(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}"
end
def build_rsa_rs256_key
n = OpenSSL::BN.new(parameters[RSA_N_LABEL], 2)
e = OpenSSL::BN.new(parameters[RSA_E_LABEL], 2)
n_bytes = parameters[RSA_N_LABEL]
e_bytes = parameters[RSA_E_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS
n = OpenSSL::BN.new(n_bytes, 2)
e = OpenSSL::BN.new(e_bytes, 2)
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
@@ -137,5 +177,7 @@ class ActionPack::WebAuthn::CoseKey
])
OpenSSL::PKey::RSA.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
end
end
+21 -1
View File
@@ -1,3 +1,23 @@
# = Action Pack WebAuthn Current Attributes
#
# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are
# set automatically by ActionPack::Passkey::Request at the start of each
# request and consumed by the registration/authentication flows.
#
# == Attributes
#
# [+host+]
# The relying party identifier (typically +request.host+). Used as the
# default RelyingParty ID.
#
# [+origin+]
# 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
attribute :host, :origin, :challenge
end
@@ -1,14 +1,94 @@
# = Action Pack WebAuthn Public Key Credential
#
# Represents a WebAuthn public key credential and orchestrates the registration
# and authentication ceremonies. During registration (+.register+), it verifies
# the attestation response and returns a new credential. During authentication
# (+#authenticate+), it verifies the assertion response against the stored
# public key.
#
# == Registration
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
# credential.id # => Base64URL-encoded credential ID
# credential.public_key # => OpenSSL::PKey::EC
# credential.sign_count # => 0
#
# == Authentication
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.new(
# id: stored_credential_id,
# public_key: stored_public_key,
# sign_count: stored_sign_count
# )
#
# credential.authenticate(params[:passkey])
#
# == Ceremony Options
#
# Use +.creation_options+ and +.request_options+ to generate the JSON options
# passed to the browser's +navigator.credentials.create()+ and
# +navigator.credentials.get()+ calls.
#
# == Attributes
#
# [+id+]
# The Base64URL-encoded credential identifier.
#
# [+public_key+]
# The OpenSSL public key for signature verification.
#
# [+sign_count+]
# The signature counter, used for replay detection.
#
# [+aaguid+]
# The authenticator attestation GUID (set during registration).
#
# [+backed_up+]
# Whether the credential is backed up to cloud storage (synced passkey).
#
# [+transports+]
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
#
class ActionPack::WebAuthn::PublicKeyCredential
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
class << self
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
# Returns a RequestOptions object for the authentication ceremony.
# Credentials responding to +to_public_key_credential+ are automatically
# transformed.
def request_options(**attributes)
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
end
# Returns a CreationOptions object for the registration ceremony.
# Credentials in +exclude_credentials+ responding to
# +to_public_key_credential+ are automatically transformed.
def creation_options(**attributes)
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
end
# Verifies an attestation response from the browser and returns a new
# 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)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: attestation_object
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
response.validate!(challenge: challenge, origin: origin)
response.validate!
new(
id: response.attestation.credential_id,
@@ -16,33 +96,61 @@ class ActionPack::WebAuthn::PublicKeyCredential
sign_count: response.attestation.sign_count,
aaguid: response.attestation.aaguid,
backed_up: response.attestation.backed_up?,
transports: transports,
owner: owner
transports: Array(params[:transports])
)
end
private
def transform_credentials(credentials)
Array(credentials).map do |credential|
if credential.respond_to?(:to_public_key_credential)
credential.to_public_key_credential
else
credential
end
end
end
end
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil)
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
@id = id
@public_key = public_key
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
@sign_count = sign_count
@aaguid = aaguid
@backed_up = backed_up
@transports = transports
@owner = owner
end
def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:)
# Verifies an assertion response against this credential's public key.
# 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)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: client_data_json,
authenticator_data: authenticator_data,
signature: signature,
credential: self
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)
response.validate!(challenge: challenge, origin: origin)
response.validate!
@sign_count = response.authenticator_data.sign_count
@backed_up = response.authenticator_data.backed_up?
end
# Returns a Hash of the credential data suitable for persisting.
def to_h
{
credential_id: id,
public_key: public_key.to_der,
sign_count: sign_count,
aaguid: aaguid,
backed_up: backed_up,
transports: transports
}
end
end
@@ -36,51 +36,33 @@
#
# == Supported Algorithms
#
# By default, supports ES256 (ECDSA with P-256 and SHA-256) and RS256
# (RSASSA-PKCS1-v1_5 with SHA-256), which cover the vast majority of
# authenticators.
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
# the vast majority of authenticators.
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
ES256 = { type: "public-key", alg: -7 }.freeze
EDDSA = { type: "public-key", alg: -8 }.freeze
RS256 = { type: "public-key", alg: -257 }.freeze
RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
attr_reader :id, :name, :display_name
attribute :id
attribute :name
attribute :display_name
attribute :resident_key, default: :preferred
attribute :exclude_credentials, default: -> { [] }
attribute :attestation, default: :none
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }
# Creates a new set of credential creation options.
#
# ==== Options
#
# [+:id+]
# Required. The user's unique identifier.
#
# [+:name+]
# Required. The user's account name (e.g., email).
#
# [+:display_name+]
# Required. The user's display name.
#
# [+:relying_party+]
# Optional. The relying party configuration.
#
# [+:resident_key+]
# Optional. Resident key requirement. One of +:preferred+, +:required+,
# or +:discouraged+. Defaults to +:preferred+.
#
# [+:exclude_credentials+]
# Optional. Existing credentials to exclude from registration. Each must
# respond to +id+ and +transports+.
#
# [+:attestation+]
# Optional. The attestation conveyance preference. One of +:none+,
# +:indirect+, +:direct+, or +:enterprise+. Defaults to +:none+.
def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], attestation: :none, **attrs)
super(**attrs)
validates :id, :name, :display_name, presence: true
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
@id = id
@name = name
@display_name = display_name
@resident_key = resident_key
@exclude_credentials = exclude_credentials
@attestation = validate_attestation(attestation)
def initialize(attributes = {})
super
self.resident_key = resident_key.to_sym
self.attestation = attestation.to_sym
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
@@ -96,37 +78,28 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
},
pubKeyCredParams: [
ES256,
EDDSA,
RS256
],
authenticatorSelection: {
residentKey: @resident_key.to_s,
requireResidentKey: @resident_key == :required,
residentKey: resident_key.to_s,
requireResidentKey: resident_key == :required,
userVerification: user_verification.to_s
}
}
if @exclude_credentials.any?
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
if exclude_credentials.any?
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
end
if @attestation != :none
json[:attestation] = @attestation.to_s
if attestation != :none
json[:attestation] = attestation.to_s
end
json
end
private
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
def validate_attestation(value)
if ATTESTATION_PREFERENCES.include?(value)
value
else
raise ArgumentError, "Invalid attestation preference: #{value.inspect}. Must be one of: #{ATTESTATION_PREFERENCES.map(&:inspect).join(", ")}"
end
end
def exclude_credential_json(credential)
hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any?
@@ -1,26 +1,77 @@
# = Action Pack WebAuthn Public Key Credential Options
#
# Abstract base class for WebAuthn ceremony options. Provides shared
# attributes and challenge generation for both CreationOptions (registration)
# and RequestOptions (authentication).
#
# This class should not be instantiated directly. Use CreationOptions or
# RequestOptions instead.
#
# == Challenge Generation
#
# Each options object generates a signed, expiring challenge via
# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
# and includes an embedded timestamp so the server can reject stale challenges.
#
# == Attributes
#
# [+user_verification+]
# Controls whether user verification (biometrics/PIN) is required. One of
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
#
# [+relying_party+]
# The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.
#
# [+challenge_expiration+]
# How long the challenge remains valid. Defaults vary by ceremony type
# (configured in the Railtie).
#
class ActionPack::WebAuthn::PublicKeyCredential::Options
include ActiveModel::API
include ActiveModel::Attributes
CHALLENGE_LENGTH = 32
USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
attr_reader :user_verification, :relying_party
attribute :user_verification, default: :preferred
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
attribute :challenge_expiration
def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party)
@user_verification = user_verification.to_sym
@relying_party = relying_party
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
unless USER_VERIFICATION_OPTIONS.include?(user_verification)
raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}"
end
def initialize(attributes = {})
super
self.user_verification = user_verification.to_sym
end
# Returns a Base64URL-encoded random challenge. The challenge is generated
# once and memoized for the lifetime of this object.
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
end
# Returns a human-readable representation of the options.
def inspect
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
"#<#{self.class.name} #{attributes_string}>"
end
# Returns a Base64URL-encoded signed challenge containing a random nonce and
# an embedded timestamp. The challenge is generated once and memoized for the
# lifetime of this object.
#
# The challenge must be stored server-side and verified when the client
# responds, to prevent replay attacks.
# The timestamp allows the server to reject stale challenges. The expiration
# window is configurable per-ceremony via
# +config.action_pack.web_authn.creation_challenge_expiration+ and
# +config.action_pack.web_authn.request_challenge_expiration+, or per-instance
# via the +challenge_expiration+ attribute.
def challenge
@challenge ||= Base64.urlsafe_encode64(
SecureRandom.random_bytes(CHALLENGE_LENGTH),
ActionPack::WebAuthn.challenge_verifier.generate(
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
expires_in: challenge_expiration
),
padding: false
)
end
@@ -24,21 +24,12 @@
# The relying party (your application) configuration. Defaults to
# +ActionPack::WebAuthn.relying_party+.
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
attr_reader :credentials
attribute :credentials, default: -> { [] }
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }
# Creates a new set of credential request options.
#
# ==== Options
#
# [+:credentials+]
# Required. The user's registered WebAuthn credentials.
#
# [+:relying_party+]
# Optional. The relying party configuration.
def initialize(credentials:, **attrs)
super(**attrs)
@credentials = credentials
def initialize(attributes = {})
super
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
@@ -0,0 +1,15 @@
module ActionPackPasskeyInferNameFromAaguid
extend ActiveSupport::Concern
class_methods do
def register(...)
super(...).tap do |credential|
credential.update!(name: credential.authenticator.name) if credential.authenticator && credential.name.blank?
end
end
end
def authenticator
Passkey::Authenticator.find_by_aaguid(aaguid)
end
end