# = 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. # # [EdDSA] # EdDSA with Ed25519 curve. Increasingly supported by modern authenticators. # # [RS256] # RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms. # # == Attributes # # [+key_type+] # The COSE key type (1 for OKP, 2 for EC2, 3 for RSA). # # [+algorithm+] # The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256). # # [+parameters+] # The full COSE key parameters map, including curve and coordinate data. class ActionPack::WebAuthn::CoseKey P256_COORDINATE_LENGTH = 32 MINIMUM_RSA_KEY_BITS = 2048 # 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 OKP_CURVE_LABEL = -1 OKP_X_LABEL = -2 # COSE key types OKP = 1 EC2 = 2 RSA = 3 # COSE algorithms ES256 = -7 EDDSA = -8 RS256 = -257 # COSE EC2 curves P256 = 1 # COSE OKP curves ED25519 = 6 # 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, +OpenSSL::PKey::RSA+ for # RSA keys, or an Ed25519 key for OKP 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 [ OKP, EDDSA ] then build_okp_eddsa_key when [ RSA, RS256 ] then build_rsa_rs256_key else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}" end end private def build_ec2_es256_key curve = parameters[EC2_CURVE_LABEL] raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256 x = parameters[EC2_X_LABEL] y = parameters[EC2_Y_LABEL] raise ActionPack::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil? raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH # 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) rescue OpenSSL::PKey::PKeyError => error raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}" end def build_okp_eddsa_key curve = parameters[OKP_CURVE_LABEL] raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519 x = parameters[OKP_X_LABEL] raise ActionPack::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil? asn1 = OpenSSL::ASN1::Sequence([ OpenSSL::ASN1::Sequence([ OpenSSL::ASN1::ObjectId("ED25519") ]), OpenSSL::ASN1::BitString(x) ]) OpenSSL::PKey.read(asn1.to_der) rescue OpenSSL::PKey::PKeyError => error raise ActionPack::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}" end def build_rsa_rs256_key n_bytes = parameters[RSA_N_LABEL] e_bytes = parameters[RSA_E_LABEL] raise ActionPack::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil? raise ActionPack::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS n = OpenSSL::BN.new(n_bytes, 2) e = OpenSSL::BN.new(e_bytes, 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) rescue OpenSSL::PKey::PKeyError => error raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}" end end