unmagic-passkeys 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE +9 -0
  5. data/README.md +151 -0
  6. data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
  7. data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
  8. data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
  9. data/app/models/unmagic/passkeys/credential.rb +103 -0
  10. data/config/importmap.rb +5 -0
  11. data/config/routes.rb +2 -0
  12. data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
  13. data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
  14. data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
  15. data/lib/unmagic/passkeys/engine.rb +78 -0
  16. data/lib/unmagic/passkeys/form_helper.rb +128 -0
  17. data/lib/unmagic/passkeys/holder.rb +143 -0
  18. data/lib/unmagic/passkeys/request.rb +77 -0
  19. data/lib/unmagic/passkeys/version.rb +5 -0
  20. data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
  21. data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
  22. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
  23. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
  24. data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
  25. data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
  26. data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
  27. data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
  28. data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
  29. data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
  30. data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
  31. data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
  32. data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
  33. data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
  34. data/lib/unmagic/passkeys/web_authn.rb +84 -0
  35. data/lib/unmagic/passkeys.rb +41 -0
  36. metadata +152 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Unmagic
7
+ module Passkeys
8
+ module Generators
9
+ # Installs unmagic-passkeys into a host app: copies the credentials migration
10
+ # (matching the host's primary-key type), imports the JavaScript, and prints the
11
+ # remaining controller/route/view wiring to do.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Copies the unmagic_passkeys_credentials migration and wires the JavaScript."
18
+
19
+ def create_migration_file
20
+ migration_template "create_unmagic_passkeys_credentials.rb.tt",
21
+ "db/migrate/create_unmagic_passkeys_credentials.rb"
22
+ end
23
+
24
+ def import_javascript
25
+ application_js = "app/javascript/application.js"
26
+
27
+ if File.exist?(File.join(destination_root, application_js))
28
+ append_to_file application_js, %(import "unmagic/passkeys"\n)
29
+ end
30
+ end
31
+
32
+ def show_post_install
33
+ readme "POST_INSTALL" if behavior == :invoke
34
+ end
35
+
36
+ private
37
+ def key_type
38
+ Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
39
+ end
40
+
41
+ def table_id_option
42
+ ", id: :#{key_type}" if key_type
43
+ end
44
+
45
+ def holder_id_type
46
+ key_type || :bigint
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+
2
+ unmagic-passkeys installed.
3
+
4
+ Next:
5
+
6
+ 1. Run the migration:
7
+ bin/rails db:migrate
8
+
9
+ 2. Add passkeys to your holder model:
10
+ class User < ApplicationRecord
11
+ has_passkeys name: :email_address, display_name: :name
12
+ end
13
+
14
+ 3. Wire up sign-in and registration controllers, routes and views.
15
+ See the README "Host wiring" section for copy-paste examples.
16
+
17
+ The challenge endpoint (POST /unmagic/passkeys/challenge) and the JavaScript
18
+ web components are already available.
19
+
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateUnmagicPasskeysCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :unmagic_passkeys_credentials<%= table_id_option %> do |t|
6
+ t.references :holder, polymorphic: true, null: false, type: <%= holder_id_type.to_sym.inspect %>
7
+ t.string :credential_id, null: false
8
+ t.binary :public_key, null: false
9
+ t.integer :sign_count, null: false, default: 0
10
+ t.string :name
11
+ t.text :transports
12
+ t.string :aaguid
13
+ t.boolean :backed_up
14
+ t.timestamps
15
+
16
+ t.index :credential_id, unique: true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Passkeys
5
+ # Integrates the WebAuthn and passkey subsystems into a host Rails app. We deliberately do
6
+ # NOT isolate_namespace: passkeys is an injected concern — +has_passkeys+ goes onto the host's
7
+ # models, the form helpers into its views, and a stateless challenge endpoint into its routes.
8
+ #
9
+ # == Configuration
10
+ #
11
+ # # config/initializers/passkeys.rb
12
+ # config.unmagic_passkeys.web_authn.default_creation_options = { attestation: :none }
13
+ # config.unmagic_passkeys.web_authn.default_request_options = { user_verification: :required }
14
+ # config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
15
+ class Engine < ::Rails::Engine
16
+ config.unmagic_passkeys = ActiveSupport::OrderedOptions.new
17
+ config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
18
+ config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
19
+ config.unmagic_passkeys.draw_routes = true
20
+ config.unmagic_passkeys.challenge_url = nil
21
+
22
+ config.unmagic_passkeys.web_authn = ActiveSupport::OrderedOptions.new
23
+ config.unmagic_passkeys.web_authn.default_request_options = {}
24
+ config.unmagic_passkeys.web_authn.default_creation_options = {}
25
+ config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
26
+ config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
27
+
28
+ initializer "unmagic_passkeys.routes" do |app|
29
+ passkey_config = config.unmagic_passkeys
30
+
31
+ app.routes.prepend do
32
+ if passkey_config.draw_routes
33
+ scope passkey_config.routes_prefix, as: :passkey do
34
+ post "/challenge" => "unmagic/passkeys/challenges#create", as: :challenge
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ initializer "unmagic_passkeys.holder" do
41
+ ActiveSupport.on_load(:active_record) do
42
+ # Shim: Holder references the Credential model (itself Active Record), so it can't be
43
+ # mixed in until Active Record is ready. The first call swaps in the real macro.
44
+ def self.has_passkeys(**options, &block)
45
+ include Unmagic::Passkeys::Holder
46
+ has_passkeys(**options, &block)
47
+ end
48
+ end
49
+ end
50
+
51
+ initializer "unmagic_passkeys.form_helper" do
52
+ ActiveSupport.on_load(:action_view) do
53
+ require "unmagic/passkeys/form_helper"
54
+ include Unmagic::Passkeys::FormHelper
55
+ end
56
+ end
57
+
58
+ initializer "unmagic_passkeys.request" do
59
+ ActiveSupport.on_load(:action_controller) do
60
+ require "unmagic/passkeys/request"
61
+ end
62
+ end
63
+
64
+ initializer "unmagic_passkeys.assets" do |app|
65
+ if app.config.respond_to?(:assets)
66
+ app.config.assets.paths << root.join("app/assets/javascripts").to_s
67
+ end
68
+ end
69
+
70
+ initializer "unmagic_passkeys.importmap", before: "importmap" do |app|
71
+ if app.config.respond_to?(:importmap)
72
+ app.config.importmap.paths << root.join("config/importmap.rb")
73
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,128 @@
1
+ # View helpers for rendering passkey registration and sign-in buttons.
2
+ module Unmagic::Passkeys::FormHelper
3
+ REGISTRATION_ERROR_MESSAGE = "Something went wrong while registering your passkey."
4
+ REGISTRATION_CANCELLED_MESSAGE = "Passkey registration was cancelled. Try again when you are ready."
5
+ REGISTRATION_DUPLICATE_MESSAGE = "You already have a passkey registered on this device. Remove the existing one first and try again."
6
+ SIGN_IN_ERROR_MESSAGE = "Something went wrong while signing in with your passkey."
7
+ SIGN_IN_CANCELLED_MESSAGE = "Passkey sign in was cancelled. Try again when you are ready."
8
+
9
+ # Renders a button for registering a new passkey. Accepts a +label+ string or a block
10
+ # for button content.
11
+ #
12
+ # Options:
13
+ # - +options+: WebAuthn creation options (JSON-serializable hash)
14
+ # - +challenge_url+: endpoint to refresh the challenge nonce
15
+ # - +wrapper+: HTML attributes for the outer web component element
16
+ # - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
17
+ # to set the form parameter namespace (default: +:passkey+)
18
+ # - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
19
+ # to override the default error text
20
+ # - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
21
+ # +:message+ key to override the default cancellation text
22
+ # - All other options are passed to the +<button>+ tag
23
+ def passkey_registration_button(name = nil, url = nil, **options, &block)
24
+ url, name = name, block ? capture(&block) : nil if block_given?
25
+ component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
26
+ error_options[:error][:message] ||= REGISTRATION_ERROR_MESSAGE
27
+ error_options[:cancellation][:message] ||= REGISTRATION_CANCELLED_MESSAGE
28
+ error_options[:duplicate][:message] ||= REGISTRATION_DUPLICATE_MESSAGE
29
+ param = form_options.delete(:param)
30
+
31
+ content_tag("unmagic-passkey-registration-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
32
+ tag.form(**form_options) do
33
+ hidden_field_tag(:authenticity_token, form_authenticity_token) +
34
+ hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
35
+ hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
36
+ hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
37
+ tag.button(name, type: :button, data: { passkey: "register" }, **button_options)
38
+ end + passkey_error_messages(**error_options)
39
+ end
40
+ end
41
+
42
+ # Renders a button for signing in with a passkey. Accepts a +label+ string or a block
43
+ # for button content.
44
+ #
45
+ # Options:
46
+ # - +options+: WebAuthn request options (JSON-serializable hash)
47
+ # - +challenge_url+: endpoint to refresh the challenge nonce
48
+ # - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
49
+ # - +wrapper+: HTML attributes for the outer web component element
50
+ # - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
51
+ # to set the form parameter namespace (default: +:passkey+)
52
+ # - +error+: HTML attributes for the error message +<div>+. Supports a +:message+ key
53
+ # to override the default error text
54
+ # - +cancellation+: HTML attributes for the cancellation message +<div>+. Supports a
55
+ # +:message+ key to override the default cancellation text
56
+ # - All other options are passed to the +<button>+ tag
57
+ def passkey_sign_in_button(name = nil, url = nil, **options, &block)
58
+ url, name = name, block ? capture(&block) : nil if block_given?
59
+ component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
60
+ error_options[:error][:message] ||= SIGN_IN_ERROR_MESSAGE
61
+ error_options[:cancellation][:message] ||= SIGN_IN_CANCELLED_MESSAGE
62
+ param = form_options.delete(:param)
63
+
64
+ content_tag("unmagic-passkey-sign-in-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
65
+ tag.form(**form_options) do
66
+ hidden_field_tag(:authenticity_token, form_authenticity_token) +
67
+ hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
68
+ hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
69
+ hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
70
+ hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
71
+ tag.button(name, type: :button, data: { passkey: "sign_in" }, **button_options)
72
+ end + passkey_error_messages(**error_options)
73
+ end
74
+ end
75
+
76
+ private
77
+ def partition_passkey_options(url, options)
78
+ passkey_options = options.fetch(:options, {})
79
+ wrapper_options = options.fetch(:wrapper, {})
80
+
81
+ component_options = options
82
+ .slice(:challenge_url, :mediation)
83
+ .reverse_merge(challenge_url: default_passkey_challenge_url, options: passkey_options.to_json(except: :challenge))
84
+
85
+ form_options = options
86
+ .fetch(:form, {})
87
+ .reverse_merge(method: :post, action: url, class: "button_to", param: :passkey)
88
+
89
+ error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {})
90
+
91
+ button_options = options.except(:options, :form, :wrapper, *component_options.keys, *error_options.keys)
92
+
93
+ [ wrapper_options.merge(component_options), form_options, button_options, error_options ]
94
+ end
95
+
96
+ def default_passkey_challenge_url
97
+ if challenge_url = Rails.configuration.unmagic_passkeys.challenge_url
98
+ instance_exec(&challenge_url)
99
+ else
100
+ passkey_challenge_path
101
+ end
102
+ end
103
+
104
+ def passkey_error_messages(error: {}, cancellation: {}, duplicate: {})
105
+ error_message, error_attributes = build_passkey_error_options("error", error)
106
+ cancellation_message, cancellation_attributes = build_passkey_error_options("cancelled", cancellation)
107
+
108
+ messages = tag.div(error_message, hidden: true, **error_attributes) +
109
+ tag.div(cancellation_message, hidden: true, **cancellation_attributes)
110
+
111
+ if duplicate[:message]
112
+ duplicate_message, duplicate_attributes = build_passkey_error_options("duplicate", duplicate)
113
+ messages += tag.div(duplicate_message, hidden: true, **duplicate_attributes)
114
+ end
115
+
116
+ messages
117
+ end
118
+
119
+ def build_passkey_error_options(type, options)
120
+ message = options[:message]
121
+
122
+ attributes = options.except(:message)
123
+ attributes[:data] ||= {}
124
+ attributes[:data][:passkey_error] = type
125
+
126
+ [ message, attributes ]
127
+ end
128
+ end
@@ -0,0 +1,143 @@
1
+ # Adds passkey support to an Active Record model (the "holder" of passkeys).
2
+ #
3
+ # == Usage
4
+ #
5
+ # class User < ApplicationRecord
6
+ # has_passkeys name: :email_address, display_name: :name
7
+ # end
8
+ #
9
+ # This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
10
+ # model that supply holder-specific options for the WebAuthn ceremonies:
11
+ #
12
+ # - +passkey_registration_options+ — merged into Unmagic::Passkeys::Credential.registration_options
13
+ # - +passkey_authentication_options+ — merged into Unmagic::Passkeys::Credential.authentication_options
14
+ #
15
+ # == Options
16
+ #
17
+ # +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
18
+ # fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
19
+ # or plain values:
20
+ #
21
+ # [+name+]
22
+ # A human-readable account identifier (typically an email or username) shown by the
23
+ # authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
24
+ #
25
+ # [+display_name+]
26
+ # A friendly label for the user (typically their full name) shown by the authenticator
27
+ # during passkey registration. Maps to the WebAuthn +user.displayName+ field.
28
+ #
29
+ # has_passkeys name: :email, display_name: :name
30
+ #
31
+ # For more complex configuration, pass a block that receives a Unmagic::Passkeys::Holder::Config:
32
+ #
33
+ # has_passkeys do |config|
34
+ # config.registration_options { { name: email, display_name: name } }
35
+ # config.authentication_options { { user_verification: "required" } }
36
+ # end
37
+ module Unmagic::Passkeys::Holder
38
+ extend ActiveSupport::Concern
39
+
40
+ class_methods do
41
+ # Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
42
+ # and defines +passkey_registration_options+ and +passkey_authentication_options+ instance methods used
43
+ # by Unmagic::Passkeys::Credential to build ceremony options.
44
+ #
45
+ # Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
46
+ # turned into holder-scoped option procs automatically. An optional block yields a Config
47
+ # for more complex setup.
48
+ def has_passkeys(**options, &block)
49
+ config = Config.new(**options)
50
+ block&.call(config)
51
+
52
+ has_many config.association_name,
53
+ as: :holder,
54
+ dependent: config.dependent,
55
+ class_name: "Unmagic::Passkeys::Credential"
56
+
57
+ define_method(:passkey_registration_options) do
58
+ {
59
+ id: id,
60
+ exclude_credentials: public_send(config.association_name)
61
+ }.merge(config.evaluate_registration_options(self))
62
+ end
63
+
64
+ define_method(:passkey_authentication_options) do
65
+ { credentials: public_send(config.association_name) }.merge(config.evaluate_authentication_options(self))
66
+ end
67
+ end
68
+ end
69
+
70
+ # Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
71
+ # custom association options and ceremony option blocks.
72
+ class Config
73
+ attr_accessor :association_name, :dependent
74
+
75
+ def initialize(**options)
76
+ @association_name = options.delete(:association_name) || :passkeys
77
+ @dependent = options.delete(:dependent) || :destroy
78
+
79
+ if creation_opts = extract_options_for(Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions, options)
80
+ @registration_options = options_to_proc(creation_opts)
81
+ end
82
+
83
+ if request_opts = extract_options_for(Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions, options)
84
+ @authentication_options = options_to_proc(request_opts)
85
+ end
86
+ end
87
+
88
+ # Sets a block to evaluate in the holder's context to produce additional authentication options.
89
+ #
90
+ # config.authentication_options { { user_verification: "required" } }
91
+ def authentication_options(&block)
92
+ @authentication_options = block
93
+ end
94
+
95
+ # Sets a block to evaluate in the holder's context to produce additional creation options.
96
+ #
97
+ # config.registration_options { { name: email, display_name: name } }
98
+ def registration_options(&block)
99
+ @registration_options = block
100
+ end
101
+
102
+ # Evaluates the request options block (if any) in the context of the given +record+. Called
103
+ # internally by the +passkey_authentication_options+ method defined on the holder.
104
+ def evaluate_authentication_options(record)
105
+ if @authentication_options
106
+ record.instance_exec(&@authentication_options)
107
+ else
108
+ {}
109
+ end
110
+ end
111
+
112
+ # Evaluates the creation options block (if any) in the context of the given +record+. Called
113
+ # internally by the +passkey_registration_options+ method defined on the holder.
114
+ def evaluate_registration_options(record)
115
+ if @registration_options
116
+ record.instance_exec(&@registration_options)
117
+ else
118
+ {}
119
+ end
120
+ end
121
+
122
+ private
123
+ def extract_options_for(klass, options)
124
+ keys = klass.attribute_names.map(&:to_sym)
125
+
126
+ extracted = options.slice(*keys)
127
+ options.except!(*keys)
128
+ extracted if extracted.any?
129
+ end
130
+
131
+ def options_to_proc(options)
132
+ proc do
133
+ options.transform_values do |value|
134
+ case value
135
+ when Symbol then send(value)
136
+ when Proc then instance_exec(&value)
137
+ else value
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,77 @@
1
+ # = Action Pack Passkey Request
2
+ #
3
+ # Controller concern that sets up the WebAuthn request context and provides
4
+ # helper methods for passkey registration and authentication. Include this
5
+ # in any controller that handles passkey form submissions.
6
+ #
7
+ # == Registration example
8
+ #
9
+ # class PasskeysController < ApplicationController
10
+ # include Unmagic::Passkeys::Request
11
+ #
12
+ # def new
13
+ # @registration_options = passkey_registration_options(holder: Current.user)
14
+ # end
15
+ #
16
+ # def create
17
+ # @passkey = Unmagic::Passkeys::Credential.register(
18
+ # passkey_registration_params, holder: Current.user
19
+ # )
20
+ # redirect_to settings_path
21
+ # end
22
+ # end
23
+ #
24
+ # == Authentication example
25
+ #
26
+ # class SessionsController < ApplicationController
27
+ # include Unmagic::Passkeys::Request
28
+ #
29
+ # def new
30
+ # @authentication_options = passkey_authentication_options
31
+ # end
32
+ #
33
+ # def create
34
+ # if passkey = Unmagic::Passkeys::Credential.authenticate(passkey_authentication_params)
35
+ # sign_in passkey.holder
36
+ # redirect_to root_path
37
+ # else
38
+ # redirect_to new_session_path, alert: "Authentication failed"
39
+ # end
40
+ # end
41
+ # end
42
+ #
43
+ # == Before Action
44
+ #
45
+ # Automatically populates +Unmagic::Passkeys::WebAuthn::Current+ with the request
46
+ # host and origin.
47
+ #
48
+ module Unmagic::Passkeys::Request
49
+ extend ActiveSupport::Concern
50
+
51
+ included do
52
+ before_action do
53
+ Unmagic::Passkeys::WebAuthn::Current.host = request.host
54
+ Unmagic::Passkeys::WebAuthn::Current.origin = request.base_url
55
+ end
56
+ end
57
+
58
+ # Returns strong parameters for the passkey registration ceremony.
59
+ def passkey_registration_params(param: :passkey)
60
+ params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
61
+ end
62
+
63
+ # Returns strong parameters for the passkey authentication ceremony.
64
+ def passkey_authentication_params(param: :passkey)
65
+ params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
66
+ end
67
+
68
+ # Returns RequestOptions for the authentication ceremony.
69
+ def passkey_authentication_options(**options)
70
+ Unmagic::Passkeys::Credential.authentication_options(**options)
71
+ end
72
+
73
+ # Returns RegistrationOptions for the registration ceremony.
74
+ def passkey_registration_options(**options)
75
+ Unmagic::Passkeys::Credential.registration_options(**options)
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ module Unmagic
2
+ module Passkeys
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,88 @@
1
+ # = Action Pack WebAuthn Assertion Response
2
+ #
3
+ # Handles the authenticator response from a WebAuthn authentication ceremony.
4
+ # When a user authenticates with an existing credential, the authenticator
5
+ # returns an assertion response containing a signature that proves possession
6
+ # of the private key.
7
+ #
8
+ # == Usage
9
+ #
10
+ # # Look up the credential by ID
11
+ # credential = user.credentials.find_by!(
12
+ # credential_id: params[:id]
13
+ # )
14
+ #
15
+ # response = Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse.new(
16
+ # client_data_json: params[:response][:clientDataJSON],
17
+ # authenticator_data: params[:response][:authenticatorData],
18
+ # signature: params[:response][:signature],
19
+ # credential: credential.to_public_key_credential,
20
+ # origin: "https://example.com"
21
+ # )
22
+ #
23
+ # response.validate!
24
+ #
25
+ # == Validation
26
+ #
27
+ # In addition to the base Response validations, this class verifies:
28
+ #
29
+ # * The client data type is "webauthn.get"
30
+ # * The signature is valid for the credential's public key
31
+ #
32
+ class Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse < Unmagic::Passkeys::WebAuthn::Authenticator::Response
33
+ attr_reader :credential, :authenticator_data, :signature
34
+
35
+ validate :client_data_type_must_be_get
36
+ validate :signature_must_be_valid
37
+ validate :sign_count_must_increase
38
+
39
+ def initialize(credential:, authenticator_data:, signature:, **attributes)
40
+ super(**attributes)
41
+ @credential = credential
42
+ @signature = signature
43
+ @signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
44
+ @authenticator_data = Unmagic::Passkeys::WebAuthn::Authenticator::Data.wrap(authenticator_data)
45
+ rescue ArgumentError
46
+ raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
47
+ end
48
+
49
+ private
50
+ def challenge_purpose
51
+ "authentication"
52
+ end
53
+
54
+ def client_data_type_must_be_get
55
+ unless client_data["type"] == "webauthn.get"
56
+ errors.add(:base, "Client data type is not webauthn.get")
57
+ end
58
+ end
59
+
60
+ def signature_must_be_valid
61
+ client_data_hash = Digest::SHA256.digest(client_data_json)
62
+ signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
63
+ digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
64
+
65
+ unless credential.public_key.verify(digest, signature, signed_data)
66
+ errors.add(:base, "Invalid signature")
67
+ end
68
+ rescue OpenSSL::PKey::PKeyError
69
+ errors.add(:base, "Invalid signature")
70
+ end
71
+
72
+ def sign_count_must_increase
73
+ unless sign_count_increased?
74
+ errors.add(:base, "Sign count did not increase")
75
+ end
76
+ end
77
+
78
+ def sign_count_increased?
79
+ if authenticator_data.sign_count.zero? && credential.sign_count.zero?
80
+ # Some authenticators always return 0 for the sign count, even after multiple authentications.
81
+ # In that case, we have to check that both the stored and returned sign counts are 0,
82
+ # which indicates that the authenticator is likely not updating the sign count.
83
+ true
84
+ else
85
+ authenticator_data.sign_count > credential.sign_count
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,73 @@
1
+ # = Action Pack WebAuthn Attestation
2
+ #
3
+ # Decodes and represents the attestation object returned by an authenticator
4
+ # during registration. The attestation object is CBOR-encoded and contains
5
+ # the authenticator data along with an optional attestation statement.
6
+ #
7
+ # == Usage
8
+ #
9
+ # attestation = Unmagic::Passkeys::WebAuthn::Authenticator::Attestation.decode(
10
+ # attestation_object_bytes
11
+ # )
12
+ #
13
+ # attestation.credential_id # => "abc123..."
14
+ # attestation.public_key # => OpenSSL::PKey::EC
15
+ # attestation.sign_count # => 0
16
+ #
17
+ # == Attributes
18
+ #
19
+ # [+authenticator_data+]
20
+ # The parsed Data containing credential information.
21
+ #
22
+ # [+format+]
23
+ # The attestation statement format (e.g., "none", "packed", "fido-u2f").
24
+ #
25
+ # [+attestation_statement+]
26
+ # The attestation statement, which may contain a signature from the
27
+ # authenticator manufacturer. Empty for "none" format.
28
+ #
29
+ # == Delegated Methods
30
+ #
31
+ # The following methods are delegated to +authenticator_data+:
32
+ #
33
+ # * +credential_id+ - Base64URL-encoded credential identifier
34
+ # * +public_key+ - OpenSSL public key object
35
+ # * +public_key_bytes+ - Raw COSE key bytes
36
+ # * +sign_count+ - Signature counter for replay detection
37
+ #
38
+ class Unmagic::Passkeys::WebAuthn::Authenticator::Attestation
39
+ attr_reader :authenticator_data, :format, :attestation_statement
40
+
41
+ delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
42
+
43
+ # Wraps raw attestation data into an Attestation instance. Accepts an
44
+ # existing Attestation object (returned as-is), a Base64URL-encoded string,
45
+ # or raw binary.
46
+ def self.wrap(data)
47
+ if data.is_a?(self)
48
+ data
49
+ else
50
+ data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
51
+ decode(data)
52
+ end
53
+ rescue ArgumentError
54
+ raise Unmagic::Passkeys::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
55
+ end
56
+
57
+ # Decodes a CBOR-encoded attestation object into an Attestation instance.
58
+ def self.decode(bytes)
59
+ cbor = Unmagic::Passkeys::WebAuthn::CborDecoder.decode(bytes)
60
+
61
+ new(
62
+ authenticator_data: Unmagic::Passkeys::WebAuthn::Authenticator::Data.decode(cbor["authData"]),
63
+ format: cbor["fmt"],
64
+ attestation_statement: cbor["attStmt"]
65
+ )
66
+ end
67
+
68
+ def initialize(authenticator_data:, format:, attestation_statement:)
69
+ @authenticator_data = authenticator_data
70
+ @format = format
71
+ @attestation_statement = attestation_statement
72
+ end
73
+ end