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,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