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
@@ -0,0 +1,158 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.get"
}.to_json
# Generate a real key pair for signature verification
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
@public_key = @private_key
@credential = Struct.new(:public_key, :sign_count).new(@public_key, 0)
@authenticator_data = build_authenticator_data(user_verified: true)
@signature = sign(@authenticator_data, @client_data_json)
@response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: @signature,
credential: @credential
)
end
test "initializes with credential, authenticator data, and signature" do
assert_equal @credential, @response.credential
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data
assert_equal @signature, @response.signature
end
test "validate! succeeds with valid challenge, origin, type, and signature" do
assert_nothing_raised do
@response.validate!(challenge: @challenge, origin: @origin)
end
end
test "validate! raises when type is not webauthn.get" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
signature: sign(@authenticator_data, client_data_json),
credential: @credential
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Client data type is not webauthn.get", error.message
end
test "validate! raises when signature is invalid" do
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: "invalid-signature",
credential: @credential
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
end
assert_equal "Origin does not match", error.message
end
test "validate! succeeds with user_verification preferred when not verified" do
authenticator_data = build_authenticator_data(user_verified: false)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
end
end
test "validate! succeeds with user_verification required when verified" do
authenticator_data = build_authenticator_data(user_verified: true)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
end
end
test "validate! raises with user_verification required when not verified" do
authenticator_data = build_authenticator_data(user_verified: false)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
end
assert_equal "User verification is required", error.message
end
private
def build_authenticator_data(user_verified:)
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x01 # user present
flags |= 0x04 if user_verified
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.pack("C*")
end
def sign(authenticator_data, client_data_json)
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data + client_data_hash
@private_key.sign("SHA256", signed_data)
end
end
@@ -0,0 +1,195 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true)
)
end
test "initializes with attestation object" do
assert_not_nil @response.attestation_object
end
test "validate! succeeds with valid challenge, origin, and type" do
assert_nothing_raised do
@response.validate!(challenge: @challenge, origin: @origin)
end
end
test "validate! succeeds with user_verification preferred when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: false)
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
end
end
test "validate! succeeds with user_verification required when verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true)
)
assert_nothing_raised do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
end
end
test "validate! raises with user_verification required when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: false)
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
end
assert_equal "User verification is required", error.message
end
test "validate! raises when type is not webauthn.create" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.get"
}.to_json
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: build_attestation_object(user_verified: true)
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Client data type is not webauthn.create", error.message
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
end
assert_equal "Origin does not match", error.message
end
test "validate! raises when attestation format is not supported" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed")
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Unsupported attestation format: packed", error.message
end
private
def build_attestation_object(user_verified:, format: "none")
auth_data = build_authenticator_data(user_verified: user_verified)
encode_cbor_attestation_object(auth_data, format: format)
end
def build_authenticator_data(user_verified:)
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x41 # user present + attested credential
flags |= 0x04 if user_verified
sign_count = 0
aaguid = SecureRandom.random_bytes(16)
credential_id = SecureRandom.random_bytes(32)
cose_key = build_cose_key
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
bytes.concat(aaguid.bytes)
bytes.concat([credential_id.bytesize].pack("n").bytes)
bytes.concat(credential_id.bytes)
bytes.concat(cose_key.bytes)
bytes.pack("C*")
end
def build_cose_key
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x = public_key_point[1, 32]
y = public_key_point[33, 32]
params = { 1 => 2, 3 => -7, -1 => 1, -2 => x, -3 => y }
encode_cbor_map(params)
end
def encode_cbor_attestation_object(auth_data, format:)
bytes = [0xa3]
bytes.concat([0x63, *"fmt".bytes])
bytes.concat([0x40 + format.bytesize, *format.bytes])
bytes.concat([0x67, *"attStmt".bytes])
bytes << 0xa0
bytes.concat([0x68, *"authData".bytes])
if auth_data.bytesize <= 255
bytes.concat([0x58, auth_data.bytesize])
else
bytes.concat([0x59, (auth_data.bytesize >> 8) & 0xff, auth_data.bytesize & 0xff])
end
bytes.concat(auth_data.bytes)
bytes.pack("C*")
end
def encode_cbor_map(hash)
bytes = [0xa0 + hash.size]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[int]
elsif int >= -24 && int < 0
[0x20 - int - 1]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
length <= 23 ? [0x40 + length, *value.bytes] : [0x58, length, *value.bytes]
end
end
end
@@ -0,0 +1,135 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
setup do
@rp_id_hash = Digest::SHA256.digest("example.com")
@sign_count = 42
@aaguid = SecureRandom.random_bytes(16)
@credential_id = SecureRandom.random_bytes(32)
# Generate a real EC key
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x_coord = public_key_point[1, 32]
y_coord = public_key_point[33, 32]
@cose_key = build_cose_key(x_coord, y_coord)
@auth_data = build_authenticator_data
@attestation_object = build_attestation_object
end
test "decodes attestation object" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
assert_equal "none", attestation.format
assert_equal({}, attestation.attestation_statement)
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data
end
test "delegates credential_id to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), attestation.credential_id
end
test "delegates sign_count to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
assert_equal @sign_count, attestation.sign_count
end
test "delegates public_key to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
end
private
def build_authenticator_data
bytes = []
bytes.concat(@rp_id_hash.bytes)
bytes << 0x41 # flags: user present + attested credential
bytes.concat([@sign_count].pack("N").bytes)
bytes.concat(@aaguid.bytes)
bytes.concat([@credential_id.bytesize].pack("n").bytes)
bytes.concat(@credential_id.bytes)
bytes.concat(@cose_key.bytes)
bytes.pack("C*")
end
def build_attestation_object
# CBOR map: { "fmt": "none", "attStmt": {}, "authData": <bytes> }
encode_cbor_attestation_object
end
def encode_cbor_attestation_object
bytes = [0xa3] # map with 3 items
# "fmt" => "none"
bytes.concat([0x63, *"fmt".bytes]) # text string "fmt"
bytes.concat([0x64, *"none".bytes]) # text string "none"
# "attStmt" => {}
bytes.concat([0x67, *"attStmt".bytes]) # text string "attStmt"
bytes << 0xa0 # empty map
# "authData" => <bytes>
bytes.concat([0x68, *"authData".bytes]) # text string "authData"
auth_data_length = @auth_data.bytesize
if auth_data_length <= 23
bytes << (0x40 + auth_data_length)
elsif auth_data_length <= 255
bytes.concat([0x58, auth_data_length])
else
bytes.concat([0x59, (auth_data_length >> 8) & 0xff, auth_data_length & 0xff])
end
bytes.concat(@auth_data.bytes)
bytes.pack("C*")
end
def build_cose_key(x, y)
params = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => x,
-3 => y
}
encode_cbor_map(params)
end
def encode_cbor_map(hash)
bytes = [0xa0 + hash.size]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[int]
elsif int >= -24 && int < 0
[0x20 - int - 1]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[0x40 + length, *value.bytes]
else
[0x58, length, *value.bytes]
end
end
end
end
@@ -0,0 +1,174 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
setup do
@rp_id_hash = Digest::SHA256.digest("example.com")
@sign_count = 42
@aaguid = SecureRandom.random_bytes(16)
@credential_id = SecureRandom.random_bytes(32)
# Generate a real EC key for COSE encoding
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_point = public_key_bn.to_s(2)
x_coord = public_key_point[1, 32]
y_coord = public_key_point[33, 32]
@cose_key = build_cose_key(x_coord, y_coord)
end
test "decodes authenticator data without attested credential" do
flags = 0x01 # user present only
bytes = build_authenticator_data(flags: flags, include_credential: false)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_equal @rp_id_hash, data.relying_party_id_hash
assert_equal flags, data.flags
assert_equal @sign_count, data.sign_count
assert_nil data.credential_id
assert_nil data.public_key_bytes
end
test "decodes authenticator data with attested credential" do
flags = 0x41 # user present + attested credential data
bytes = build_authenticator_data(flags: flags, include_credential: true)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_equal @rp_id_hash, data.relying_party_id_hash
assert_equal flags, data.flags
assert_equal @sign_count, data.sign_count
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), data.credential_id
assert_equal @cose_key, data.public_key_bytes
end
test "user_present? returns true when flag is set" do
data = build_data_with_flags(0x01)
assert data.user_present?
end
test "user_present? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.user_present?
end
test "user_verified? returns true when flag is set" do
data = build_data_with_flags(0x04)
assert data.user_verified?
end
test "user_verified? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.user_verified?
end
test "backup_eligible? returns true when flag is set" do
data = build_data_with_flags(0x08)
assert data.backup_eligible?
end
test "backup_eligible? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.backup_eligible?
end
test "backed_up? returns true when flag is set" do
data = build_data_with_flags(0x10)
assert data.backed_up?
end
test "backed_up? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.backed_up?
end
test "public_key returns OpenSSL key when public_key_bytes present" do
flags = 0x41
bytes = build_authenticator_data(flags: flags, include_credential: true)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_instance_of OpenSSL::PKey::EC, data.public_key
end
test "public_key returns nil when public_key_bytes not present" do
flags = 0x01
bytes = build_authenticator_data(flags: flags, include_credential: false)
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
assert_nil data.public_key
end
private
def build_authenticator_data(flags:, include_credential:)
bytes = []
bytes.concat(@rp_id_hash.bytes)
bytes << flags
bytes.concat([ @sign_count ].pack("N").bytes)
if include_credential
bytes.concat(@aaguid.bytes)
bytes.concat([ @credential_id.bytesize ].pack("n").bytes)
bytes.concat(@credential_id.bytes)
bytes.concat(@cose_key.bytes)
end
bytes.pack("C*")
end
def build_data_with_flags(flags)
ActionPack::WebAuthn::Authenticator::Data.new(
bytes: [],
relying_party_id_hash: @rp_id_hash,
flags: flags,
sign_count: 0,
credential_id: nil,
public_key_bytes: nil
)
end
def build_cose_key(x, y)
# Simple CBOR map for EC2 key
params = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => x,
-3 => y
}
encode_cbor_map(params)
end
def encode_cbor_map(hash)
bytes = [ 0xa0 + hash.size ]
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[ int ]
elsif int >= -24 && int < 0
[ 0x20 - int - 1 ]
else
raise "Integer encoding not implemented for #{int}"
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[ 0x40 + length, *value.bytes ]
else
[ 0x58, length, *value.bytes ]
end
end
end
end
@@ -0,0 +1,141 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = "test-challenge-123"
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
@authenticator_data = build_authenticator_data
@response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data
)
end
class TestableResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :authenticator_data
def initialize(authenticator_data:, **attrs)
super(**attrs)
@authenticator_data = authenticator_data
end
end
test "parses client data JSON" do
assert_equal @challenge, @response.client_data["challenge"]
assert_equal @origin, @response.client_data["origin"]
end
test "valid? returns true when challenge and origin match" do
assert @response.valid?(challenge: @challenge, origin: @origin)
end
test "valid? returns false when challenge does not match" do
assert_not @response.valid?(challenge: "wrong-challenge", origin: @origin)
end
test "valid? returns false when origin does not match" do
assert_not @response.valid?(challenge: @challenge, origin: "https://evil.com")
end
test "validate! raises when challenge does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: "wrong-challenge", origin: @origin)
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@response.validate!(challenge: @challenge, origin: "https://evil.com")
end
assert_equal "Origin does not match", error.message
end
test "validate! raises when crossOrigin is true" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create",
crossOrigin: true
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Cross-origin requests are not supported", error.message
end
test "validate! raises when relying party ID does not match" do
rp_id_hash = Digest::SHA256.digest("evil.com")
flags = 0x05
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: wrong_rp_data
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Relying party ID does not match", error.message
end
test "validate! raises when tokenBinding status is present" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create",
tokenBinding: { status: "present", id: "some-id" }
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data
)
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
response.validate!(challenge: @challenge, origin: @origin)
end
assert_equal "Token binding is not supported", error.message
end
private
def build_authenticator_data
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x05 # user present + user verified
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([sign_count].pack("N").bytes)
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
end
end
@@ -0,0 +1,297 @@
require "test_helper"
class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
test "decodes unsigned integer 0" do
assert_equal 0, decode("00")
end
test "decodes unsigned integer 1" do
assert_equal 1, decode("01")
end
test "decodes unsigned integer 10" do
assert_equal 10, decode("0a")
end
test "decodes unsigned integer 23" do
assert_equal 23, decode("17")
end
test "decodes unsigned integer 24 (single byte follows)" do
assert_equal 24, decode("1818")
end
test "decodes unsigned integer 25" do
assert_equal 25, decode("1819")
end
test "decodes unsigned integer 100" do
assert_equal 100, decode("1864")
end
test "decodes unsigned integer 1000 (two bytes follow)" do
assert_equal 1000, decode("1903e8")
end
test "decodes unsigned integer 1000000 (four bytes follow)" do
assert_equal 1000000, decode("1a000f4240")
end
test "decodes unsigned integer 1000000000000 (eight bytes follow)" do
assert_equal 1000000000000, decode("1b000000e8d4a51000")
end
test "decodes negative integer -1" do
assert_equal(-1, decode("20"))
end
test "decodes negative integer -10" do
assert_equal(-10, decode("29"))
end
test "decodes negative integer -100" do
assert_equal(-100, decode("3863"))
end
test "decodes negative integer -1000" do
assert_equal(-1000, decode("3903e7"))
end
test "decodes empty byte string" do
assert_equal "", decode("40")
end
test "decodes byte string with 4 bytes" do
assert_equal "\x01\x02\x03\x04".b, decode("4401020304")
end
test "decodes empty text string" do
result = decode("60")
assert_equal "", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string 'a'" do
result = decode("6161")
assert_equal "a", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string 'IETF'" do
result = decode("6449455446")
assert_equal "IETF", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string with unicode" do
result = decode("62c3bc")
assert_equal "ü", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes empty array" do
assert_equal [], decode("80")
end
test "decodes array [1, 2, 3]" do
assert_equal [ 1, 2, 3 ], decode("83010203")
end
test "decodes nested array [1, [2, 3], [4, 5]]" do
assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode("8301820203820405")
end
test "decodes array with 25 elements" do
expected = (1..25).to_a
# 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25)
# integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819
elements = (1..23).map { |n| format("%02x", n) }.join + "18181819"
assert_equal expected, decode("9819" + elements)
end
test "decodes empty map" do
assert_equal({}, decode("a0"))
end
test "decodes map {1: 2, 3: 4}" do
assert_equal({ 1 => 2, 3 => 4 }, decode("a201020304"))
end
test "decodes map with string keys" do
assert_equal({ "a" => 1, "b" => 2 }, decode("a2616101616202"))
end
test "decodes nested map" do
assert_equal({ "a" => { "b" => 1 } }, decode("a16161a1616201"))
end
test "decodes false" do
assert_equal false, decode("f4")
end
test "decodes true" do
assert_equal true, decode("f5")
end
test "decodes null" do
assert_nil decode("f6")
end
test "decodes undefined as nil" do
assert_nil decode("f7")
end
test "decodes tagged value, ignoring tag" do
# 0xc0 = tag 0 (date/time string), followed by text "2013-03-21T20:04:00Z"
assert_equal "2013-03-21T20:04:00Z", decode("c074323031332d30332d32315432303a30343a30305a")
end
test "decodes tagged integer" do
# 0xc1 = tag 1 (epoch time), followed by integer 1363896240
assert_equal 1363896240, decode("c11a514b67b0")
end
test "raises error for reserved additional info values" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode("1c")
end
end
test "decodes indefinite length array" do
assert_equal [ 1, 2, 3 ], decode("9f010203ff")
end
test "decodes empty indefinite length array" do
assert_equal [], decode("9fff")
end
test "decodes indefinite length map" do
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
end
test "decodes indefinite length byte string" do
assert_equal "\x01\x02\x03".b, decode("5f4201024103ff")
end
test "decodes indefinite length text string" do
result = decode("7f657374726561646d696e67ff")
assert_equal "streaming", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes half-precision float 0.0" do
assert_equal 0.0, decode("f90000")
end
test "decodes half-precision float 1.0" do
assert_equal 1.0, decode("f93c00")
end
test "decodes half-precision float 1.5" do
assert_equal 1.5, decode("f93e00")
end
test "decodes half-precision float -4.0" do
assert_equal(-4.0, decode("f9c400"))
end
test "decodes half-precision positive infinity" do
assert_equal Float::INFINITY, decode("f97c00")
end
test "decodes half-precision NaN" do
assert_predicate decode("f97e00"), :nan?
end
test "decodes single-precision float 100000.0" do
assert_equal 100000.0, decode("fa47c35000")
end
test "decodes single-precision positive infinity" do
assert_equal Float::INFINITY, decode("fa7f800000")
end
test "decodes double-precision float 1.1" do
assert_in_delta 1.1, decode("fb3ff199999999999a"), 0.0001
end
test "decodes double-precision float -4.1" do
assert_in_delta(-4.1, decode("fbc010666666666666"), 0.0001)
end
test "decodes double-precision positive infinity" do
assert_equal Float::INFINITY, decode("fb7ff0000000000000")
end
test "decodes double-precision negative infinity" do
assert_equal(-Float::INFINITY, decode("fbfff0000000000000"))
end
test "raises error for unsupported simple value" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode("e0")
end
end
test "decode accepts string input" do
bytes = [ 0x01 ].pack("C*")
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes)
end
test "decode accepts array input" do
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ])
end
test "raises error for empty input" do
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
ActionPack::WebAuthn::CborDecoder.decode([])
end
end
test "raises error for truncated byte string" do
# 0x44 = byte string of length 4, but only 2 bytes follow
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode("440102")
end
end
test "raises error for truncated integer" do
# 0x19 = 2-byte integer follows, but only 1 byte provided
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode("19ff")
end
end
test "raises error for truncated array" do
# 0x82 = array of 2 items, but only 1 provided
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode("8201")
end
end
test "raises error for deeply nested structure" do
# Build array nested 20 levels deep: [[[[...]]]]
# 0x81 = array of 1 item
deeply_nested = "81" * 20 + "01"
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
decode(deeply_nested)
end
assert_equal "Maximum nesting depth exceeded", error.message
end
test "raises error for input exceeding max size" do
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
end
assert_equal "Input exceeds maximum size", error.message
end
private
def decode(hex)
bytes = [ hex ].pack("H*").bytes
ActionPack::WebAuthn::CborDecoder.decode(bytes)
end
end
@@ -0,0 +1,161 @@
require "test_helper"
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
setup do
# Generate a real EC key for valid test data
ec_key = OpenSSL::PKey::EC.generate("prime256v1")
public_key_bn = ec_key.public_key.to_bn
public_key_bytes = public_key_bn.to_s(2)
# Skip the 0x04 uncompressed point prefix
@ec2_x = public_key_bytes[1, 32]
@ec2_y = public_key_bytes[33, 32]
@ec2_parameters = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => @ec2_x,
-3 => @ec2_y
}
# Generate a real RSA key for valid test data
rsa_key = OpenSSL::PKey::RSA.new(2048)
@rsa_n = rsa_key.n.to_s(2)
@rsa_e = rsa_key.e.to_s(2)
@rsa_parameters = {
1 => 3, # kty: RSA
3 => -257, # alg: RS256
-1 => @rsa_n,
-2 => @rsa_e
}
end
test "initializes with key type, algorithm, and parameters" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: @ec2_parameters
)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
assert_equal @ec2_parameters, key.parameters
end
test "decodes EC2/ES256 key from CBOR" do
cbor = encode_cbor(@ec2_parameters)
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
end
test "decodes RSA/RS256 key from CBOR" do
cbor = encode_cbor(@rsa_parameters)
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
assert_equal 3, key.key_type
assert_equal(-257, key.algorithm)
end
test "converts EC2/ES256 key to OpenSSL EC key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: @ec2_parameters
)
openssl_key = key.to_openssl_key
assert_instance_of OpenSSL::PKey::EC, openssl_key
assert_equal "prime256v1", openssl_key.group.curve_name
end
test "converts RSA/RS256 key to OpenSSL RSA key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: @rsa_parameters
)
openssl_key = key.to_openssl_key
assert_instance_of OpenSSL::PKey::RSA, openssl_key
assert_equal 65537, openssl_key.e.to_i
end
test "raises error for unsupported key type/algorithm combination" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 99,
algorithm: -7,
parameters: {}
)
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/99\/-7/, error.message)
end
test "raises error for unsupported EC curve" do
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
private
def encode_cbor(hash)
# CBOR map encoding
bytes = [ 0xa0 + hash.size ] # map with n items
hash.each do |key, value|
bytes.concat(encode_cbor_integer(key))
bytes.concat(encode_cbor_value(value))
end
bytes.pack("C*")
end
def encode_cbor_integer(int)
if int >= 0 && int <= 23
[ int ]
elsif int >= 0 && int <= 255
[ 0x18, int ]
elsif int >= -24 && int < 0
[ 0x20 - int - 1 ]
elsif int >= -256 && int < -24
[ 0x38, -int - 1 ]
else
# 16-bit negative integer
val = -int - 1
[ 0x39, (val >> 8) & 0xff, val & 0xff ]
end
end
def encode_cbor_value(value)
case value
when Integer
encode_cbor_integer(value)
when String
length = value.bytesize
if length <= 23
[ 0x40 + length, *value.bytes ]
elsif length <= 255
[ 0x58, length, *value.bytes ]
else
[ 0x59, (length >> 8) & 0xff, length & 0xff, *value.bytes ]
end
end
end
end
@@ -0,0 +1,101 @@
require "test_helper"
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase
setup do
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
@options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
relying_party: @relying_party
)
end
test "initializes with required parameters" do
assert_equal "user-123", @options.id
assert_equal "user@example.com", @options.name
assert_equal "Test User", @options.display_name
assert_equal @relying_party, @options.relying_party
end
test "generates base64url encoded challenge" do
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates challenge of correct length" do
decoded = Base64.urlsafe_decode64(@options.challenge)
assert_equal 32, decoded.bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp])
user = @options.as_json[:user]
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id]
assert_equal "user@example.com", user[:name]
assert_equal "Test User", user[:displayName]
assert_equal [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -257 }
], @options.as_json[:pubKeyCredParams]
assert_equal "preferred", @options.as_json[:authenticatorSelection][:residentKey]
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
end
test "as_json includes residentKey in authenticatorSelection" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
resident_key: :required,
relying_party: @relying_party
)
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
end
test "as_json excludes excludeCredentials when empty" do
assert_nil @options.as_json[:excludeCredentials]
end
test "as_json includes excludeCredentials" do
credential = Struct.new(:id, :transports)
credentials = [
credential.new("cred-1", [ "usb", "nfc" ]),
credential.new("cred-2", [ "internal" ])
]
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
exclude_credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:excludeCredentials]
end
test "as_json excludeCredentials omits transports when empty" do
credential = Struct.new(:id, :transports).new("cred-1", [])
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
exclude_credentials: [ credential ],
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:excludeCredentials]
end
end
@@ -0,0 +1,72 @@
require "test_helper"
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
setup do
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
credential = Struct.new(:id, :transports)
@credentials = [
credential.new("credential-1", []),
credential.new("credential-2", [])
]
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: @credentials,
relying_party: @relying_party
)
end
test "initializes with required parameters" do
assert_equal @credentials, @options.credentials
assert_equal @relying_party, @options.relying_party
end
test "generates base64url encoded challenge" do
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates challenge of correct length" do
decoded = Base64.urlsafe_decode64(@options.challenge)
assert_equal 32, decoded.bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
assert_equal "example.com", @options.as_json[:rpId]
assert_equal [
{ type: "public-key", id: "credential-1" },
{ type: "public-key", id: "credential-2" }
], @options.as_json[:allowCredentials]
assert_equal "preferred", @options.as_json[:userVerification]
end
test "as_json includes transports when present" do
credential = Struct.new(:id, :transports)
credentials = [
credential.new("cred-1", [ "usb", "nfc" ]),
credential.new("cred-2", [ "internal" ])
]
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:allowCredentials]
end
test "as_json omits transports when empty" do
credential = Struct.new(:id, :transports)
credentials = [ credential.new("cred-1", []) ]
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:allowCredentials]
end
end
@@ -0,0 +1,30 @@
require "test_helper"
class ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase
test "initializes with explicit id and name" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
assert_equal "example.com", relying_party.id
assert_equal "Example App", relying_party.name
end
test "initializes with default id from Current.host" do
ActionPack::WebAuthn::Current.set(host: "default.example.com") do
relying_party = ActionPack::WebAuthn::RelyingParty.new(name: "Example App")
assert_equal "default.example.com", relying_party.id
end
end
test "initializes with default name from Rails application" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com")
assert_equal Rails.application.name, relying_party.name
end
test "as_json returns id and name" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
assert_equal({ id: "example.com", name: "Example App" }, relying_party.as_json)
end
end