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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +151 -0
- data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
- data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
- data/app/models/unmagic/passkeys/credential.rb +103 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +2 -0
- data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
- data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
- data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
- data/lib/unmagic/passkeys/engine.rb +78 -0
- data/lib/unmagic/passkeys/form_helper.rb +128 -0
- data/lib/unmagic/passkeys/holder.rb +143 -0
- data/lib/unmagic/passkeys/request.rb +77 -0
- data/lib/unmagic/passkeys/version.rb +5 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
- data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
- data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
- data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
- data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
- data/lib/unmagic/passkeys/web_authn.rb +84 -0
- data/lib/unmagic/passkeys.rb +41 -0
- metadata +152 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# = Action Pack WebAuthn COSE Key
|
|
2
|
+
#
|
|
3
|
+
# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in
|
|
4
|
+
# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators
|
|
5
|
+
# return public keys in COSE format, which must be converted to a standard format
|
|
6
|
+
# for signature verification.
|
|
7
|
+
#
|
|
8
|
+
# == Usage
|
|
9
|
+
#
|
|
10
|
+
# # Decode a COSE key from CBOR bytes (e.g., from authenticator data)
|
|
11
|
+
# cose_key = Unmagic::Passkeys::WebAuthn::CoseKey.decode(cbor_bytes)
|
|
12
|
+
#
|
|
13
|
+
# # Convert to OpenSSL key for signature verification
|
|
14
|
+
# openssl_key = cose_key.to_openssl_key
|
|
15
|
+
# openssl_key.verify("SHA256", signature, signed_data)
|
|
16
|
+
#
|
|
17
|
+
# == Supported Algorithms
|
|
18
|
+
#
|
|
19
|
+
# [ES256]
|
|
20
|
+
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
|
|
21
|
+
#
|
|
22
|
+
# [EdDSA]
|
|
23
|
+
# EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.
|
|
24
|
+
#
|
|
25
|
+
# [RS256]
|
|
26
|
+
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
|
|
27
|
+
#
|
|
28
|
+
# == Attributes
|
|
29
|
+
#
|
|
30
|
+
# [+key_type+]
|
|
31
|
+
# The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).
|
|
32
|
+
#
|
|
33
|
+
# [+algorithm+]
|
|
34
|
+
# The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).
|
|
35
|
+
#
|
|
36
|
+
# [+parameters+]
|
|
37
|
+
# The full COSE key parameters map, including curve and coordinate data.
|
|
38
|
+
class Unmagic::Passkeys::WebAuthn::CoseKey
|
|
39
|
+
P256_COORDINATE_LENGTH = 32
|
|
40
|
+
MINIMUM_RSA_KEY_BITS = 2048
|
|
41
|
+
|
|
42
|
+
# COSE key labels
|
|
43
|
+
KEY_TYPE_LABEL = 1
|
|
44
|
+
ALGORITHM_LABEL = 3
|
|
45
|
+
EC2_CURVE_LABEL = -1
|
|
46
|
+
EC2_X_LABEL = -2
|
|
47
|
+
EC2_Y_LABEL = -3
|
|
48
|
+
RSA_N_LABEL = -1
|
|
49
|
+
RSA_E_LABEL = -2
|
|
50
|
+
OKP_CURVE_LABEL = -1
|
|
51
|
+
OKP_X_LABEL = -2
|
|
52
|
+
|
|
53
|
+
# COSE key types
|
|
54
|
+
OKP = 1
|
|
55
|
+
EC2 = 2
|
|
56
|
+
RSA = 3
|
|
57
|
+
|
|
58
|
+
# COSE algorithms
|
|
59
|
+
ES256 = -7
|
|
60
|
+
EDDSA = -8
|
|
61
|
+
RS256 = -257
|
|
62
|
+
|
|
63
|
+
# COSE EC2 curves
|
|
64
|
+
P256 = 1
|
|
65
|
+
|
|
66
|
+
# COSE OKP curves
|
|
67
|
+
ED25519 = 6
|
|
68
|
+
|
|
69
|
+
# OpenSSL types
|
|
70
|
+
UNCOMPRESSED_POINT_MARKER = 0x04
|
|
71
|
+
|
|
72
|
+
attr_reader :key_type, :algorithm, :parameters
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# Decodes a COSE key from CBOR-encoded bytes.
|
|
76
|
+
#
|
|
77
|
+
# cose_key = Unmagic::Passkeys::WebAuthn::CoseKey.decode(cbor_bytes)
|
|
78
|
+
# cose_key.algorithm # => -7 (ES256)
|
|
79
|
+
def decode(bytes)
|
|
80
|
+
data = Unmagic::Passkeys::WebAuthn::CborDecoder.decode(bytes)
|
|
81
|
+
new(
|
|
82
|
+
key_type: data[KEY_TYPE_LABEL],
|
|
83
|
+
algorithm: data[ALGORITHM_LABEL],
|
|
84
|
+
parameters: data
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def initialize(key_type:, algorithm:, parameters:) # :nodoc:
|
|
90
|
+
@key_type = key_type
|
|
91
|
+
@algorithm = algorithm
|
|
92
|
+
@parameters = parameters
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Converts the COSE key to an OpenSSL public key object.
|
|
96
|
+
#
|
|
97
|
+
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
|
|
98
|
+
# RSA keys, or an Ed25519 key for OKP keys, suitable for use with
|
|
99
|
+
# +OpenSSL::PKey#verify+.
|
|
100
|
+
#
|
|
101
|
+
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
|
|
102
|
+
# is not supported.
|
|
103
|
+
def to_openssl_key
|
|
104
|
+
case [ key_type, algorithm ]
|
|
105
|
+
when [ EC2, ES256 ] then build_ec2_es256_key
|
|
106
|
+
when [ OKP, EDDSA ] then build_okp_eddsa_key
|
|
107
|
+
when [ RSA, RS256 ] then build_rsa_rs256_key
|
|
108
|
+
else raise Unmagic::Passkeys::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
def build_ec2_es256_key
|
|
114
|
+
curve = parameters[EC2_CURVE_LABEL]
|
|
115
|
+
raise Unmagic::Passkeys::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
|
|
116
|
+
|
|
117
|
+
x = parameters[EC2_X_LABEL]
|
|
118
|
+
y = parameters[EC2_Y_LABEL]
|
|
119
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil?
|
|
120
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH
|
|
121
|
+
|
|
122
|
+
# Uncompressed point format: 0x04 || x || y
|
|
123
|
+
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
|
|
124
|
+
|
|
125
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
126
|
+
OpenSSL::ASN1::Sequence([
|
|
127
|
+
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
|
|
128
|
+
OpenSSL::ASN1::ObjectId("prime256v1")
|
|
129
|
+
]),
|
|
130
|
+
OpenSSL::ASN1::BitString(public_key_bytes)
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
|
134
|
+
rescue OpenSSL::PKey::PKeyError => error
|
|
135
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_okp_eddsa_key
|
|
139
|
+
curve = parameters[OKP_CURVE_LABEL]
|
|
140
|
+
raise Unmagic::Passkeys::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519
|
|
141
|
+
|
|
142
|
+
x = parameters[OKP_X_LABEL]
|
|
143
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil?
|
|
144
|
+
|
|
145
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
146
|
+
OpenSSL::ASN1::Sequence([
|
|
147
|
+
OpenSSL::ASN1::ObjectId("ED25519")
|
|
148
|
+
]),
|
|
149
|
+
OpenSSL::ASN1::BitString(x)
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
OpenSSL::PKey.read(asn1.to_der)
|
|
153
|
+
rescue OpenSSL::PKey::PKeyError => error
|
|
154
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_rsa_rs256_key
|
|
158
|
+
n_bytes = parameters[RSA_N_LABEL]
|
|
159
|
+
e_bytes = parameters[RSA_E_LABEL]
|
|
160
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil?
|
|
161
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS
|
|
162
|
+
|
|
163
|
+
n = OpenSSL::BN.new(n_bytes, 2)
|
|
164
|
+
e = OpenSSL::BN.new(e_bytes, 2)
|
|
165
|
+
|
|
166
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
167
|
+
OpenSSL::ASN1::Sequence([
|
|
168
|
+
OpenSSL::ASN1::ObjectId("rsaEncryption"),
|
|
169
|
+
OpenSSL::ASN1::Null.new(nil)
|
|
170
|
+
]),
|
|
171
|
+
OpenSSL::ASN1::BitString(
|
|
172
|
+
OpenSSL::ASN1::Sequence([
|
|
173
|
+
OpenSSL::ASN1::Integer(n),
|
|
174
|
+
OpenSSL::ASN1::Integer(e)
|
|
175
|
+
]).to_der
|
|
176
|
+
)
|
|
177
|
+
])
|
|
178
|
+
|
|
179
|
+
OpenSSL::PKey::RSA.new(asn1.to_der)
|
|
180
|
+
rescue OpenSSL::PKey::PKeyError => error
|
|
181
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Current Attributes
|
|
2
|
+
#
|
|
3
|
+
# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are
|
|
4
|
+
# set automatically by Unmagic::Passkeys::Request at the start of each
|
|
5
|
+
# request and consumed by the registration/authentication flows.
|
|
6
|
+
#
|
|
7
|
+
# == Attributes
|
|
8
|
+
#
|
|
9
|
+
# [+host+]
|
|
10
|
+
# The relying party identifier (typically +request.host+). Used as the
|
|
11
|
+
# default RelyingParty ID.
|
|
12
|
+
#
|
|
13
|
+
# [+origin+]
|
|
14
|
+
# The expected origin (typically +request.base_url+). Validated against the
|
|
15
|
+
# +origin+ field in the authenticator's client data.
|
|
16
|
+
#
|
|
17
|
+
class Unmagic::Passkeys::WebAuthn::Current < ActiveSupport::CurrentAttributes
|
|
18
|
+
attribute :host, :origin
|
|
19
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Public Key Credential Creation Options
|
|
2
|
+
#
|
|
3
|
+
# Generates options for the WebAuthn registration ceremony (creating a new
|
|
4
|
+
# credential). These options are passed to +navigator.credentials.create()+ in
|
|
5
|
+
# the browser to prompt the user to register an authenticator.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# options = Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
|
10
|
+
# id: current_user.id,
|
|
11
|
+
# name: current_user.email,
|
|
12
|
+
# display_name: current_user.name
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
|
16
|
+
# render json: { publicKey: options.as_json }
|
|
17
|
+
#
|
|
18
|
+
# == Attributes
|
|
19
|
+
#
|
|
20
|
+
# [+id+]
|
|
21
|
+
# A unique identifier for the user account. Will be Base64URL-encoded in the
|
|
22
|
+
# output. This should be an opaque identifier (like a primary key), not
|
|
23
|
+
# personally identifiable information.
|
|
24
|
+
#
|
|
25
|
+
# [+name+]
|
|
26
|
+
# A human-readable identifier for the user account, typically an email
|
|
27
|
+
# address or username. Displayed by the authenticator.
|
|
28
|
+
#
|
|
29
|
+
# [+display_name+]
|
|
30
|
+
# A human-friendly name for the user, typically their full name. Displayed
|
|
31
|
+
# by the authenticator during registration.
|
|
32
|
+
#
|
|
33
|
+
# [+relying_party+]
|
|
34
|
+
# The relying party (your application) configuration. Defaults to
|
|
35
|
+
# +Unmagic::Passkeys::WebAuthn.relying_party+.
|
|
36
|
+
#
|
|
37
|
+
# == Supported Algorithms
|
|
38
|
+
#
|
|
39
|
+
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
|
|
40
|
+
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
|
|
41
|
+
# the vast majority of authenticators.
|
|
42
|
+
class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions < Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options
|
|
43
|
+
ES256 = { type: "public-key", alg: -7 }.freeze
|
|
44
|
+
EDDSA = { type: "public-key", alg: -8 }.freeze
|
|
45
|
+
RS256 = { type: "public-key", alg: -257 }.freeze
|
|
46
|
+
RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze
|
|
47
|
+
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
|
|
48
|
+
|
|
49
|
+
attribute :id
|
|
50
|
+
attribute :name
|
|
51
|
+
attribute :display_name
|
|
52
|
+
attribute :resident_key, default: :required
|
|
53
|
+
attribute :exclude_credentials, default: -> { [] }
|
|
54
|
+
attribute :attestation, default: :none
|
|
55
|
+
attribute :challenge_expiration, default: -> { Rails.configuration.unmagic_passkeys.web_authn.creation_challenge_expiration }
|
|
56
|
+
attribute :challenge_purpose, default: "registration"
|
|
57
|
+
|
|
58
|
+
validates :id, :name, :display_name, presence: true
|
|
59
|
+
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
|
|
60
|
+
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
|
|
61
|
+
|
|
62
|
+
def initialize(attributes = {})
|
|
63
|
+
super
|
|
64
|
+
self.resident_key = resident_key.to_sym
|
|
65
|
+
self.attestation = attestation.to_sym
|
|
66
|
+
validate!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns a Hash suitable for JSON serialization and passing to the
|
|
70
|
+
# WebAuthn JavaScript API.
|
|
71
|
+
def as_json(options = {})
|
|
72
|
+
json = {
|
|
73
|
+
challenge: challenge,
|
|
74
|
+
rp: relying_party.as_json,
|
|
75
|
+
user: {
|
|
76
|
+
id: Base64.urlsafe_encode64(id.to_s, padding: false),
|
|
77
|
+
name: name,
|
|
78
|
+
displayName: display_name
|
|
79
|
+
},
|
|
80
|
+
pubKeyCredParams: [
|
|
81
|
+
ES256,
|
|
82
|
+
EDDSA,
|
|
83
|
+
RS256
|
|
84
|
+
],
|
|
85
|
+
authenticatorSelection: {
|
|
86
|
+
residentKey: resident_key.to_s,
|
|
87
|
+
requireResidentKey: resident_key == :required,
|
|
88
|
+
userVerification: user_verification.to_s
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if exclude_credentials.any?
|
|
93
|
+
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if attestation != :none
|
|
97
|
+
json[:attestation] = attestation.to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
json.as_json(options)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
def exclude_credential_json(credential)
|
|
105
|
+
hash = { type: "public-key", id: credential.id }
|
|
106
|
+
hash[:transports] = credential.transports if credential.transports.any?
|
|
107
|
+
hash
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Public Key Credential Options
|
|
2
|
+
#
|
|
3
|
+
# Abstract base class for WebAuthn ceremony options. Provides shared
|
|
4
|
+
# attributes and challenge generation for both CreationOptions (registration)
|
|
5
|
+
# and RequestOptions (authentication).
|
|
6
|
+
#
|
|
7
|
+
# This class should not be instantiated directly. Use CreationOptions or
|
|
8
|
+
# RequestOptions instead.
|
|
9
|
+
#
|
|
10
|
+
# == Challenge Generation
|
|
11
|
+
#
|
|
12
|
+
# Each options object generates a signed, expiring challenge via
|
|
13
|
+
# +Unmagic::Passkeys::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
|
|
14
|
+
# and includes an embedded timestamp so the server can reject stale challenges.
|
|
15
|
+
#
|
|
16
|
+
# == Attributes
|
|
17
|
+
#
|
|
18
|
+
# [+user_verification+]
|
|
19
|
+
# Controls whether user verification (biometrics/PIN) is required. One of
|
|
20
|
+
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
|
|
21
|
+
#
|
|
22
|
+
# [+relying_party+]
|
|
23
|
+
# The RelyingParty configuration. Defaults to +Unmagic::Passkeys::WebAuthn.relying_party+.
|
|
24
|
+
#
|
|
25
|
+
# [+challenge_expiration+]
|
|
26
|
+
# How long the challenge remains valid. Defaults vary by ceremony type
|
|
27
|
+
# (configured in the Railtie).
|
|
28
|
+
#
|
|
29
|
+
class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options
|
|
30
|
+
include ActiveModel::API
|
|
31
|
+
include ActiveModel::Attributes
|
|
32
|
+
|
|
33
|
+
CHALLENGE_LENGTH = 32
|
|
34
|
+
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
|
|
35
|
+
|
|
36
|
+
attribute :user_verification, default: :preferred
|
|
37
|
+
attribute :relying_party, default: -> { Unmagic::Passkeys::WebAuthn.relying_party }
|
|
38
|
+
attribute :challenge_expiration
|
|
39
|
+
attribute :challenge_purpose
|
|
40
|
+
|
|
41
|
+
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
|
|
42
|
+
|
|
43
|
+
def initialize(attributes = {})
|
|
44
|
+
super
|
|
45
|
+
self.user_verification = user_verification.to_sym
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
|
|
49
|
+
def validate!
|
|
50
|
+
super
|
|
51
|
+
rescue ActiveModel::ValidationError
|
|
52
|
+
raise Unmagic::Passkeys::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns a human-readable representation of the options.
|
|
56
|
+
def inspect
|
|
57
|
+
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
|
|
58
|
+
"#<#{self.class.name} #{attributes_string}>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns a Base64URL-encoded signed challenge containing a random nonce and
|
|
62
|
+
# an embedded timestamp. The challenge is generated once and memoized for the
|
|
63
|
+
# lifetime of this object.
|
|
64
|
+
#
|
|
65
|
+
# The timestamp allows the server to reject stale challenges. The expiration
|
|
66
|
+
# window is configurable per-ceremony via
|
|
67
|
+
# +config.unmagic_passkeys.web_authn.creation_challenge_expiration+ and
|
|
68
|
+
# +config.unmagic_passkeys.web_authn.request_challenge_expiration+, or per-instance
|
|
69
|
+
# via the +challenge_expiration+ attribute.
|
|
70
|
+
def challenge
|
|
71
|
+
@challenge ||= Base64.urlsafe_encode64(
|
|
72
|
+
Unmagic::Passkeys::WebAuthn.challenge_verifier.generate(
|
|
73
|
+
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
|
|
74
|
+
expires_in: challenge_expiration,
|
|
75
|
+
purpose: challenge_purpose
|
|
76
|
+
),
|
|
77
|
+
padding: false
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Public Key Credential Request Options
|
|
2
|
+
#
|
|
3
|
+
# Generates options for the WebAuthn authentication ceremony (using an existing
|
|
4
|
+
# credential). These options are passed to +navigator.credentials.get()+ in
|
|
5
|
+
# the browser to prompt the user to authenticate with a registered authenticator.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# options = Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
|
10
|
+
# credentials: current_user.webauthn_credentials
|
|
11
|
+
# )
|
|
12
|
+
#
|
|
13
|
+
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
|
14
|
+
# render json: { publicKey: options.as_json }
|
|
15
|
+
#
|
|
16
|
+
# == Attributes
|
|
17
|
+
#
|
|
18
|
+
# [+credentials+]
|
|
19
|
+
# A collection of credential records for the user. Each credential must
|
|
20
|
+
# respond to +id+ returning the Base64URL-encoded credential ID, and
|
|
21
|
+
# +transports+ returning an array of transport strings.
|
|
22
|
+
#
|
|
23
|
+
# [+relying_party+]
|
|
24
|
+
# The relying party (your application) configuration. Defaults to
|
|
25
|
+
# +Unmagic::Passkeys::WebAuthn.relying_party+.
|
|
26
|
+
class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions < Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options
|
|
27
|
+
attribute :credentials, default: -> { [] }
|
|
28
|
+
attribute :challenge_expiration, default: -> { Rails.configuration.unmagic_passkeys.web_authn.request_challenge_expiration }
|
|
29
|
+
attribute :challenge_purpose, default: "authentication"
|
|
30
|
+
|
|
31
|
+
def initialize(attributes = {})
|
|
32
|
+
super
|
|
33
|
+
validate!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns a Hash suitable for JSON serialization and passing to the
|
|
37
|
+
# WebAuthn JavaScript API.
|
|
38
|
+
def as_json(options = {})
|
|
39
|
+
json = {
|
|
40
|
+
challenge: challenge,
|
|
41
|
+
rpId: relying_party.id,
|
|
42
|
+
allowCredentials: credentials.map { |credential| allow_credential_json(credential) },
|
|
43
|
+
userVerification: user_verification.to_s
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
json.as_json(options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
def allow_credential_json(credential)
|
|
51
|
+
hash = { type: "public-key", id: credential.id }
|
|
52
|
+
hash[:transports] = credential.transports if credential.transports.any?
|
|
53
|
+
hash
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Public Key Credential
|
|
2
|
+
#
|
|
3
|
+
# Represents a WebAuthn public key credential and orchestrates the registration
|
|
4
|
+
# and authentication ceremonies. During registration (+.register+), it verifies
|
|
5
|
+
# the attestation response and returns a new credential. During authentication
|
|
6
|
+
# (+#authenticate+), it verifies the assertion response against the stored
|
|
7
|
+
# public key.
|
|
8
|
+
#
|
|
9
|
+
# == Registration
|
|
10
|
+
#
|
|
11
|
+
# credential = Unmagic::Passkeys::WebAuthn::PublicKeyCredential.register(
|
|
12
|
+
# params[:passkey],
|
|
13
|
+
# origin: Unmagic::Passkeys::WebAuthn::Current.origin
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# credential.id # => Base64URL-encoded credential ID
|
|
17
|
+
# credential.public_key # => OpenSSL::PKey::EC
|
|
18
|
+
# credential.sign_count # => 0
|
|
19
|
+
#
|
|
20
|
+
# == Authentication
|
|
21
|
+
#
|
|
22
|
+
# credential = Unmagic::Passkeys::WebAuthn::PublicKeyCredential.new(
|
|
23
|
+
# id: stored_credential_id,
|
|
24
|
+
# public_key: stored_public_key,
|
|
25
|
+
# sign_count: stored_sign_count
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# credential.authenticate(params[:passkey])
|
|
29
|
+
#
|
|
30
|
+
# == Ceremony Options
|
|
31
|
+
#
|
|
32
|
+
# Use +.creation_options+ and +.request_options+ to generate the JSON options
|
|
33
|
+
# passed to the browser's +navigator.credentials.create()+ and
|
|
34
|
+
# +navigator.credentials.get()+ calls.
|
|
35
|
+
#
|
|
36
|
+
# == Attributes
|
|
37
|
+
#
|
|
38
|
+
# [+id+]
|
|
39
|
+
# The Base64URL-encoded credential identifier.
|
|
40
|
+
#
|
|
41
|
+
# [+public_key+]
|
|
42
|
+
# The OpenSSL public key for signature verification.
|
|
43
|
+
#
|
|
44
|
+
# [+sign_count+]
|
|
45
|
+
# The signature counter, used for replay detection.
|
|
46
|
+
#
|
|
47
|
+
# [+aaguid+]
|
|
48
|
+
# The authenticator attestation GUID (set during registration).
|
|
49
|
+
#
|
|
50
|
+
# [+backed_up+]
|
|
51
|
+
# Whether the credential is backed up to cloud storage (synced passkey).
|
|
52
|
+
#
|
|
53
|
+
# [+transports+]
|
|
54
|
+
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
|
|
55
|
+
#
|
|
56
|
+
class Unmagic::Passkeys::WebAuthn::PublicKeyCredential
|
|
57
|
+
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
# Returns a RequestOptions object for the authentication ceremony.
|
|
61
|
+
# Credentials responding to +to_public_key_credential+ are automatically
|
|
62
|
+
# transformed.
|
|
63
|
+
def request_options(**attributes)
|
|
64
|
+
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
|
|
65
|
+
|
|
66
|
+
Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns a CreationOptions object for the registration ceremony.
|
|
70
|
+
# Credentials in +exclude_credentials+ responding to
|
|
71
|
+
# +to_public_key_credential+ are automatically transformed.
|
|
72
|
+
def creation_options(**attributes)
|
|
73
|
+
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
|
|
74
|
+
|
|
75
|
+
Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Verifies an attestation response from the browser and returns a new
|
|
79
|
+
# PublicKeyCredential with the registered credential data.
|
|
80
|
+
#
|
|
81
|
+
# Raises +InvalidResponseError+ if the attestation is invalid.
|
|
82
|
+
def register(params, origin: Unmagic::Passkeys::WebAuthn::Current.origin)
|
|
83
|
+
response = Unmagic::Passkeys::WebAuthn::Authenticator::AttestationResponse.new(
|
|
84
|
+
client_data_json: params[:client_data_json],
|
|
85
|
+
attestation_object: params[:attestation_object],
|
|
86
|
+
origin: origin
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response.validate!
|
|
90
|
+
|
|
91
|
+
new(
|
|
92
|
+
id: response.attestation.credential_id,
|
|
93
|
+
public_key: response.attestation.public_key,
|
|
94
|
+
sign_count: response.attestation.sign_count,
|
|
95
|
+
aaguid: response.attestation.aaguid,
|
|
96
|
+
backed_up: response.attestation.backed_up?,
|
|
97
|
+
transports: Array(params[:transports])
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
def transform_credentials(credentials)
|
|
103
|
+
Array(credentials).map do |credential|
|
|
104
|
+
if credential.respond_to?(:to_public_key_credential)
|
|
105
|
+
credential.to_public_key_credential
|
|
106
|
+
else
|
|
107
|
+
credential
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
|
|
114
|
+
@id = id
|
|
115
|
+
@public_key = public_key
|
|
116
|
+
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
|
|
117
|
+
@sign_count = sign_count
|
|
118
|
+
@aaguid = aaguid
|
|
119
|
+
@backed_up = backed_up
|
|
120
|
+
@transports = transports
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Verifies an assertion response against this credential's public key.
|
|
124
|
+
# Updates +sign_count+ and +backed_up+ on success.
|
|
125
|
+
#
|
|
126
|
+
# Raises +InvalidResponseError+ if the assertion is invalid.
|
|
127
|
+
def authenticate(params, origin: Unmagic::Passkeys::WebAuthn::Current.origin)
|
|
128
|
+
response = Unmagic::Passkeys::WebAuthn::Authenticator::AssertionResponse.new(
|
|
129
|
+
client_data_json: params[:client_data_json],
|
|
130
|
+
authenticator_data: params[:authenticator_data],
|
|
131
|
+
signature: params[:signature],
|
|
132
|
+
credential: self,
|
|
133
|
+
origin: origin
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
response.validate!
|
|
137
|
+
|
|
138
|
+
@sign_count = response.authenticator_data.sign_count
|
|
139
|
+
@backed_up = response.authenticator_data.backed_up?
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Returns a Hash of the credential data suitable for persisting.
|
|
143
|
+
def to_h
|
|
144
|
+
{
|
|
145
|
+
credential_id: id,
|
|
146
|
+
public_key: public_key.to_der,
|
|
147
|
+
sign_count: sign_count,
|
|
148
|
+
aaguid: aaguid,
|
|
149
|
+
backed_up: backed_up,
|
|
150
|
+
transports: transports
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# = Action Pack WebAuthn Relying Party
|
|
2
|
+
#
|
|
3
|
+
# Represents the relying party (your application) in WebAuthn ceremonies. The
|
|
4
|
+
# relying party identity is sent to authenticators during registration and
|
|
5
|
+
# authentication to scope credentials to your application.
|
|
6
|
+
#
|
|
7
|
+
# == Usage
|
|
8
|
+
#
|
|
9
|
+
# # Using defaults (host from Current, name from Rails application)
|
|
10
|
+
# relying_party = Unmagic::Passkeys::WebAuthn::RelyingParty.new
|
|
11
|
+
#
|
|
12
|
+
# # With explicit values
|
|
13
|
+
# relying_party = Unmagic::Passkeys::WebAuthn::RelyingParty.new(
|
|
14
|
+
# id: "example.com",
|
|
15
|
+
# name: "Example Application"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# == Attributes
|
|
19
|
+
#
|
|
20
|
+
# [+id+]
|
|
21
|
+
# The relying party identifier, typically the application's domain name
|
|
22
|
+
# (e.g., "example.com"). This must match the origin's effective domain
|
|
23
|
+
# or be a registrable domain suffix of it. Credentials are scoped to this
|
|
24
|
+
# identifier. Defaults to +Unmagic::Passkeys::WebAuthn::Current.host+.
|
|
25
|
+
#
|
|
26
|
+
# [+name+]
|
|
27
|
+
# A human-readable name for your application, displayed by authenticators
|
|
28
|
+
# during ceremonies. Defaults to +Rails.application.name+.
|
|
29
|
+
class Unmagic::Passkeys::WebAuthn::RelyingParty
|
|
30
|
+
attr_reader :id, :name
|
|
31
|
+
|
|
32
|
+
# Creates a new relying party configuration.
|
|
33
|
+
#
|
|
34
|
+
# ==== Options
|
|
35
|
+
#
|
|
36
|
+
# [+:id+]
|
|
37
|
+
# Optional. The relying party identifier (domain).
|
|
38
|
+
#
|
|
39
|
+
# [+:name+]
|
|
40
|
+
# Optional. The application display name.
|
|
41
|
+
def initialize(id: Unmagic::Passkeys::WebAuthn::Current.host, name: Rails.application.name)
|
|
42
|
+
@id = id
|
|
43
|
+
@name = name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns a Hash suitable for JSON serialization.
|
|
47
|
+
def as_json(*)
|
|
48
|
+
{ id: id, name: name }
|
|
49
|
+
end
|
|
50
|
+
end
|