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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user