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

184 lines
5.7 KiB
Ruby

# = 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