Implement WebAuthn protocol
- Add a CBOR decoder - Add public key credential options - Add COSE key decoder - Add authenticator attestation and assertion - Add credentials - Add exclude_credentials
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
module ActionPack::WebAuthn
|
||||
class << self
|
||||
def relying_party
|
||||
RelyingParty.new
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
# = Action Pack WebAuthn Assertion Response
|
||||
#
|
||||
# Handles the authenticator response from a WebAuthn authentication ceremony.
|
||||
# When a user authenticates with an existing credential, the authenticator
|
||||
# returns an assertion response containing a signature that proves possession
|
||||
# of the private key.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Look up the credential by ID
|
||||
# credential = user.credentials.find_by!(
|
||||
# credentail_id: params[:id]
|
||||
# )
|
||||
#
|
||||
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
# client_data_json: params[:response][:clientDataJSON],
|
||||
# authenticator_data: params[:response][:authenticatorData],
|
||||
# signature: params[:response][:signature],
|
||||
# credential: credential.to_public_key_credential
|
||||
# )
|
||||
#
|
||||
# response.validate!(
|
||||
# challenge: session[:authentication_challenge],
|
||||
# origin: "https://example.com"
|
||||
# )
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# In addition to the base Response validations, this class verifies:
|
||||
#
|
||||
# * The client data type is "webauthn.get"
|
||||
# * The signature is valid for the credential's public key
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
attr_reader :credential, :authenticator_data, :signature
|
||||
|
||||
def initialize(credential:, authenticator_data:, signature:, **attributes)
|
||||
super(**attributes)
|
||||
@credential = credential
|
||||
@signature = signature
|
||||
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
|
||||
end
|
||||
|
||||
def validate!(**args)
|
||||
super(**args)
|
||||
|
||||
unless client_data["type"] == "webauthn.get"
|
||||
raise InvalidResponseError, "Client data type is not webauthn.get"
|
||||
end
|
||||
|
||||
unless valid_signature?
|
||||
raise InvalidResponseError, "Invalid signature"
|
||||
end
|
||||
|
||||
unless sign_count_increased?
|
||||
raise InvalidResponseError, "Sign count did not increase"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def valid_signature?
|
||||
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
|
||||
|
||||
credential.public_key.verify("SHA256", signature, signed_data)
|
||||
rescue OpenSSL::PKey::PKeyError
|
||||
false
|
||||
end
|
||||
|
||||
def sign_count_increased?
|
||||
if authenticator_data.sign_count.zero? && credential.sign_count.zero?
|
||||
# Some authenticators always return 0 for the sign count, even after multiple authentications.
|
||||
# In that case, we have to check that both the stored and returned sign counts are 0,
|
||||
# which indicates that the authenticator is likely not updating the sign count.
|
||||
true
|
||||
else
|
||||
authenticator_data.sign_count > credential.sign_count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# = Action Pack WebAuthn Attestation
|
||||
#
|
||||
# Decodes and represents the attestation object returned by an authenticator
|
||||
# during registration. The attestation object is CBOR-encoded and contains
|
||||
# the authenticator data along with an optional attestation statement.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(
|
||||
# attestation_object_bytes
|
||||
# )
|
||||
#
|
||||
# attestation.credential_id # => "abc123..."
|
||||
# attestation.public_key # => OpenSSL::PKey::EC
|
||||
# attestation.sign_count # => 0
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+authenticator_data+]
|
||||
# The parsed Data containing credential information.
|
||||
#
|
||||
# [+format+]
|
||||
# The attestation statement format (e.g., "none", "packed", "fido-u2f").
|
||||
#
|
||||
# [+attestation_statement+]
|
||||
# The attestation statement, which may contain a signature from the
|
||||
# authenticator manufacturer. Empty for "none" format.
|
||||
#
|
||||
# == Delegated Methods
|
||||
#
|
||||
# The following methods are delegated to +authenticator_data+:
|
||||
#
|
||||
# * +credential_id+ - Base64URL-encoded credential identifier
|
||||
# * +public_key+ - OpenSSL public key object
|
||||
# * +public_key_bytes+ - Raw COSE key bytes
|
||||
# * +sign_count+ - Signature counter for replay detection
|
||||
#
|
||||
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
|
||||
|
||||
def self.decode(bytes)
|
||||
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
|
||||
new(
|
||||
authenticator_data: ActionPack::WebAuthn::Authenticator::Data.decode(cbor["authData"]),
|
||||
format: cbor["fmt"],
|
||||
attestation_statement: cbor["attStmt"]
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(authenticator_data:, format:, attestation_statement:)
|
||||
@authenticator_data = authenticator_data
|
||||
@format = format
|
||||
@attestation_statement = attestation_statement
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# = Action Pack WebAuthn Attestation Response
|
||||
#
|
||||
# Handles the authenticator response from a WebAuthn registration ceremony.
|
||||
# When a user registers a new credential, the authenticator returns an
|
||||
# attestation response containing the new public key and credential ID.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
# client_data_json: params[:response][:clientDataJSON],
|
||||
# attestation_object: params[:response][:attestationObject]
|
||||
# )
|
||||
#
|
||||
# response.validate!(
|
||||
# challenge: session[:registration_challenge],
|
||||
# origin: "https://example.com"
|
||||
# )
|
||||
#
|
||||
# # Store the credential
|
||||
# credential_id = response.attestation.credential_id
|
||||
# public_key = response.attestation.public_key
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# 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)
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze
|
||||
attr_reader :attestation_object
|
||||
|
||||
def initialize(attestation_object:, **attributes)
|
||||
super(**attributes)
|
||||
@attestation_object = attestation_object
|
||||
end
|
||||
|
||||
def validate!(**args)
|
||||
super(**args)
|
||||
|
||||
unless client_data["type"] == "webauthn.create"
|
||||
raise InvalidResponseError, "Client data type is not webauthn.create"
|
||||
end
|
||||
|
||||
unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format)
|
||||
raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}"
|
||||
end
|
||||
end
|
||||
|
||||
def attestation
|
||||
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.decode(attestation_object)
|
||||
end
|
||||
|
||||
def authenticator_data
|
||||
attestation.authenticator_data
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
# = Action Pack WebAuthn Authenticator Data
|
||||
#
|
||||
# Decodes and represents the authenticator data structure from WebAuthn
|
||||
# responses. This binary format contains information about the authenticator
|
||||
# and, during registration, the newly created credential.
|
||||
#
|
||||
# == Structure
|
||||
#
|
||||
# The authenticator data consists of:
|
||||
#
|
||||
# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID
|
||||
# * Flags (1 byte) - Bit flags for user presence, verification, etc.
|
||||
# * Sign Count (4 bytes) - Signature counter for replay detection
|
||||
# * Attested Credential Data (variable) - Present only during registration
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
#
|
||||
# data.user_present? # => true
|
||||
# data.user_verified? # => true
|
||||
# data.sign_count # => 42
|
||||
# data.credential_id # => "abc123..." (registration only)
|
||||
# data.public_key # => OpenSSL::PKey::EC (registration only)
|
||||
#
|
||||
# == Flags
|
||||
#
|
||||
# [+user_present?+]
|
||||
# Returns true if the user performed a test of user presence (e.g., touched
|
||||
# the authenticator).
|
||||
#
|
||||
# [+user_verified?+]
|
||||
# Returns true if the user was verified through biometrics, PIN, or other
|
||||
# method. This is stronger than mere presence.
|
||||
#
|
||||
# [+backup_eligible?+]
|
||||
# Returns true if the credential can be backed up (e.g., synced passkeys
|
||||
# from Apple, Google, or Microsoft). Indicates multi-device credential support.
|
||||
#
|
||||
# [+backed_up?+]
|
||||
# Returns true if the credential is currently backed up to cloud storage.
|
||||
# Useful for risk assessment—backed-up credentials may be accessible from
|
||||
# multiple devices.
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Data
|
||||
# Segment lengths
|
||||
RELYING_PARTY_ID_HASH_LENGTH = 32
|
||||
FLAGS_LENGTH = 1
|
||||
SIGN_COUNT_LENGTH = 4
|
||||
AAGUID_LENGTH = 16
|
||||
CREDENTIAL_ID_LENGTH_BYTES = 2
|
||||
|
||||
# Flags
|
||||
USER_PRESENT_FLAG = 0x01
|
||||
USER_VERIFIED_FLAG = 0x04
|
||||
BACKUP_ELIGIBLE_FLAG = 0x08
|
||||
BACKUP_STATE_FLAG = 0x10
|
||||
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
|
||||
|
||||
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes
|
||||
|
||||
class << self
|
||||
def wrap(data)
|
||||
if data.is_a?(self)
|
||||
data
|
||||
else
|
||||
decode(data)
|
||||
end
|
||||
end
|
||||
|
||||
def decode(bytes)
|
||||
bytes = bytes.bytes if bytes.is_a?(String)
|
||||
position = 0
|
||||
|
||||
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
|
||||
position += RELYING_PARTY_ID_HASH_LENGTH
|
||||
|
||||
flags = bytes[position]
|
||||
position += FLAGS_LENGTH
|
||||
|
||||
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
|
||||
position += SIGN_COUNT_LENGTH
|
||||
|
||||
credential_id = nil
|
||||
public_key_bytes = nil
|
||||
|
||||
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
|
||||
position += AAGUID_LENGTH
|
||||
|
||||
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
|
||||
position += CREDENTIAL_ID_LENGTH_BYTES
|
||||
|
||||
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
|
||||
position += credential_id_length
|
||||
|
||||
public_key_bytes = bytes[position..].pack("C*")
|
||||
end
|
||||
|
||||
new(
|
||||
bytes: bytes,
|
||||
relying_party_id_hash: relying_party_id_hash,
|
||||
flags: flags,
|
||||
sign_count: sign_count,
|
||||
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:)
|
||||
@bytes = bytes
|
||||
@relying_party_id_hash = relying_party_id_hash
|
||||
@flags = flags
|
||||
@sign_count = sign_count
|
||||
@credential_id = credential_id
|
||||
@public_key_bytes = public_key_bytes
|
||||
end
|
||||
|
||||
def user_present?
|
||||
flags & USER_PRESENT_FLAG != 0
|
||||
end
|
||||
|
||||
def user_verified?
|
||||
flags & USER_VERIFIED_FLAG != 0
|
||||
end
|
||||
|
||||
# Returns true if the credential is eligible for backup (e.g., synced passkey).
|
||||
# This indicates the authenticator supports multi-device credentials.
|
||||
def backup_eligible?
|
||||
flags & BACKUP_ELIGIBLE_FLAG != 0
|
||||
end
|
||||
|
||||
# Returns true if the credential is currently backed up to cloud storage.
|
||||
# Only meaningful when +backup_eligible?+ is true.
|
||||
def backed_up?
|
||||
flags & BACKUP_STATE_FLAG != 0
|
||||
end
|
||||
|
||||
def public_key
|
||||
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
# = Action Pack WebAuthn Authenticator Response
|
||||
#
|
||||
# Abstract base class for WebAuthn authenticator responses. Provides common
|
||||
# validation logic for both registration (attestation) and authentication
|
||||
# (assertion) ceremonies.
|
||||
#
|
||||
# This class should not be instantiated directly. Use AttestationResponse for
|
||||
# registration or AssertionResponse for authentication.
|
||||
#
|
||||
# == Validation
|
||||
#
|
||||
# The +validate!+ method performs security checks required by the WebAuthn
|
||||
# specification:
|
||||
#
|
||||
# * Challenge verification - ensures the response matches the server-generated challenge
|
||||
# * Origin verification - ensures the response comes from the expected origin
|
||||
# * User verification - optionally requires biometric or PIN verification
|
||||
#
|
||||
# == Example
|
||||
#
|
||||
# response.validate!(
|
||||
# challenge: session[:webauthn_challenge],
|
||||
# origin: "https://example.com",
|
||||
# user_verification: :required
|
||||
# )
|
||||
#
|
||||
class ActionPack::WebAuthn::Authenticator::Response
|
||||
# Raised when response validation fails.
|
||||
class InvalidResponseError < StandardError; end
|
||||
|
||||
attr_reader :client_data_json
|
||||
|
||||
def initialize(client_data_json:)
|
||||
@client_data_json = client_data_json
|
||||
end
|
||||
|
||||
def valid?(**args)
|
||||
validate!(**args)
|
||||
true
|
||||
rescue InvalidResponseError
|
||||
false
|
||||
end
|
||||
|
||||
def validate!(challenge:, origin:, user_verification: :preferred)
|
||||
unless challenge_matches?(challenge)
|
||||
raise InvalidResponseError, "Challenge does not match"
|
||||
end
|
||||
|
||||
unless origin_matches?(origin)
|
||||
raise InvalidResponseError, "Origin does not match"
|
||||
end
|
||||
|
||||
if cross_origin?
|
||||
raise InvalidResponseError, "Cross-origin requests are not supported"
|
||||
end
|
||||
|
||||
if token_binding_present?
|
||||
raise InvalidResponseError, "Token binding is not supported"
|
||||
end
|
||||
|
||||
unless relying_party_id_matches?
|
||||
raise InvalidResponseError, "Relying party ID does not match"
|
||||
end
|
||||
|
||||
unless user_present?
|
||||
raise InvalidResponseError, "User presence is required"
|
||||
end
|
||||
|
||||
if user_verification == :required && !user_verified?
|
||||
raise InvalidResponseError, "User verification is required"
|
||||
end
|
||||
end
|
||||
|
||||
def relying_party
|
||||
ActionPack::WebAuthn.relying_party
|
||||
end
|
||||
|
||||
def client_data
|
||||
@client_data ||= JSON.parse(client_data_json)
|
||||
end
|
||||
|
||||
def authenticator_data
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
def challenge_matches?(expected_challenge)
|
||||
ActiveSupport::SecurityUtils.secure_compare(expected_challenge, client_data["challenge"])
|
||||
end
|
||||
|
||||
def origin_matches?(expected_origin)
|
||||
ActiveSupport::SecurityUtils.secure_compare(expected_origin, client_data["origin"])
|
||||
end
|
||||
|
||||
def relying_party_id_matches?
|
||||
ActiveSupport::SecurityUtils.secure_compare(
|
||||
Digest::SHA256.digest(relying_party.id),
|
||||
authenticator_data&.relying_party_id_hash || ""
|
||||
)
|
||||
end
|
||||
|
||||
def cross_origin?
|
||||
client_data["crossOrigin"] == true
|
||||
end
|
||||
|
||||
def token_binding_present?
|
||||
client_data.dig("tokenBinding", "status") == "present"
|
||||
end
|
||||
|
||||
def user_present?
|
||||
authenticator_data&.user_present?
|
||||
end
|
||||
|
||||
def user_verified?
|
||||
authenticator_data&.user_verified?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,262 @@
|
||||
# = Action Pack WebAuthn CBOR Decoder
|
||||
#
|
||||
# Decodes Concise Binary Object Representation (CBOR) data as specified in
|
||||
# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format
|
||||
# used by WebAuthn for encoding authenticator data and attestation objects.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# The decoder accepts either a binary string or an array of bytes:
|
||||
#
|
||||
# # From binary string
|
||||
# ActionPack::WebAuthn::CborDecoder.decode("\x83\x01\x02\x03")
|
||||
# # => [1, 2, 3]
|
||||
#
|
||||
# # From byte array
|
||||
# ActionPack::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03])
|
||||
# # => [1, 2, 3]
|
||||
#
|
||||
# == Supported Types
|
||||
#
|
||||
# The decoder supports the following CBOR types:
|
||||
#
|
||||
# [Integers]
|
||||
# Unsigned (major type 0) and negative (major type 1) integers of any size.
|
||||
#
|
||||
# [Byte strings]
|
||||
# Binary data (major type 2), returned as ASCII-8BIT encoded strings.
|
||||
#
|
||||
# [Text strings]
|
||||
# UTF-8 text (major type 3), returned as UTF-8 encoded strings.
|
||||
#
|
||||
# [Arrays]
|
||||
# Ordered collections (major type 4) of any CBOR values.
|
||||
#
|
||||
# [Maps]
|
||||
# Key-value pairs (major type 5) with any CBOR values as keys and values.
|
||||
#
|
||||
# [Floats]
|
||||
# IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision.
|
||||
#
|
||||
# [Simple values]
|
||||
# +false+, +true+, +null+, and +undefined+ (both decoded as +nil+).
|
||||
#
|
||||
# [Indefinite length]
|
||||
# Streaming byte strings, text strings, arrays, and maps.
|
||||
#
|
||||
# Tags (major type 6) are recognized but their semantic meaning is ignored;
|
||||
# the tagged value is returned directly.
|
||||
#
|
||||
# == Errors
|
||||
#
|
||||
# Raises +DecodeError+ when encountering malformed or unsupported CBOR data.
|
||||
class ActionPack::WebAuthn::CborDecoder
|
||||
# Raised when the decoder encounters invalid or unsupported CBOR data.
|
||||
class DecodeError < StandardError; end
|
||||
|
||||
# Major types
|
||||
UNSIGNED_INTEGER_TYPE = 0
|
||||
NEGATIVE_INTEGER_TYPE = 1
|
||||
BYTE_STRING_TYPE = 2
|
||||
TEXT_STRING_TYPE = 3
|
||||
ARRAY_TYPE = 4
|
||||
MAP_TYPE = 5
|
||||
TAG_TYPE = 6
|
||||
FLOAT_OR_SIMPLE_TYPE = 7
|
||||
|
||||
# Additional information values
|
||||
SIMPLE_VALUE_RANGE = 0..23
|
||||
SINGLE_BYTE_VALUE_FOLLOWS = 24
|
||||
TWO_BYTE_VALUE_FOLLOWS = 25
|
||||
FOUR_BYTE_VALUE_FOLLOWS = 26
|
||||
EIGHT_BYTE_VALUE_FOLLOWS = 27
|
||||
RESERVED_VALUE_RANGE = 28..30
|
||||
INDEFINITE_LENGTH_MAJOR_TYPE = 31
|
||||
|
||||
# Simple values
|
||||
SIMPLE_FALSE_VALUE = 20
|
||||
SIMPLE_TRUE_VALUE = 21
|
||||
SIMPLE_NULL_VALUE = 22
|
||||
SIMPLE_UNDEFINED_VALUE = 23
|
||||
|
||||
# Flow control
|
||||
BREAK_CODE = 0xFF
|
||||
|
||||
# Limits
|
||||
MAX_DEPTH = 16
|
||||
MAX_SIZE = 10.megabytes
|
||||
|
||||
class << self
|
||||
# Decodes a CBOR-encoded byte sequence into a Ruby object.
|
||||
#
|
||||
# ActionPack::WebAuthn::CborDecoder.decode("\xa2\x61a\x01\x61b\x02")
|
||||
# # => {"a" => 1, "b" => 2}
|
||||
def decode(bytes, **args)
|
||||
bytes = bytes.bytes if bytes.respond_to?(:bytes)
|
||||
new(bytes, **args).decode
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
|
||||
raise DecodeError, "Input exceeds maximum size" if bytes.length > max_size
|
||||
|
||||
@bytes = bytes
|
||||
@max_depth = max_depth
|
||||
@position = 0
|
||||
@depth = 0
|
||||
end
|
||||
|
||||
# Decodes the next CBOR data item from the byte sequence.
|
||||
def decode
|
||||
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
|
||||
raise DecodeError, "Maximum nesting depth exceeded" if @depth > @max_depth
|
||||
|
||||
@depth += 1
|
||||
|
||||
result = case major_type
|
||||
when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer
|
||||
when NEGATIVE_INTEGER_TYPE then decode_negative_integer
|
||||
when BYTE_STRING_TYPE then decode_byte_string
|
||||
when TEXT_STRING_TYPE then decode_text_string
|
||||
when ARRAY_TYPE then decode_array
|
||||
when MAP_TYPE then decode_map
|
||||
when TAG_TYPE then decode_tag
|
||||
when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple
|
||||
end
|
||||
|
||||
@depth -= 1
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
def major_type
|
||||
peek >> 5
|
||||
end
|
||||
|
||||
def peek
|
||||
@bytes[@position]
|
||||
end
|
||||
|
||||
def decode_unsigned_integer
|
||||
read_argument
|
||||
end
|
||||
|
||||
def decode_negative_integer
|
||||
-1 - read_argument
|
||||
end
|
||||
|
||||
def decode_byte_string
|
||||
if indefinite_length?
|
||||
String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? }
|
||||
else
|
||||
read_bytes(read_argument).pack("C*")
|
||||
end
|
||||
end
|
||||
|
||||
def decode_text_string
|
||||
if indefinite_length?
|
||||
String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? }
|
||||
else
|
||||
read_bytes(read_argument).pack("C*").force_encoding(Encoding::UTF_8)
|
||||
end
|
||||
end
|
||||
|
||||
def decode_array
|
||||
if indefinite_length?
|
||||
Array.new.tap { |arr| arr << decode until break_code? }
|
||||
else
|
||||
Array.new(read_argument) { decode }
|
||||
end
|
||||
end
|
||||
|
||||
def decode_map
|
||||
if indefinite_length?
|
||||
Hash.new.tap { |hash| hash[decode] = decode until break_code? }
|
||||
else
|
||||
Hash.new.tap do |hash|
|
||||
read_argument.times do
|
||||
hash[decode] = decode
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def decode_float_or_simple
|
||||
case info = additional_info
|
||||
when SIMPLE_FALSE_VALUE then false
|
||||
when SIMPLE_TRUE_VALUE then true
|
||||
when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil
|
||||
when TWO_BYTE_VALUE_FOLLOWS then decode_half_float
|
||||
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
|
||||
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
|
||||
else
|
||||
raise DecodeError, "Invalid simple value: #{info}"
|
||||
end
|
||||
end
|
||||
|
||||
def decode_tag
|
||||
read_argument
|
||||
decode
|
||||
end
|
||||
|
||||
def decode_half_float
|
||||
half = read_bytes(2).pack("C*").unpack1("n")
|
||||
|
||||
sign = (half >> 15) & 0x1
|
||||
exponent = (half >> 10) & 0x1F
|
||||
mantissa = half & 0x3FF
|
||||
|
||||
value = if exponent == 0
|
||||
Math.ldexp(mantissa, -24)
|
||||
elsif exponent == 31
|
||||
mantissa == 0 ? Float::INFINITY : Float::NAN
|
||||
else
|
||||
Math.ldexp(mantissa + 1024, exponent - 25)
|
||||
end
|
||||
|
||||
sign == 1 ? -value : value
|
||||
end
|
||||
|
||||
def read_argument
|
||||
case info = additional_info
|
||||
when SIMPLE_VALUE_RANGE then info
|
||||
when SINGLE_BYTE_VALUE_FOLLOWS then read_byte
|
||||
when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack("C*").unpack1("n")
|
||||
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
|
||||
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
|
||||
when RESERVED_VALUE_RANGE
|
||||
raise DecodeError, "Reserved additional info: #{info}"
|
||||
else
|
||||
raise DecodeError, "Invalid additional info: #{info}"
|
||||
end
|
||||
end
|
||||
|
||||
def additional_info(consume: true)
|
||||
byte = consume ? read_byte : peek
|
||||
byte & 0b00011111
|
||||
end
|
||||
|
||||
def indefinite_length?
|
||||
read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE
|
||||
end
|
||||
|
||||
def break_code?
|
||||
read_byte if peek == BREAK_CODE
|
||||
end
|
||||
|
||||
def read_bytes(length)
|
||||
raise DecodeError, "Unexpected end of input" if @position + length > @bytes.length
|
||||
|
||||
bytes = @bytes[@position, length]
|
||||
@position += length
|
||||
bytes
|
||||
end
|
||||
|
||||
def read_byte
|
||||
raise DecodeError, "Unexpected end of input" if @position >= @bytes.length
|
||||
|
||||
byte = @bytes[@position]
|
||||
@position += 1
|
||||
byte
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
# = Action Pack WebAuthn COSE Key
|
||||
#
|
||||
# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in
|
||||
# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators
|
||||
# return public keys in COSE format, which must be converted to a standard format
|
||||
# for signature verification.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Decode a COSE key from CBOR bytes (e.g., from authenticator data)
|
||||
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
|
||||
#
|
||||
# # Convert to OpenSSL key for signature verification
|
||||
# openssl_key = cose_key.to_openssl_key
|
||||
# openssl_key.verify("SHA256", signature, signed_data)
|
||||
#
|
||||
# == Supported Algorithms
|
||||
#
|
||||
# [ES256]
|
||||
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
|
||||
#
|
||||
# [RS256]
|
||||
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+key_type+]
|
||||
# The COSE key type (2 for EC2, 3 for RSA).
|
||||
#
|
||||
# [+algorithm+]
|
||||
# The COSE algorithm identifier (-7 for ES256, -257 for RS256).
|
||||
#
|
||||
# [+parameters+]
|
||||
# The full COSE key parameters map, including curve and coordinate data.
|
||||
class ActionPack::WebAuthn::CoseKey
|
||||
# Raised when the key type, algorithm, or curve is not supported.
|
||||
class UnsupportedKeyTypeError < StandardError; end
|
||||
|
||||
# COSE key labels
|
||||
KEY_TYPE_LABEL = 1
|
||||
ALGORITHM_LABEL = 3
|
||||
EC2_CURVE_LABEL = -1
|
||||
EC2_X_LABEL = -2
|
||||
EC2_Y_LABEL = -3
|
||||
RSA_N_LABEL = -1
|
||||
RSA_E_LABEL = -2
|
||||
|
||||
# COSE key types
|
||||
EC2 = 2
|
||||
RSA = 3
|
||||
|
||||
# COSE algorithms
|
||||
ES256 = -7
|
||||
RS256 = -257
|
||||
|
||||
# COSE EC2 curves
|
||||
P256 = 1
|
||||
|
||||
# OpenSSL types
|
||||
UNCOMPRESSED_POINT_MARKER = 0x04
|
||||
|
||||
attr_reader :key_type, :algorithm, :parameters
|
||||
|
||||
class << self
|
||||
# Decodes a COSE key from CBOR-encoded bytes.
|
||||
#
|
||||
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
|
||||
# cose_key.algorithm # => -7 (ES256)
|
||||
def decode(bytes)
|
||||
data = ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
new(
|
||||
key_type: data[KEY_TYPE_LABEL],
|
||||
algorithm: data[ALGORITHM_LABEL],
|
||||
parameters: data
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(key_type:, algorithm:, parameters:) # :nodoc:
|
||||
@key_type = key_type
|
||||
@algorithm = algorithm
|
||||
@parameters = parameters
|
||||
end
|
||||
|
||||
# Converts the COSE key to an OpenSSL public key object.
|
||||
#
|
||||
# Returns an +OpenSSL::PKey::EC+ for EC2 keys or +OpenSSL::PKey::RSA+ for
|
||||
# RSA keys, suitable for use with +OpenSSL::PKey#verify+.
|
||||
#
|
||||
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
|
||||
# is not supported.
|
||||
def to_openssl_key
|
||||
case [ key_type, algorithm ]
|
||||
when [ EC2, ES256 ] then build_ec2_es256_key
|
||||
when [ RSA, RS256 ] then build_rsa_rs256_key
|
||||
else raise UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_ec2_es256_key
|
||||
curve = parameters[EC2_CURVE_LABEL]
|
||||
raise UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
|
||||
|
||||
x = parameters[EC2_X_LABEL]
|
||||
y = parameters[EC2_Y_LABEL]
|
||||
|
||||
# Uncompressed point format: 0x04 || x || y
|
||||
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
|
||||
OpenSSL::ASN1::ObjectId("prime256v1")
|
||||
]),
|
||||
OpenSSL::ASN1::BitString(public_key_bytes)
|
||||
])
|
||||
|
||||
OpenSSL::PKey::EC.new(asn1.to_der)
|
||||
end
|
||||
|
||||
def build_rsa_rs256_key
|
||||
n = OpenSSL::BN.new(parameters[RSA_N_LABEL], 2)
|
||||
e = OpenSSL::BN.new(parameters[RSA_E_LABEL], 2)
|
||||
|
||||
asn1 = OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::ObjectId("rsaEncryption"),
|
||||
OpenSSL::ASN1::Null.new(nil)
|
||||
]),
|
||||
OpenSSL::ASN1::BitString(
|
||||
OpenSSL::ASN1::Sequence([
|
||||
OpenSSL::ASN1::Integer(n),
|
||||
OpenSSL::ASN1::Integer(e)
|
||||
]).to_der
|
||||
)
|
||||
])
|
||||
|
||||
OpenSSL::PKey::RSA.new(asn1.to_der)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
|
||||
attribute :host
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
class ActionPack::WebAuthn::PublicKeyCredential
|
||||
attr_reader :id, :public_key, :sign_count, :owner
|
||||
|
||||
class << self
|
||||
def create(client_data_json:, attestation_object:, challenge:, origin:, owner: nil)
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
attestation_object: attestation_object
|
||||
)
|
||||
|
||||
response.validate!(challenge: challenge, origin: origin)
|
||||
|
||||
new(
|
||||
id: response.attestation.credential_id,
|
||||
public_key: response.attestation.public_key,
|
||||
sign_count: response.attestation.sign_count,
|
||||
owner: owner
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(id:, public_key:, sign_count:, owner: nil)
|
||||
@id = id
|
||||
@public_key = public_key
|
||||
@sign_count = sign_count
|
||||
@owner = owner
|
||||
end
|
||||
|
||||
def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: signature,
|
||||
credential: self
|
||||
)
|
||||
|
||||
response.validate!(challenge: challenge, origin: origin)
|
||||
|
||||
@sign_count = response.authenticator_data.sign_count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
# = Action Pack WebAuthn Public Key Credential Creation Options
|
||||
#
|
||||
# Generates options for the WebAuthn registration ceremony (creating a new
|
||||
# credential). These options are passed to +navigator.credentials.create()+ in
|
||||
# the browser to prompt the user to register an authenticator.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
# id: current_user.id,
|
||||
# name: current_user.email,
|
||||
# display_name: current_user.name
|
||||
# )
|
||||
#
|
||||
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
||||
# render json: { publicKey: options.as_json }
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+id+]
|
||||
# A unique identifier for the user account. Will be Base64URL-encoded in the
|
||||
# output. This should be an opaque identifier (like a primary key), not
|
||||
# personally identifiable information.
|
||||
#
|
||||
# [+name+]
|
||||
# A human-readable identifier for the user account, typically an email
|
||||
# address or username. Displayed by the authenticator.
|
||||
#
|
||||
# [+display_name+]
|
||||
# A human-friendly name for the user, typically their full name. Displayed
|
||||
# by the authenticator during registration.
|
||||
#
|
||||
# [+relying_party+]
|
||||
# The relying party (your application) configuration. Defaults to
|
||||
# +ActionPack::WebAuthn.relying_party+.
|
||||
#
|
||||
# == Supported Algorithms
|
||||
#
|
||||
# By default, supports ES256 (ECDSA with P-256 and SHA-256) and RS256
|
||||
# (RSASSA-PKCS1-v1_5 with SHA-256), which cover the vast majority of
|
||||
# authenticators.
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
ES256 = { type: "public-key", alg: -7 }.freeze
|
||||
RS256 = { type: "public-key", alg: -257 }.freeze
|
||||
|
||||
attr_reader :id, :name, :display_name
|
||||
|
||||
# Creates a new set of credential creation options.
|
||||
#
|
||||
# ==== Options
|
||||
#
|
||||
# [+:id+]
|
||||
# Required. The user's unique identifier.
|
||||
#
|
||||
# [+:name+]
|
||||
# Required. The user's account name (e.g., email).
|
||||
#
|
||||
# [+:display_name+]
|
||||
# Required. The user's display name.
|
||||
#
|
||||
# [+:relying_party+]
|
||||
# Optional. The relying party configuration.
|
||||
#
|
||||
# [+:resident_key+]
|
||||
# Optional. Resident key requirement. One of +:preferred+, +:required+,
|
||||
# or +:discouraged+. Defaults to +:preferred+.
|
||||
#
|
||||
# [+: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)
|
||||
super(**attrs)
|
||||
|
||||
@id = id
|
||||
@name = name
|
||||
@display_name = display_name
|
||||
@resident_key = resident_key
|
||||
@exclude_credentials = exclude_credentials
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization and passing to the
|
||||
# WebAuthn JavaScript API.
|
||||
def as_json(*)
|
||||
json = {
|
||||
challenge: challenge,
|
||||
rp: relying_party.as_json,
|
||||
user: {
|
||||
id: Base64.urlsafe_encode64(id.to_s, padding: false),
|
||||
name: name,
|
||||
displayName: display_name
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
ES256,
|
||||
RS256
|
||||
],
|
||||
authenticatorSelection: {
|
||||
residentKey: @resident_key.to_s,
|
||||
userVerification: user_verification.to_s
|
||||
}
|
||||
}
|
||||
|
||||
if @exclude_credentials.any?
|
||||
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
|
||||
end
|
||||
|
||||
json
|
||||
end
|
||||
|
||||
private
|
||||
def exclude_credential_json(credential)
|
||||
hash = { type: "public-key", id: credential.id }
|
||||
hash[:transports] = credential.transports if credential.transports.any?
|
||||
hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
CHALLENGE_LENGTH = 32
|
||||
USER_VERIFICATION_OPTIONS = [ :required, :preferred, :discouraged ].freeze
|
||||
|
||||
attr_reader :user_verification, :relying_party
|
||||
|
||||
def initialize(user_verification: :preferred, relying_party: ActionPack::WebAuthn.relying_party)
|
||||
@user_verification = user_verification.to_sym
|
||||
@relying_party = relying_party
|
||||
|
||||
unless USER_VERIFICATION_OPTIONS.include?(user_verification)
|
||||
raise ArgumentError, "Invalid user verification option: #{user_verification.inspect}"
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a Base64URL-encoded random challenge. The challenge is generated
|
||||
# once and memoized for the lifetime of this object.
|
||||
#
|
||||
# The challenge must be stored server-side and verified when the client
|
||||
# responds, to prevent replay attacks.
|
||||
def challenge
|
||||
@challenge ||= Base64.urlsafe_encode64(
|
||||
SecureRandom.random_bytes(CHALLENGE_LENGTH),
|
||||
padding: false
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# = Action Pack WebAuthn Public Key Credential Request Options
|
||||
#
|
||||
# Generates options for the WebAuthn authentication ceremony (using an existing
|
||||
# credential). These options are passed to +navigator.credentials.get()+ in
|
||||
# the browser to prompt the user to authenticate with a registered authenticator.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
# credentials: current_user.webauthn_credentials
|
||||
# )
|
||||
#
|
||||
# # In your controller, return as JSON for the JavaScript WebAuthn API
|
||||
# render json: { publicKey: options.as_json }
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+credentials+]
|
||||
# A collection of credential records for the user. Each credential must
|
||||
# respond to +id+ returning the Base64URL-encoded credential ID, and
|
||||
# +transports+ returning an array of transport strings.
|
||||
#
|
||||
# [+relying_party+]
|
||||
# The relying party (your application) configuration. Defaults to
|
||||
# +ActionPack::WebAuthn.relying_party+.
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
|
||||
attr_reader :credentials
|
||||
|
||||
# Creates a new set of credential request options.
|
||||
#
|
||||
# ==== Options
|
||||
#
|
||||
# [+:credentials+]
|
||||
# Required. The user's registered WebAuthn credentials.
|
||||
#
|
||||
# [+:relying_party+]
|
||||
# Optional. The relying party configuration.
|
||||
def initialize(credentials:, **attrs)
|
||||
super(**attrs)
|
||||
|
||||
@credentials = credentials
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization and passing to the
|
||||
# WebAuthn JavaScript API.
|
||||
def as_json(*)
|
||||
{
|
||||
challenge: challenge,
|
||||
rpId: relying_party.id,
|
||||
allowCredentials: credentials.map { |credential| allow_credential_json(credential) },
|
||||
userVerification: user_verification.to_s
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def allow_credential_json(credential)
|
||||
hash = { type: "public-key", id: credential.id }
|
||||
hash[:transports] = credential.transports if credential.transports.any?
|
||||
hash
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
# = Action Pack WebAuthn Relying Party
|
||||
#
|
||||
# Represents the relying party (your application) in WebAuthn ceremonies. The
|
||||
# relying party identity is sent to authenticators during registration and
|
||||
# authentication to scope credentials to your application.
|
||||
#
|
||||
# == Usage
|
||||
#
|
||||
# # Using defaults (host from Current, name from Rails application)
|
||||
# relying_party = ActionPack::WebAuthn::RelyingParty.new
|
||||
#
|
||||
# # With explicit values
|
||||
# relying_party = ActionPack::WebAuthn::RelyingParty.new(
|
||||
# id: "example.com",
|
||||
# name: "Example Application"
|
||||
# )
|
||||
#
|
||||
# == Attributes
|
||||
#
|
||||
# [+id+]
|
||||
# The relying party identifier, typically the application's domain name
|
||||
# (e.g., "example.com"). This must match the origin's effective domain
|
||||
# or be a registrable domain suffix of it. Credentials are scoped to this
|
||||
# identifier. Defaults to +ActionPack::WebAuthn::Current.host+.
|
||||
#
|
||||
# [+name+]
|
||||
# A human-readable name for your application, displayed by authenticators
|
||||
# during ceremonies. Defaults to +Rails.application.name+.
|
||||
class ActionPack::WebAuthn::RelyingParty
|
||||
attr_reader :id, :name
|
||||
|
||||
# Creates a new relying party configuration.
|
||||
#
|
||||
# ==== Options
|
||||
#
|
||||
# [+:id+]
|
||||
# Optional. The relying party identifier (domain).
|
||||
#
|
||||
# [+:name+]
|
||||
# Optional. The application display name.
|
||||
def initialize(id: ActionPack::WebAuthn::Current.host, name: Rails.application.name)
|
||||
@id = id
|
||||
@name = name
|
||||
end
|
||||
|
||||
# Returns a Hash suitable for JSON serialization.
|
||||
def as_json(*)
|
||||
{ id: id, name: name }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = "test-challenge-123"
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
# Generate a real key pair for signature verification
|
||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
@public_key = @private_key
|
||||
@credential = Struct.new(:public_key, :sign_count).new(@public_key, 0)
|
||||
|
||||
@authenticator_data = build_authenticator_data(user_verified: true)
|
||||
@signature = sign(@authenticator_data, @client_data_json)
|
||||
|
||||
@response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: @signature,
|
||||
credential: @credential
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with credential, authenticator data, and signature" do
|
||||
assert_equal @credential, @response.credential
|
||||
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data
|
||||
assert_equal @signature, @response.signature
|
||||
end
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, type, and signature" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises when type is not webauthn.get" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: sign(@authenticator_data, client_data_json),
|
||||
credential: @credential
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Client data type is not webauthn.get", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when signature is invalid" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: "invalid-signature",
|
||||
credential: @credential
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Invalid signature", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification preferred when not verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: false)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification required when verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: true)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises with user_verification required when not verified" do
|
||||
authenticator_data = build_authenticator_data(user_verified: false)
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
end
|
||||
|
||||
assert_equal "User verification is required", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data(user_verified:)
|
||||
rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
flags = 0x01 # user present
|
||||
flags |= 0x04 if user_verified
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def sign(authenticator_data, client_data_json)
|
||||
client_data_hash = Digest::SHA256.digest(client_data_json)
|
||||
signed_data = authenticator_data + client_data_hash
|
||||
@private_key.sign("SHA256", signed_data)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,195 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = "test-challenge-123"
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true)
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with attestation object" do
|
||||
assert_not_nil @response.attestation_object
|
||||
end
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, and type" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification preferred when not verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: false)
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! succeeds with user_verification required when verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true)
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
end
|
||||
end
|
||||
|
||||
test "validate! raises with user_verification required when not verified" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: false)
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
end
|
||||
|
||||
assert_equal "User verification is required", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when type is not webauthn.create" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true)
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Client data type is not webauthn.create", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when attestation format is not supported" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true, format: "packed")
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Unsupported attestation format: packed", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def build_attestation_object(user_verified:, format: "none")
|
||||
auth_data = build_authenticator_data(user_verified: user_verified)
|
||||
encode_cbor_attestation_object(auth_data, format: format)
|
||||
end
|
||||
|
||||
def build_authenticator_data(user_verified:)
|
||||
rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
flags = 0x41 # user present + attested credential
|
||||
flags |= 0x04 if user_verified
|
||||
sign_count = 0
|
||||
aaguid = SecureRandom.random_bytes(16)
|
||||
credential_id = SecureRandom.random_bytes(32)
|
||||
cose_key = build_cose_key
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
bytes.concat(aaguid.bytes)
|
||||
bytes.concat([credential_id.bytesize].pack("n").bytes)
|
||||
bytes.concat(credential_id.bytes)
|
||||
bytes.concat(cose_key.bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def build_cose_key
|
||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
public_key_bn = ec_key.public_key.to_bn
|
||||
public_key_point = public_key_bn.to_s(2)
|
||||
x = public_key_point[1, 32]
|
||||
y = public_key_point[33, 32]
|
||||
|
||||
params = { 1 => 2, 3 => -7, -1 => 1, -2 => x, -3 => y }
|
||||
encode_cbor_map(params)
|
||||
end
|
||||
|
||||
def encode_cbor_attestation_object(auth_data, format:)
|
||||
bytes = [0xa3]
|
||||
bytes.concat([0x63, *"fmt".bytes])
|
||||
bytes.concat([0x40 + format.bytesize, *format.bytes])
|
||||
bytes.concat([0x67, *"attStmt".bytes])
|
||||
bytes << 0xa0
|
||||
bytes.concat([0x68, *"authData".bytes])
|
||||
if auth_data.bytesize <= 255
|
||||
bytes.concat([0x58, auth_data.bytesize])
|
||||
else
|
||||
bytes.concat([0x59, (auth_data.bytesize >> 8) & 0xff, auth_data.bytesize & 0xff])
|
||||
end
|
||||
bytes.concat(auth_data.bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def encode_cbor_map(hash)
|
||||
bytes = [0xa0 + hash.size]
|
||||
hash.each do |key, value|
|
||||
bytes.concat(encode_cbor_integer(key))
|
||||
bytes.concat(encode_cbor_value(value))
|
||||
end
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def encode_cbor_integer(int)
|
||||
if int >= 0 && int <= 23
|
||||
[int]
|
||||
elsif int >= -24 && int < 0
|
||||
[0x20 - int - 1]
|
||||
else
|
||||
raise "Integer encoding not implemented for #{int}"
|
||||
end
|
||||
end
|
||||
|
||||
def encode_cbor_value(value)
|
||||
case value
|
||||
when Integer
|
||||
encode_cbor_integer(value)
|
||||
when String
|
||||
length = value.bytesize
|
||||
length <= 23 ? [0x40 + length, *value.bytes] : [0x58, length, *value.bytes]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
@sign_count = 42
|
||||
@aaguid = SecureRandom.random_bytes(16)
|
||||
@credential_id = SecureRandom.random_bytes(32)
|
||||
|
||||
# Generate a real EC key
|
||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
public_key_bn = ec_key.public_key.to_bn
|
||||
public_key_point = public_key_bn.to_s(2)
|
||||
x_coord = public_key_point[1, 32]
|
||||
y_coord = public_key_point[33, 32]
|
||||
|
||||
@cose_key = build_cose_key(x_coord, y_coord)
|
||||
@auth_data = build_authenticator_data
|
||||
@attestation_object = build_attestation_object
|
||||
end
|
||||
|
||||
test "decodes attestation object" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
|
||||
assert_equal "none", attestation.format
|
||||
assert_equal({}, attestation.attestation_statement)
|
||||
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data
|
||||
end
|
||||
|
||||
test "delegates credential_id to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
|
||||
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), attestation.credential_id
|
||||
end
|
||||
|
||||
test "delegates sign_count to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
|
||||
assert_equal @sign_count, attestation.sign_count
|
||||
end
|
||||
|
||||
test "delegates public_key to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data
|
||||
bytes = []
|
||||
bytes.concat(@rp_id_hash.bytes)
|
||||
bytes << 0x41 # flags: user present + attested credential
|
||||
bytes.concat([@sign_count].pack("N").bytes)
|
||||
bytes.concat(@aaguid.bytes)
|
||||
bytes.concat([@credential_id.bytesize].pack("n").bytes)
|
||||
bytes.concat(@credential_id.bytes)
|
||||
bytes.concat(@cose_key.bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def build_attestation_object
|
||||
# CBOR map: { "fmt": "none", "attStmt": {}, "authData": <bytes> }
|
||||
encode_cbor_attestation_object
|
||||
end
|
||||
|
||||
def encode_cbor_attestation_object
|
||||
bytes = [0xa3] # map with 3 items
|
||||
|
||||
# "fmt" => "none"
|
||||
bytes.concat([0x63, *"fmt".bytes]) # text string "fmt"
|
||||
bytes.concat([0x64, *"none".bytes]) # text string "none"
|
||||
|
||||
# "attStmt" => {}
|
||||
bytes.concat([0x67, *"attStmt".bytes]) # text string "attStmt"
|
||||
bytes << 0xa0 # empty map
|
||||
|
||||
# "authData" => <bytes>
|
||||
bytes.concat([0x68, *"authData".bytes]) # text string "authData"
|
||||
auth_data_length = @auth_data.bytesize
|
||||
if auth_data_length <= 23
|
||||
bytes << (0x40 + auth_data_length)
|
||||
elsif auth_data_length <= 255
|
||||
bytes.concat([0x58, auth_data_length])
|
||||
else
|
||||
bytes.concat([0x59, (auth_data_length >> 8) & 0xff, auth_data_length & 0xff])
|
||||
end
|
||||
bytes.concat(@auth_data.bytes)
|
||||
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def build_cose_key(x, y)
|
||||
params = {
|
||||
1 => 2, # kty: EC2
|
||||
3 => -7, # alg: ES256
|
||||
-1 => 1, # crv: P-256
|
||||
-2 => x,
|
||||
-3 => y
|
||||
}
|
||||
encode_cbor_map(params)
|
||||
end
|
||||
|
||||
def encode_cbor_map(hash)
|
||||
bytes = [0xa0 + hash.size]
|
||||
hash.each do |key, value|
|
||||
bytes.concat(encode_cbor_integer(key))
|
||||
bytes.concat(encode_cbor_value(value))
|
||||
end
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def encode_cbor_integer(int)
|
||||
if int >= 0 && int <= 23
|
||||
[int]
|
||||
elsif int >= -24 && int < 0
|
||||
[0x20 - int - 1]
|
||||
else
|
||||
raise "Integer encoding not implemented for #{int}"
|
||||
end
|
||||
end
|
||||
|
||||
def encode_cbor_value(value)
|
||||
case value
|
||||
when Integer
|
||||
encode_cbor_integer(value)
|
||||
when String
|
||||
length = value.bytesize
|
||||
if length <= 23
|
||||
[0x40 + length, *value.bytes]
|
||||
else
|
||||
[0x58, length, *value.bytes]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
@sign_count = 42
|
||||
@aaguid = SecureRandom.random_bytes(16)
|
||||
@credential_id = SecureRandom.random_bytes(32)
|
||||
|
||||
# Generate a real EC key for COSE encoding
|
||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
public_key_bn = ec_key.public_key.to_bn
|
||||
public_key_point = public_key_bn.to_s(2)
|
||||
x_coord = public_key_point[1, 32]
|
||||
y_coord = public_key_point[33, 32]
|
||||
|
||||
@cose_key = build_cose_key(x_coord, y_coord)
|
||||
end
|
||||
|
||||
test "decodes authenticator data without attested credential" do
|
||||
flags = 0x01 # user present only
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
||||
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
|
||||
assert_equal @rp_id_hash, data.relying_party_id_hash
|
||||
assert_equal flags, data.flags
|
||||
assert_equal @sign_count, data.sign_count
|
||||
assert_nil data.credential_id
|
||||
assert_nil data.public_key_bytes
|
||||
end
|
||||
|
||||
test "decodes authenticator data with attested credential" do
|
||||
flags = 0x41 # user present + attested credential data
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: true)
|
||||
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
|
||||
assert_equal @rp_id_hash, data.relying_party_id_hash
|
||||
assert_equal flags, data.flags
|
||||
assert_equal @sign_count, data.sign_count
|
||||
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), data.credential_id
|
||||
assert_equal @cose_key, data.public_key_bytes
|
||||
end
|
||||
|
||||
test "user_present? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x01)
|
||||
assert data.user_present?
|
||||
end
|
||||
|
||||
test "user_present? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.user_present?
|
||||
end
|
||||
|
||||
test "user_verified? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x04)
|
||||
assert data.user_verified?
|
||||
end
|
||||
|
||||
test "user_verified? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.user_verified?
|
||||
end
|
||||
|
||||
test "backup_eligible? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x08)
|
||||
assert data.backup_eligible?
|
||||
end
|
||||
|
||||
test "backup_eligible? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.backup_eligible?
|
||||
end
|
||||
|
||||
test "backed_up? returns true when flag is set" do
|
||||
data = build_data_with_flags(0x10)
|
||||
assert data.backed_up?
|
||||
end
|
||||
|
||||
test "backed_up? returns false when flag is not set" do
|
||||
data = build_data_with_flags(0x00)
|
||||
assert_not data.backed_up?
|
||||
end
|
||||
|
||||
test "public_key returns OpenSSL key when public_key_bytes present" do
|
||||
flags = 0x41
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: true)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, data.public_key
|
||||
end
|
||||
|
||||
test "public_key returns nil when public_key_bytes not present" do
|
||||
flags = 0x01
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
|
||||
assert_nil data.public_key
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data(flags:, include_credential:)
|
||||
bytes = []
|
||||
bytes.concat(@rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([ @sign_count ].pack("N").bytes)
|
||||
|
||||
if include_credential
|
||||
bytes.concat(@aaguid.bytes)
|
||||
bytes.concat([ @credential_id.bytesize ].pack("n").bytes)
|
||||
bytes.concat(@credential_id.bytes)
|
||||
bytes.concat(@cose_key.bytes)
|
||||
end
|
||||
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def build_data_with_flags(flags)
|
||||
ActionPack::WebAuthn::Authenticator::Data.new(
|
||||
bytes: [],
|
||||
relying_party_id_hash: @rp_id_hash,
|
||||
flags: flags,
|
||||
sign_count: 0,
|
||||
credential_id: nil,
|
||||
public_key_bytes: nil
|
||||
)
|
||||
end
|
||||
|
||||
def build_cose_key(x, y)
|
||||
# Simple CBOR map for EC2 key
|
||||
params = {
|
||||
1 => 2, # kty: EC2
|
||||
3 => -7, # alg: ES256
|
||||
-1 => 1, # crv: P-256
|
||||
-2 => x,
|
||||
-3 => y
|
||||
}
|
||||
encode_cbor_map(params)
|
||||
end
|
||||
|
||||
def encode_cbor_map(hash)
|
||||
bytes = [ 0xa0 + hash.size ]
|
||||
hash.each do |key, value|
|
||||
bytes.concat(encode_cbor_integer(key))
|
||||
bytes.concat(encode_cbor_value(value))
|
||||
end
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def encode_cbor_integer(int)
|
||||
if int >= 0 && int <= 23
|
||||
[ int ]
|
||||
elsif int >= -24 && int < 0
|
||||
[ 0x20 - int - 1 ]
|
||||
else
|
||||
raise "Integer encoding not implemented for #{int}"
|
||||
end
|
||||
end
|
||||
|
||||
def encode_cbor_value(value)
|
||||
case value
|
||||
when Integer
|
||||
encode_cbor_integer(value)
|
||||
when String
|
||||
length = value.bytesize
|
||||
if length <= 23
|
||||
[ 0x40 + length, *value.bytes ]
|
||||
else
|
||||
[ 0x58, length, *value.bytes ]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = "test-challenge-123"
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create"
|
||||
}.to_json
|
||||
|
||||
@authenticator_data = build_authenticator_data
|
||||
@response = TestableResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
)
|
||||
end
|
||||
|
||||
class TestableResponse < ActionPack::WebAuthn::Authenticator::Response
|
||||
attr_reader :authenticator_data
|
||||
|
||||
def initialize(authenticator_data:, **attrs)
|
||||
super(**attrs)
|
||||
@authenticator_data = authenticator_data
|
||||
end
|
||||
end
|
||||
|
||||
test "parses client data JSON" do
|
||||
assert_equal @challenge, @response.client_data["challenge"]
|
||||
assert_equal @origin, @response.client_data["origin"]
|
||||
end
|
||||
|
||||
test "valid? returns true when challenge and origin match" do
|
||||
assert @response.valid?(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
test "valid? returns false when challenge does not match" do
|
||||
assert_not @response.valid?(challenge: "wrong-challenge", origin: @origin)
|
||||
end
|
||||
|
||||
test "valid? returns false when origin does not match" do
|
||||
assert_not @response.valid?(challenge: @challenge, origin: "https://evil.com")
|
||||
end
|
||||
|
||||
test "validate! raises when challenge does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Challenge does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when origin does not match" do
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when crossOrigin is true" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create",
|
||||
crossOrigin: true
|
||||
}.to_json
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Cross-origin requests are not supported", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when relying party ID does not match" do
|
||||
rp_id_hash = Digest::SHA256.digest("evil.com")
|
||||
flags = 0x05
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
|
||||
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: wrong_rp_data
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Relying party ID does not match", error.message
|
||||
end
|
||||
|
||||
test "validate! raises when tokenBinding status is present" do
|
||||
client_data_json = {
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
type: "webauthn.create",
|
||||
tokenBinding: { status: "present", id: "some-id" }
|
||||
}.to_json
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
end
|
||||
|
||||
assert_equal "Token binding is not supported", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def build_authenticator_data
|
||||
rp_id_hash = Digest::SHA256.digest("example.com")
|
||||
flags = 0x05 # user present + user verified
|
||||
sign_count = 0
|
||||
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,297 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
test "decodes unsigned integer 0" do
|
||||
assert_equal 0, decode("00")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1" do
|
||||
assert_equal 1, decode("01")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 10" do
|
||||
assert_equal 10, decode("0a")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 23" do
|
||||
assert_equal 23, decode("17")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 24 (single byte follows)" do
|
||||
assert_equal 24, decode("1818")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 25" do
|
||||
assert_equal 25, decode("1819")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 100" do
|
||||
assert_equal 100, decode("1864")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000 (two bytes follow)" do
|
||||
assert_equal 1000, decode("1903e8")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000000 (four bytes follow)" do
|
||||
assert_equal 1000000, decode("1a000f4240")
|
||||
end
|
||||
|
||||
test "decodes unsigned integer 1000000000000 (eight bytes follow)" do
|
||||
assert_equal 1000000000000, decode("1b000000e8d4a51000")
|
||||
end
|
||||
|
||||
test "decodes negative integer -1" do
|
||||
assert_equal(-1, decode("20"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -10" do
|
||||
assert_equal(-10, decode("29"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -100" do
|
||||
assert_equal(-100, decode("3863"))
|
||||
end
|
||||
|
||||
test "decodes negative integer -1000" do
|
||||
assert_equal(-1000, decode("3903e7"))
|
||||
end
|
||||
|
||||
test "decodes empty byte string" do
|
||||
assert_equal "", decode("40")
|
||||
end
|
||||
|
||||
test "decodes byte string with 4 bytes" do
|
||||
assert_equal "\x01\x02\x03\x04".b, decode("4401020304")
|
||||
end
|
||||
|
||||
test "decodes empty text string" do
|
||||
result = decode("60")
|
||||
assert_equal "", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string 'a'" do
|
||||
result = decode("6161")
|
||||
assert_equal "a", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string 'IETF'" do
|
||||
result = decode("6449455446")
|
||||
assert_equal "IETF", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes text string with unicode" do
|
||||
result = decode("62c3bc")
|
||||
assert_equal "ü", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes empty array" do
|
||||
assert_equal [], decode("80")
|
||||
end
|
||||
|
||||
test "decodes array [1, 2, 3]" do
|
||||
assert_equal [ 1, 2, 3 ], decode("83010203")
|
||||
end
|
||||
|
||||
test "decodes nested array [1, [2, 3], [4, 5]]" do
|
||||
assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode("8301820203820405")
|
||||
end
|
||||
|
||||
test "decodes array with 25 elements" do
|
||||
expected = (1..25).to_a
|
||||
# 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25)
|
||||
# integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819
|
||||
elements = (1..23).map { |n| format("%02x", n) }.join + "18181819"
|
||||
assert_equal expected, decode("9819" + elements)
|
||||
end
|
||||
|
||||
test "decodes empty map" do
|
||||
assert_equal({}, decode("a0"))
|
||||
end
|
||||
|
||||
test "decodes map {1: 2, 3: 4}" do
|
||||
assert_equal({ 1 => 2, 3 => 4 }, decode("a201020304"))
|
||||
end
|
||||
|
||||
test "decodes map with string keys" do
|
||||
assert_equal({ "a" => 1, "b" => 2 }, decode("a2616101616202"))
|
||||
end
|
||||
|
||||
test "decodes nested map" do
|
||||
assert_equal({ "a" => { "b" => 1 } }, decode("a16161a1616201"))
|
||||
end
|
||||
|
||||
test "decodes false" do
|
||||
assert_equal false, decode("f4")
|
||||
end
|
||||
|
||||
test "decodes true" do
|
||||
assert_equal true, decode("f5")
|
||||
end
|
||||
|
||||
test "decodes null" do
|
||||
assert_nil decode("f6")
|
||||
end
|
||||
|
||||
test "decodes undefined as nil" do
|
||||
assert_nil decode("f7")
|
||||
end
|
||||
|
||||
test "decodes tagged value, ignoring tag" do
|
||||
# 0xc0 = tag 0 (date/time string), followed by text "2013-03-21T20:04:00Z"
|
||||
assert_equal "2013-03-21T20:04:00Z", decode("c074323031332d30332d32315432303a30343a30305a")
|
||||
end
|
||||
|
||||
test "decodes tagged integer" do
|
||||
# 0xc1 = tag 1 (epoch time), followed by integer 1363896240
|
||||
assert_equal 1363896240, decode("c11a514b67b0")
|
||||
end
|
||||
|
||||
test "raises error for reserved additional info values" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode("1c")
|
||||
end
|
||||
end
|
||||
|
||||
test "decodes indefinite length array" do
|
||||
assert_equal [ 1, 2, 3 ], decode("9f010203ff")
|
||||
end
|
||||
|
||||
test "decodes empty indefinite length array" do
|
||||
assert_equal [], decode("9fff")
|
||||
end
|
||||
|
||||
test "decodes indefinite length map" do
|
||||
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
|
||||
end
|
||||
|
||||
test "decodes indefinite length byte string" do
|
||||
assert_equal "\x01\x02\x03".b, decode("5f4201024103ff")
|
||||
end
|
||||
|
||||
test "decodes indefinite length text string" do
|
||||
result = decode("7f657374726561646d696e67ff")
|
||||
assert_equal "streaming", result
|
||||
assert_equal Encoding::UTF_8, result.encoding
|
||||
end
|
||||
|
||||
test "decodes half-precision float 0.0" do
|
||||
assert_equal 0.0, decode("f90000")
|
||||
end
|
||||
|
||||
test "decodes half-precision float 1.0" do
|
||||
assert_equal 1.0, decode("f93c00")
|
||||
end
|
||||
|
||||
test "decodes half-precision float 1.5" do
|
||||
assert_equal 1.5, decode("f93e00")
|
||||
end
|
||||
|
||||
test "decodes half-precision float -4.0" do
|
||||
assert_equal(-4.0, decode("f9c400"))
|
||||
end
|
||||
|
||||
test "decodes half-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("f97c00")
|
||||
end
|
||||
|
||||
test "decodes half-precision NaN" do
|
||||
assert_predicate decode("f97e00"), :nan?
|
||||
end
|
||||
|
||||
test "decodes single-precision float 100000.0" do
|
||||
assert_equal 100000.0, decode("fa47c35000")
|
||||
end
|
||||
|
||||
test "decodes single-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("fa7f800000")
|
||||
end
|
||||
|
||||
test "decodes double-precision float 1.1" do
|
||||
assert_in_delta 1.1, decode("fb3ff199999999999a"), 0.0001
|
||||
end
|
||||
|
||||
test "decodes double-precision float -4.1" do
|
||||
assert_in_delta(-4.1, decode("fbc010666666666666"), 0.0001)
|
||||
end
|
||||
|
||||
test "decodes double-precision positive infinity" do
|
||||
assert_equal Float::INFINITY, decode("fb7ff0000000000000")
|
||||
end
|
||||
|
||||
test "decodes double-precision negative infinity" do
|
||||
assert_equal(-Float::INFINITY, decode("fbfff0000000000000"))
|
||||
end
|
||||
|
||||
test "raises error for unsupported simple value" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode("e0")
|
||||
end
|
||||
end
|
||||
|
||||
test "decode accepts string input" do
|
||||
bytes = [ 0x01 ].pack("C*")
|
||||
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
end
|
||||
|
||||
test "decode accepts array input" do
|
||||
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ])
|
||||
end
|
||||
|
||||
test "raises error for empty input" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
ActionPack::WebAuthn::CborDecoder.decode([])
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated byte string" do
|
||||
# 0x44 = byte string of length 4, but only 2 bytes follow
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode("440102")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated integer" do
|
||||
# 0x19 = 2-byte integer follows, but only 1 byte provided
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode("19ff")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for truncated array" do
|
||||
# 0x82 = array of 2 items, but only 1 provided
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode("8201")
|
||||
end
|
||||
end
|
||||
|
||||
test "raises error for deeply nested structure" do
|
||||
# Build array nested 20 levels deep: [[[[...]]]]
|
||||
# 0x81 = array of 1 item
|
||||
deeply_nested = "81" * 20 + "01"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
decode(deeply_nested)
|
||||
end
|
||||
|
||||
assert_equal "Maximum nesting depth exceeded", error.message
|
||||
end
|
||||
|
||||
test "raises error for input exceeding max size" do
|
||||
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
|
||||
end
|
||||
|
||||
assert_equal "Input exceeds maximum size", error.message
|
||||
end
|
||||
|
||||
private
|
||||
def decode(hex)
|
||||
bytes = [ hex ].pack("H*").bytes
|
||||
ActionPack::WebAuthn::CborDecoder.decode(bytes)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
# Generate a real EC key for valid test data
|
||||
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
public_key_bn = ec_key.public_key.to_bn
|
||||
public_key_bytes = public_key_bn.to_s(2)
|
||||
# Skip the 0x04 uncompressed point prefix
|
||||
@ec2_x = public_key_bytes[1, 32]
|
||||
@ec2_y = public_key_bytes[33, 32]
|
||||
|
||||
@ec2_parameters = {
|
||||
1 => 2, # kty: EC2
|
||||
3 => -7, # alg: ES256
|
||||
-1 => 1, # crv: P-256
|
||||
-2 => @ec2_x,
|
||||
-3 => @ec2_y
|
||||
}
|
||||
|
||||
# Generate a real RSA key for valid test data
|
||||
rsa_key = OpenSSL::PKey::RSA.new(2048)
|
||||
@rsa_n = rsa_key.n.to_s(2)
|
||||
@rsa_e = rsa_key.e.to_s(2)
|
||||
|
||||
@rsa_parameters = {
|
||||
1 => 3, # kty: RSA
|
||||
3 => -257, # alg: RS256
|
||||
-1 => @rsa_n,
|
||||
-2 => @rsa_e
|
||||
}
|
||||
end
|
||||
|
||||
test "initializes with key type, algorithm, and parameters" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: @ec2_parameters
|
||||
)
|
||||
|
||||
assert_equal 2, key.key_type
|
||||
assert_equal(-7, key.algorithm)
|
||||
assert_equal @ec2_parameters, key.parameters
|
||||
end
|
||||
|
||||
test "decodes EC2/ES256 key from CBOR" do
|
||||
cbor = encode_cbor(@ec2_parameters)
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
||||
|
||||
assert_equal 2, key.key_type
|
||||
assert_equal(-7, key.algorithm)
|
||||
end
|
||||
|
||||
test "decodes RSA/RS256 key from CBOR" do
|
||||
cbor = encode_cbor(@rsa_parameters)
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
||||
|
||||
assert_equal 3, key.key_type
|
||||
assert_equal(-257, key.algorithm)
|
||||
end
|
||||
|
||||
test "converts EC2/ES256 key to OpenSSL EC key" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: @ec2_parameters
|
||||
)
|
||||
|
||||
openssl_key = key.to_openssl_key
|
||||
|
||||
assert_instance_of OpenSSL::PKey::EC, openssl_key
|
||||
assert_equal "prime256v1", openssl_key.group.curve_name
|
||||
end
|
||||
|
||||
test "converts RSA/RS256 key to OpenSSL RSA key" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 3,
|
||||
algorithm: -257,
|
||||
parameters: @rsa_parameters
|
||||
)
|
||||
|
||||
openssl_key = key.to_openssl_key
|
||||
|
||||
assert_instance_of OpenSSL::PKey::RSA, openssl_key
|
||||
assert_equal 65537, openssl_key.e.to_i
|
||||
end
|
||||
|
||||
test "raises error for unsupported key type/algorithm combination" do
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 99,
|
||||
algorithm: -7,
|
||||
parameters: {}
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/99\/-7/, error.message)
|
||||
end
|
||||
|
||||
test "raises error for unsupported EC curve" do
|
||||
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
|
||||
key = ActionPack::WebAuthn::CoseKey.new(
|
||||
key_type: 2,
|
||||
algorithm: -7,
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
|
||||
key.to_openssl_key
|
||||
end
|
||||
|
||||
assert_match(/curve/, error.message.downcase)
|
||||
end
|
||||
|
||||
private
|
||||
def encode_cbor(hash)
|
||||
# CBOR map encoding
|
||||
bytes = [ 0xa0 + hash.size ] # map with n items
|
||||
|
||||
hash.each do |key, value|
|
||||
bytes.concat(encode_cbor_integer(key))
|
||||
bytes.concat(encode_cbor_value(value))
|
||||
end
|
||||
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
def encode_cbor_integer(int)
|
||||
if int >= 0 && int <= 23
|
||||
[ int ]
|
||||
elsif int >= 0 && int <= 255
|
||||
[ 0x18, int ]
|
||||
elsif int >= -24 && int < 0
|
||||
[ 0x20 - int - 1 ]
|
||||
elsif int >= -256 && int < -24
|
||||
[ 0x38, -int - 1 ]
|
||||
else
|
||||
# 16-bit negative integer
|
||||
val = -int - 1
|
||||
[ 0x39, (val >> 8) & 0xff, val & 0xff ]
|
||||
end
|
||||
end
|
||||
|
||||
def encode_cbor_value(value)
|
||||
case value
|
||||
when Integer
|
||||
encode_cbor_integer(value)
|
||||
when String
|
||||
length = value.bytesize
|
||||
if length <= 23
|
||||
[ 0x40 + length, *value.bytes ]
|
||||
elsif length <= 255
|
||||
[ 0x58, length, *value.bytes ]
|
||||
else
|
||||
[ 0x59, (length >> 8) & 0xff, length & 0xff, *value.bytes ]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
@options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
relying_party: @relying_party
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with required parameters" do
|
||||
assert_equal "user-123", @options.id
|
||||
assert_equal "user@example.com", @options.name
|
||||
assert_equal "Test User", @options.display_name
|
||||
assert_equal @relying_party, @options.relying_party
|
||||
end
|
||||
|
||||
test "generates base64url encoded challenge" do
|
||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||
end
|
||||
|
||||
test "generates challenge of correct length" do
|
||||
decoded = Base64.urlsafe_decode64(@options.challenge)
|
||||
assert_equal 32, decoded.bytesize
|
||||
end
|
||||
|
||||
test "as_json" do
|
||||
assert_equal @options.challenge, @options.as_json[:challenge]
|
||||
|
||||
assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp])
|
||||
|
||||
user = @options.as_json[:user]
|
||||
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id]
|
||||
assert_equal "user@example.com", user[:name]
|
||||
assert_equal "Test User", user[:displayName]
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", alg: -7 },
|
||||
{ type: "public-key", alg: -257 }
|
||||
], @options.as_json[:pubKeyCredParams]
|
||||
|
||||
assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey]
|
||||
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
|
||||
end
|
||||
|
||||
test "as_json includes residentKey in authenticatorSelection" do
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
resident_key: :required,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
|
||||
end
|
||||
|
||||
test "as_json excludes excludeCredentials when empty" do
|
||||
assert_nil @options.as_json[:excludeCredentials]
|
||||
end
|
||||
|
||||
test "as_json includes excludeCredentials" do
|
||||
credential = Struct.new(:id, :transports)
|
||||
credentials = [
|
||||
credential.new("cred-1", [ "usb", "nfc" ]),
|
||||
credential.new("cred-2", [ "internal" ])
|
||||
]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
exclude_credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
|
||||
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
|
||||
], options.as_json[:excludeCredentials]
|
||||
end
|
||||
|
||||
test "as_json excludeCredentials omits transports when empty" do
|
||||
credential = Struct.new(:id, :transports).new("cred-1", [])
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
display_name: "Test User",
|
||||
exclude_credentials: [ credential ],
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1" }
|
||||
], options.as_json[:excludeCredentials]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
credential = Struct.new(:id, :transports)
|
||||
@credentials = [
|
||||
credential.new("credential-1", []),
|
||||
credential.new("credential-2", [])
|
||||
]
|
||||
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: @credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
end
|
||||
|
||||
test "initializes with required parameters" do
|
||||
assert_equal @credentials, @options.credentials
|
||||
assert_equal @relying_party, @options.relying_party
|
||||
end
|
||||
|
||||
test "generates base64url encoded challenge" do
|
||||
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
|
||||
end
|
||||
|
||||
test "generates challenge of correct length" do
|
||||
decoded = Base64.urlsafe_decode64(@options.challenge)
|
||||
assert_equal 32, decoded.bytesize
|
||||
end
|
||||
|
||||
test "as_json" do
|
||||
assert_equal @options.challenge, @options.as_json[:challenge]
|
||||
assert_equal "example.com", @options.as_json[:rpId]
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "credential-1" },
|
||||
{ type: "public-key", id: "credential-2" }
|
||||
], @options.as_json[:allowCredentials]
|
||||
assert_equal "preferred", @options.as_json[:userVerification]
|
||||
end
|
||||
|
||||
test "as_json includes transports when present" do
|
||||
credential = Struct.new(:id, :transports)
|
||||
credentials = [
|
||||
credential.new("cred-1", [ "usb", "nfc" ]),
|
||||
credential.new("cred-2", [ "internal" ])
|
||||
]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
|
||||
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
|
||||
], options.as_json[:allowCredentials]
|
||||
end
|
||||
|
||||
test "as_json omits transports when empty" do
|
||||
credential = Struct.new(:id, :transports)
|
||||
credentials = [ credential.new("cred-1", []) ]
|
||||
|
||||
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
|
||||
credentials: credentials,
|
||||
relying_party: @relying_party
|
||||
)
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", id: "cred-1" }
|
||||
], options.as_json[:allowCredentials]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase
|
||||
test "initializes with explicit id and name" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
|
||||
assert_equal "example.com", relying_party.id
|
||||
assert_equal "Example App", relying_party.name
|
||||
end
|
||||
|
||||
test "initializes with default id from Current.host" do
|
||||
ActionPack::WebAuthn::Current.set(host: "default.example.com") do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(name: "Example App")
|
||||
|
||||
assert_equal "default.example.com", relying_party.id
|
||||
end
|
||||
end
|
||||
|
||||
test "initializes with default name from Rails application" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com")
|
||||
|
||||
assert_equal Rails.application.name, relying_party.name
|
||||
end
|
||||
|
||||
test "as_json returns id and name" do
|
||||
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
|
||||
|
||||
assert_equal({ id: "example.com", name: "Example App" }, relying_party.as_json)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user