diff --git a/lib/action_pack/web_authn.rb b/lib/action_pack/web_authn.rb new file mode 100644 index 000000000..8ee72e6cb --- /dev/null +++ b/lib/action_pack/web_authn.rb @@ -0,0 +1,7 @@ +module ActionPack::WebAuthn + class << self + def relying_party + RelyingParty.new + end + end +end diff --git a/lib/action_pack/web_authn/authenticator/assertion_response.rb b/lib/action_pack/web_authn/authenticator/assertion_response.rb new file mode 100644 index 000000000..fa6aafcfd --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/assertion_response.rb @@ -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 diff --git a/lib/action_pack/web_authn/authenticator/attestation.rb b/lib/action_pack/web_authn/authenticator/attestation.rb new file mode 100644 index 000000000..66714fd6c --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/attestation.rb @@ -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 diff --git a/lib/action_pack/web_authn/authenticator/attestation_response.rb b/lib/action_pack/web_authn/authenticator/attestation_response.rb new file mode 100644 index 000000000..e20b4d7f7 --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/attestation_response.rb @@ -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 diff --git a/lib/action_pack/web_authn/authenticator/data.rb b/lib/action_pack/web_authn/authenticator/data.rb new file mode 100644 index 000000000..a088179ef --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/data.rb @@ -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 diff --git a/lib/action_pack/web_authn/authenticator/response.rb b/lib/action_pack/web_authn/authenticator/response.rb new file mode 100644 index 000000000..cdedc8411 --- /dev/null +++ b/lib/action_pack/web_authn/authenticator/response.rb @@ -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 diff --git a/lib/action_pack/web_authn/cbor_decoder.rb b/lib/action_pack/web_authn/cbor_decoder.rb new file mode 100644 index 000000000..af8e284f7 --- /dev/null +++ b/lib/action_pack/web_authn/cbor_decoder.rb @@ -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 diff --git a/lib/action_pack/web_authn/cose_key.rb b/lib/action_pack/web_authn/cose_key.rb new file mode 100644 index 000000000..53ef3037c --- /dev/null +++ b/lib/action_pack/web_authn/cose_key.rb @@ -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 diff --git a/lib/action_pack/web_authn/current.rb b/lib/action_pack/web_authn/current.rb new file mode 100644 index 000000000..ab9038a80 --- /dev/null +++ b/lib/action_pack/web_authn/current.rb @@ -0,0 +1,3 @@ +class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes + attribute :host +end diff --git a/lib/action_pack/web_authn/public_key_credential.rb b/lib/action_pack/web_authn/public_key_credential.rb new file mode 100644 index 000000000..1e00b74fb --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential.rb @@ -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 diff --git a/lib/action_pack/web_authn/public_key_credential/creation_options.rb b/lib/action_pack/web_authn/public_key_credential/creation_options.rb new file mode 100644 index 000000000..0497a2bad --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/creation_options.rb @@ -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 diff --git a/lib/action_pack/web_authn/public_key_credential/options.rb b/lib/action_pack/web_authn/public_key_credential/options.rb new file mode 100644 index 000000000..24c0fc29b --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/options.rb @@ -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 diff --git a/lib/action_pack/web_authn/public_key_credential/request_options.rb b/lib/action_pack/web_authn/public_key_credential/request_options.rb new file mode 100644 index 000000000..0b62ad532 --- /dev/null +++ b/lib/action_pack/web_authn/public_key_credential/request_options.rb @@ -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 diff --git a/lib/action_pack/web_authn/relying_party.rb b/lib/action_pack/web_authn/relying_party.rb new file mode 100644 index 000000000..08dba0d36 --- /dev/null +++ b/lib/action_pack/web_authn/relying_party.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb b/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb new file mode 100644 index 000000000..68abaf2df --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/assertion_response_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb new file mode 100644 index 000000000..5d58a3ff4 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/authenticator/attestation_test.rb b/test/lib/action_pack/web_authn/authenticator/attestation_test.rb new file mode 100644 index 000000000..4531039e0 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/attestation_test.rb @@ -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": } + 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.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 diff --git a/test/lib/action_pack/web_authn/authenticator/data_test.rb b/test/lib/action_pack/web_authn/authenticator/data_test.rb new file mode 100644 index 000000000..3291585cc --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/data_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/authenticator/response_test.rb b/test/lib/action_pack/web_authn/authenticator/response_test.rb new file mode 100644 index 000000000..8a172e561 --- /dev/null +++ b/test/lib/action_pack/web_authn/authenticator/response_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/cbor_decoder_test.rb b/test/lib/action_pack/web_authn/cbor_decoder_test.rb new file mode 100644 index 000000000..ea4a12984 --- /dev/null +++ b/test/lib/action_pack/web_authn/cbor_decoder_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/cose_key_test.rb b/test/lib/action_pack/web_authn/cose_key_test.rb new file mode 100644 index 000000000..a3f9a33e9 --- /dev/null +++ b/test/lib/action_pack/web_authn/cose_key_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb new file mode 100644 index 000000000..e4bd049c1 --- /dev/null +++ b/test/lib/action_pack/web_authn/public_key_credential/creation_options_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb b/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb new file mode 100644 index 000000000..4c6ee3baa --- /dev/null +++ b/test/lib/action_pack/web_authn/public_key_credential/request_options_test.rb @@ -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 diff --git a/test/lib/action_pack/web_authn/relying_party_test.rb b/test/lib/action_pack/web_authn/relying_party_test.rb new file mode 100644 index 000000000..1ea06c425 --- /dev/null +++ b/test/lib/action_pack/web_authn/relying_party_test.rb @@ -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