Files
fizzy/lib/action_pack/web_authn/public_key_credential.rb
T
Stanko K.R. 017bcc9ce1 Polish up Passkey interface
- Fix indentation
- Split awkward initializer into separate methods
- Rename credentials to passkeys
- Simplify authenticator registry loading
- Flatten nested errors under ActionPack::WebAuthn
- Set passkey current params only requests that use it
- Inline anemic methods
- Replace custom validation with ActiveModel::Validation
- Rename identity to holder in Passkey
- Rename credentials to passkeys in JS
- Extract framework library out of controllers
- Pass params hashes down to ActionPack
- Attempt to simplify public interface
- Push data decoding down to the classes representing the data
- Introduce has_passkeys
- Add CBOR bigint support
- Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey
- Add create_passkey_button helper
- Rename public-key to creation-options
- Add sign_in_with_passkey_button helper
- Dispatch events for the whole Passkey lifecycle
- Add ED25519 support
- Prevent crash on missing meta tag
- Validate resident key options
- Don't clobber existing ActionPack config options
- Validate cryptographic params
- Move CurrentWebAuthnRequest into ActionPack::Passkey
- Use ActiveModel::Attributes for Options objects
- Implement expiring challanges
- Add lifecycle events
- Extract param helpers into Request
- Add passkey_creation_options and passkey_request_options helpers
- Add create_passkey_challenge to make it easier to override the create method if needed
- Prefix all view helpers with passkey_
- Auto-include ActionPack::Passkey::Holder
- Make the passkey challange url configurable
- Add a reminder about Passkeys to the magic link email
2026-03-18 11:51:10 +01:00

157 lines
5.2 KiB
Ruby

# = Action Pack WebAuthn Public Key Credential
#
# Represents a WebAuthn public key credential and orchestrates the registration
# and authentication ceremonies. During registration (+.register+), it verifies
# the attestation response and returns a new credential. During authentication
# (+#authenticate+), it verifies the assertion response against the stored
# public key.
#
# == Registration
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
# credential.id # => Base64URL-encoded credential ID
# credential.public_key # => OpenSSL::PKey::EC
# credential.sign_count # => 0
#
# == Authentication
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.new(
# id: stored_credential_id,
# public_key: stored_public_key,
# sign_count: stored_sign_count
# )
#
# credential.authenticate(params[:passkey])
#
# == Ceremony Options
#
# Use +.creation_options+ and +.request_options+ to generate the JSON options
# passed to the browser's +navigator.credentials.create()+ and
# +navigator.credentials.get()+ calls.
#
# == Attributes
#
# [+id+]
# The Base64URL-encoded credential identifier.
#
# [+public_key+]
# The OpenSSL public key for signature verification.
#
# [+sign_count+]
# The signature counter, used for replay detection.
#
# [+aaguid+]
# The authenticator attestation GUID (set during registration).
#
# [+backed_up+]
# Whether the credential is backed up to cloud storage (synced passkey).
#
# [+transports+]
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
#
class ActionPack::WebAuthn::PublicKeyCredential
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
class << self
# Returns a RequestOptions object for the authentication ceremony.
# Credentials responding to +to_public_key_credential+ are automatically
# transformed.
def request_options(**attributes)
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
end
# Returns a CreationOptions object for the registration ceremony.
# Credentials in +exclude_credentials+ responding to
# +to_public_key_credential+ are automatically transformed.
def creation_options(**attributes)
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
end
# Verifies an attestation response from the browser and returns a new
# PublicKeyCredential with the registered credential data.
#
# Raises +InvalidResponseError+ if the attestation is invalid.
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
response.validate!
new(
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: Array(params[:transports])
)
end
private
def transform_credentials(credentials)
Array(credentials).map do |credential|
if credential.respond_to?(:to_public_key_credential)
credential.to_public_key_credential
else
credential
end
end
end
end
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
@id = id
@public_key = public_key
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
@sign_count = sign_count
@aaguid = aaguid
@backed_up = backed_up
@transports = transports
end
# Verifies an assertion response against this credential's public key.
# Updates +sign_count+ and +backed_up+ on success.
#
# Raises +InvalidResponseError+ if the assertion is invalid.
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)
response.validate!
@sign_count = response.authenticator_data.sign_count
@backed_up = response.authenticator_data.backed_up?
end
# Returns a Hash of the credential data suitable for persisting.
def to_h
{
credential_id: id,
public_key: public_key.to_der,
sign_count: sign_count,
aaguid: aaguid,
backed_up: backed_up,
transports: transports
}
end
end