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:
Stanko K.R.
2026-02-19 10:56:22 +01:00
parent b23660d897
commit fbe9252db1
53 changed files with 666 additions and 172 deletions
+10
View File
@@ -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 -1
View File
@@ -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?