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:
Stanko K.R.
2026-02-04 12:39:50 +01:00
parent 768e7a3a6e
commit 4bb0a2929c
24 changed files with 2626 additions and 0 deletions
+7
View File
@@ -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
+262
View File
@@ -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
+141
View File
@@ -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
+3
View File
@@ -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