Auto-suggest name for new Passkeys
- Use plain client data json everywhere - Expose AAGUID and backed_up on Credentials - Make attestation verifiers configurable - Auto-suggest names for passkeys and show icons
This commit is contained in:
@@ -3,5 +3,15 @@ module ActionPack::WebAuthn
|
||||
def relying_party
|
||||
RelyingParty.new
|
||||
end
|
||||
|
||||
def attestation_verifiers
|
||||
@attestation_verifiers ||= {
|
||||
"none" => Authenticator::AttestationVerifiers::None.new
|
||||
}
|
||||
end
|
||||
|
||||
def register_attestation_verifier(format, verifier)
|
||||
attestation_verifiers[format.to_s] = verifier
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
class ActionPack::WebAuthn::Authenticator::Attestation
|
||||
attr_reader :authenticator_data, :format, :attestation_statement
|
||||
|
||||
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, to: :authenticator_data
|
||||
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
|
||||
|
||||
def self.decode(bytes)
|
||||
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
# In addition to the base Response validations, this class verifies:
|
||||
#
|
||||
# * The client data type is "webauthn.create"
|
||||
# * The attestation format is "none" (other formats require certificate verification)
|
||||
# * The attestation format has a registered verifier
|
||||
# * The attestation statement passes format-specific verification
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze
|
||||
attr_reader :attestation_object
|
||||
|
||||
def initialize(attestation_object:, **attributes)
|
||||
@@ -43,9 +43,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::Web
|
||||
raise InvalidResponseError, "Client data type is not webauthn.create"
|
||||
end
|
||||
|
||||
unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format)
|
||||
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
|
||||
|
||||
def attestation
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# = Action Pack WebAuthn None Attestation Verifier
|
||||
#
|
||||
# Verifies attestation responses with the "none" format, which indicates the
|
||||
# authenticator did not provide any attestation statement. This is the default
|
||||
# format used by most consumer authenticators.
|
||||
#
|
||||
# == Implementing Custom Verifiers
|
||||
#
|
||||
# To support other attestation formats (e.g., "packed", "fido-u2f"), implement
|
||||
# a class with the same +verify!+ interface and register it:
|
||||
#
|
||||
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
|
||||
#
|
||||
# The +verify!+ method receives the decoded +Attestation+ object and the raw
|
||||
# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
|
||||
def verify!(attestation, client_data_json:)
|
||||
if attestation.attestation_statement.present?
|
||||
raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
|
||||
"Attestation statement must be empty for 'none' format"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -57,7 +57,7 @@ class ActionPack::WebAuthn::Authenticator::Data
|
||||
BACKUP_STATE_FLAG = 0x10
|
||||
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
|
||||
|
||||
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes
|
||||
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
|
||||
|
||||
class << self
|
||||
def wrap(data)
|
||||
@@ -81,10 +81,13 @@ class ActionPack::WebAuthn::Authenticator::Data
|
||||
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
|
||||
position += SIGN_COUNT_LENGTH
|
||||
|
||||
aaguid = nil
|
||||
credential_id = nil
|
||||
public_key_bytes = nil
|
||||
|
||||
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
|
||||
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
|
||||
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
|
||||
position += AAGUID_LENGTH
|
||||
|
||||
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
|
||||
@@ -101,17 +104,19 @@ class ActionPack::WebAuthn::Authenticator::Data
|
||||
relying_party_id_hash: relying_party_id_hash,
|
||||
flags: flags,
|
||||
sign_count: sign_count,
|
||||
aaguid: aaguid,
|
||||
credential_id: credential_id,
|
||||
public_key_bytes: public_key_bytes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, credential_id:, public_key_bytes:)
|
||||
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
|
||||
@bytes = bytes
|
||||
@relying_party_id_hash = relying_party_id_hash
|
||||
@flags = flags
|
||||
@sign_count = sign_count
|
||||
@aaguid = aaguid
|
||||
@credential_id = credential_id
|
||||
@public_key_bytes = public_key_bytes
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
|
||||
attribute :host
|
||||
attribute :host, :origin
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ActionPack::WebAuthn::PublicKeyCredential
|
||||
attr_reader :id, :public_key, :sign_count, :transports, :owner
|
||||
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner
|
||||
|
||||
class << self
|
||||
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
|
||||
@@ -14,16 +14,20 @@ class ActionPack::WebAuthn::PublicKeyCredential
|
||||
id: response.attestation.credential_id,
|
||||
public_key: response.attestation.public_key,
|
||||
sign_count: response.attestation.sign_count,
|
||||
aaguid: response.attestation.aaguid,
|
||||
backed_up: response.attestation.backed_up?,
|
||||
transports: transports,
|
||||
owner: owner
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(id:, public_key:, sign_count:, transports: [], owner: nil)
|
||||
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil)
|
||||
@id = id
|
||||
@public_key = public_key
|
||||
@sign_count = sign_count
|
||||
@aaguid = aaguid
|
||||
@backed_up = backed_up
|
||||
@transports = transports
|
||||
@owner = owner
|
||||
end
|
||||
@@ -39,5 +43,6 @@ class ActionPack::WebAuthn::PublicKeyCredential
|
||||
response.validate!(challenge: challenge, origin: origin)
|
||||
|
||||
@sign_count = response.authenticator_data.sign_count
|
||||
@backed_up = response.authenticator_data.backed_up?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,7 +68,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
|
||||
# [+:exclude_credentials+]
|
||||
# Optional. Existing credentials to exclude from registration. Each must
|
||||
# respond to +id+ and +transports+.
|
||||
def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], **attrs)
|
||||
#
|
||||
# [+: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)
|
||||
|
||||
@id = id
|
||||
@@ -76,6 +80,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
|
||||
@display_name = display_name
|
||||
@resident_key = resident_key
|
||||
@exclude_credentials = exclude_credentials
|
||||
@attestation = validate_attestation(attestation)
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization and passing to the
|
||||
@@ -104,10 +109,24 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
|
||||
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
||||
end
|
||||
|
||||
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?
|
||||
|
||||
Reference in New Issue
Block a user