Merge pull request #2623 from basecamp/passkeys

Passkeys
This commit is contained in:
Stanko Krtalić
2026-03-18 14:54:54 +01:00
committed by GitHub
92 changed files with 5010 additions and 16 deletions
@@ -0,0 +1,33 @@
require "test_helper"
class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
test "returns a fresh challenge" do
untenanted do
post my_passkey_challenge_url
assert_response :success
assert_not_nil response.parsed_body["challenge"]
end
end
test "stores challenge in cookie" do
untenanted do
post my_passkey_challenge_url
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
end
end
test "returns a different challenge each time" do
untenanted do
post my_passkey_challenge_url
first_challenge = response.parsed_body["challenge"]
post my_passkey_challenge_url
second_challenge = response.parsed_body["challenge"]
assert_not_equal first_challenge, second_challenge
end
end
end
@@ -0,0 +1,26 @@
require "test_helper"
class My::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
sign_in_as :kevin
end
test "index" do
get my_passkeys_path
assert_response :success
end
test "register a passkey" do
challenge = request_webauthn_challenge
assert_difference -> { identities(:kevin).passkeys.count }, 1 do
post my_passkeys_path, params: build_attestation_params(challenge: challenge)
end
passkey = identities(:kevin).passkeys.order(created_at: :desc).first
assert_redirected_to edit_my_passkey_path(passkey, created: true)
assert_equal [ "internal" ], passkey.transports
end
end
@@ -0,0 +1,101 @@
require "test_helper"
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
@identity = identities(:kevin)
@credential = @identity.passkeys.create!(
name: "Test Passkey",
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: webauthn_private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
end
test "successful authentication" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to landing_path
end
end
test "updates sign count" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)
assert_equal 1, @credential.reload.sign_count
end
end
test "rejects invalid signature" do
untenanted do
challenge = request_webauthn_challenge
params = build_assertion_params(challenge: challenge, credential: @credential)
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
post session_passkey_url, params: params
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
assert_equal "That passkey didn't work. Try again.", flash[:alert]
end
end
test "rejects unknown credential" do
untenanted do
request_webauthn_challenge
post session_passkey_url, params: {
passkey: {
id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
}
}
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
end
test "successful authentication via JSON" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :success
assert @response.parsed_body["session_token"].present?
end
end
test "failed authentication via JSON" do
untenanted do
request_webauthn_challenge
post session_passkey_url(format: :json), params: {
passkey: {
id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
}
}
assert_response :unauthorized
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
end
end
end
+92
View File
@@ -0,0 +1,92 @@
require "test_helper"
class ActionPack::PasskeyTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
@passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
end
test "authenticate with valid assertion" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
result = @passkey.authenticate(assertion, challenge: challenge)
assert_equal @passkey, result
end
test "authenticate returns nil with invalid signature" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
assert_nil @passkey.authenticate(assertion, challenge: challenge)
end
test "authenticate updates sign count and backed_up" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)
@passkey.authenticate(assertion, challenge: challenge)
assert_equal 5, @passkey.reload.sign_count
assert @passkey.backed_up?
end
test "to_public_key_credential" do
credential = @passkey.to_public_key_credential
assert_equal @passkey.credential_id, credential.id
assert_equal @passkey.sign_count, credential.sign_count
assert_equal @passkey.transports, credential.transports
end
private
def build_assertion(challenge:, sign_count: 1, backed_up: false)
origin = ActionPack::WebAuthn::Current.origin
client_data_json = {
challenge: challenge,
origin: origin,
type: "webauthn.get"
}.to_json
authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up)
signature = sign(authenticator_data, client_data_json)
{
id: @passkey.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
end
def build_authenticator_data(sign_count:, backed_up: false)
rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host)
flags = 0x01 | 0x04 # user present + user verified
flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state
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,179 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@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,
challenge: @challenge,
origin: @origin
)
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!
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,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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: Base64.urlsafe_encode64("invalid-signature", padding: false),
credential: @credential,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
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,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!
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,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!
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,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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,185 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
# Auth data in all objects contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 0
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_NONE_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a068617574684461" \
"746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" \
"4500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c" \
"0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f" \
"39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929" \
"e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x41 (UP+AT)>}
ATTESTATION_NONE_NOT_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a06861757468" \
"4461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce" \
"19474100000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a" \
"0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c" \
"686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bd" \
"d929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "packed", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_PACKED_VERIFIED = [ "a363666d74667061636b65646761747453746d74a068617574" \
"684461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586" \
"ce19474500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090" \
"a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104" \
"c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519b" \
"dd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@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: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
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!
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: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!
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: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!
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: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Client data type is not webauthn.create", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
end
test "validate! raises when attestation format is not registered" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Unsupported attestation format: packed", error.message
end
test "validate! calls registered verifier for custom format" do
verified = false
custom_verifier = Object.new
custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true }
ActionPack::WebAuthn.register_attestation_verifier("packed", custom_verifier)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
response.validate!
assert verified
ensure
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
end
end
@@ -0,0 +1,47 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
# Attestation object: {"fmt": "none", "attStmt": {}, "authData": <164 bytes>}
# Auth data contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# flags: 0x41 (user present + attested credential)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
ATTESTATION_CBOR = [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4a3" \
"79a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947410000002a" \
"000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c0d0e0f1011" \
"12131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f39d4b623cc" \
"9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9" \
"f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
SIGN_COUNT = 42
test "decodes attestation object" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
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_CBOR)
assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id
end
test "delegates sign_count to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal SIGN_COUNT, attestation.sign_count
end
test "delegates public_key to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
end
end
@@ -0,0 +1,33 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase
setup do
@verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new
end
test "verify! passes with nil attestation statement" do
attestation = stub(attestation_statement: nil)
assert_nothing_raised do
@verifier.verify!(attestation, client_data_json: "")
end
end
test "verify! passes with empty attestation statement" do
attestation = stub(attestation_statement: {})
assert_nothing_raised do
@verifier.verify!(attestation, client_data_json: "")
end
end
test "verify! raises with non-empty attestation statement" do
attestation = stub(attestation_statement: { "sig" => "abc" })
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@verifier.verify!(attestation, client_data_json: "")
end
assert_equal "Attestation statement must be empty for 'none' format", error.message
end
end
@@ -0,0 +1,145 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
# Common values:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
SIGN_COUNT = 42
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
COSE_KEY_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# rp_id_hash(32) + flags 0x01 (UP) + sign_count 42
AUTH_DATA_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce1947010000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 (UP+AT) + sign_count 42 + aaguid(16) +
# credential_id_len 32 (2 bytes) + credential_id(32) + cose_key CBOR
AUTH_DATA_WITH_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13" \
"d2125586ce1947410000002a000102030405060708090a0b0c0d0e0f0020000102030405" \
"060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa5010203262001215820" \
"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069" \
"cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# Error test data: flags 0x41 (AT set) but no attested credential data after header
# rp_id_hash(32) + flags 0x41 + sign_count 42
AUTH_DATA_AT_FLAG_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30" \
"ab13d2125586ce1947410000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16), missing credential_id_len
AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f" \
"2d30ab13d2125586ce19474100000000000102030405060708090a0b0c0d0e0f" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16) + credential_id_len 9999
AUTH_DATA_HUGE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce19474100000000000102030405060708090a0b0c0d0e0f270f" ].pack("H*")
test "decodes authenticator data without attested credential" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x01, 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
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x41, data.flags
assert_equal SIGN_COUNT, data.sign_count
assert_equal CREDENTIAL_ID_BASE64, data.credential_id
assert_equal COSE_KEY_CBOR, 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
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
assert_instance_of OpenSSL::PKey::EC, data.public_key
end
test "public_key returns nil when public_key_bytes not present" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
assert_nil data.public_key
end
test "raises when attested credential flag set but data truncated before AAGUID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_AT_FLAG_NO_CREDENTIAL)
end
end
test "raises when attested credential flag set but data truncated before credential ID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN)
end
end
test "raises when credential ID length exceeds remaining bytes" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_HUGE_CRED_LEN)
end
end
private
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
end
@@ -0,0 +1,157 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@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,
challenge: @challenge,
origin: @origin
)
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?
end
test "valid? returns false when challenge does not match" do
@response.challenge = "wrong-challenge"
assert_not @response.valid?
end
test "valid? returns false when origin does not match" do
@response.origin = "https://evil.com"
assert_not @response.valid?
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
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,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
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,301 @@
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::InvalidCborError) 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 empty indefinite length map" do
assert_equal({}, decode("bfff"))
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::InvalidCborError) 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::InvalidCborError) 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::InvalidCborError) 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::InvalidCborError) 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::InvalidCborError) 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::InvalidCborError) 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::InvalidCborError) 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,255 @@
require "test_helper"
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
# EC2/ES256 P-256 public key (32-byte x and y coordinates)
EC2_X = [ "2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71" ].pack("H*")
EC2_Y = [ "69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# CBOR: {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
EC2_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# Ed25519 public key (32 bytes)
ED25519_X = [ "a95ee02872a2c5224b394832767bea746620e50776e845872228716065f16005" ].pack("H*")
# CBOR: {1: 1, 3: -8, -1: 6, -2: <x 32 bytes>}
OKP_CBOR = [ "a4010103272006215820a95ee02872a2c5224b394832767bea746620e50776e8" \
"45872228716065f16005" ].pack("H*")
# RSA 2048-bit public key (256-byte modulus, 3-byte exponent 65537)
RSA_N = [ "d388adb3aa7812402281c57ce870821b17558f0a247a771834892d85399ecd4f" \
"830dd35f65e7afe5030d9ee10f4873567039976486202cce8ac499114194d32fe615026e" \
"7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa74249b06e4ffa6b38ff5ef54e" \
"1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da016618308f3fa7f74efc178ec46" \
"e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd14536aa9c726519806a96ea00" \
"517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc76368b552b628adb788b0bc" \
"9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666bdc92bc6b0f2a04fdb40cf1" \
"d253ba1582673ec293113" ].pack("H*")
RSA_E = [ "010001" ].pack("H*")
# CBOR: {1: 3, 3: -257, -1: <n 256 bytes>, -2: <e 3 bytes>}
RSA_CBOR = [ "a401030339010020590100d388adb3aa7812402281c57ce870821b17558f0a24" \
"7a771834892d85399ecd4f830dd35f65e7afe5030d9ee10f4873567039976486202cce8a" \
"c499114194d32fe615026e7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa742" \
"49b06e4ffa6b38ff5ef54e1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da01661" \
"8308f3fa7f74efc178ec46e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd145" \
"36aa9c726519806a96ea00517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc7" \
"6368b552b628adb788b0bc9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666b" \
"dc92bc6b0f2a04fdb40cf1d253ba1582673ec2931132143010001" ].pack("H*")
setup do
@ec2_parameters = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => EC2_X,
-3 => EC2_Y
}
@rsa_parameters = {
1 => 3, # kty: RSA
3 => -257, # alg: RS256
-1 => RSA_N,
-2 => RSA_E
}
@okp_parameters = {
1 => 1, # kty: OKP
3 => -8, # alg: EdDSA
-1 => 6, # crv: Ed25519
-2 => ED25519_X
}
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
key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
end
test "decodes OKP/EdDSA key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(OKP_CBOR)
assert_equal 1, key.key_type
assert_equal(-8, key.algorithm)
end
test "decodes RSA/RS256 key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(RSA_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 OKP/EdDSA key to OpenSSL Ed25519 key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: @okp_parameters
)
openssl_key = key.to_openssl_key
assert_equal "ED25519", openssl_key.oid
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::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/99\/-7/, error.message)
end
test "raises error for unsupported OKP curve" do
parameters = @okp_parameters.merge(-1 => 5) # Ed448 instead of Ed25519
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
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::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
test "raises error for EC2 key with missing coordinates" do
parameters = @ec2_parameters.except(-3) # missing y coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing ec2 key coordinates/i, error.message)
end
test "raises error for EC2 key with wrong coordinate length" do
parameters = @ec2_parameters.merge(-2 => "\x00" * 16) # 16 bytes instead of 32
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/invalid ec2 coordinate length/i, error.message)
end
test "raises error for OKP key with missing coordinate" do
parameters = @okp_parameters.except(-2) # missing x coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing okp key coordinate/i, error.message)
end
test "raises error for RSA key with missing parameters" do
parameters = @rsa_parameters.except(-1) # missing n
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing rsa key parameters/i, error.message)
end
test "raises error for RSA key smaller than 2048 bits" do
small_n = "\x01" + ("\x00" * 127) # 1024-bit modulus
parameters = @rsa_parameters.merge(-1 => small_n)
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/at least 2048 bits/i, error.message)
end
end
@@ -0,0 +1,142 @@
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 signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).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: -8 },
{ type: "public-key", alg: -257 }
], @options.as_json[:pubKeyCredParams]
assert_equal "required", @options.as_json[:authenticatorSelection][:residentKey]
assert_equal true, @options.as_json[:authenticatorSelection][:requireResidentKey]
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]
assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]
end
test "as_json excludes excludeCredentials when empty" do
assert_nil @options.as_json[:excludeCredentials]
end
test "as_json includes excludeCredentials" do
credentials = [
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
build_credential(id: "cred-2", transports: [ "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 excludes attestation when none" do
assert_nil @options.as_json[:attestation]
end
test "as_json includes attestation when not none" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
attestation: :direct,
relying_party: @relying_party
)
assert_equal "direct", options.as_json[:attestation]
end
test "raises with invalid attestation preference" do
assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
attestation: :invalid,
relying_party: @relying_party
)
end
end
test "as_json excludeCredentials omits transports when empty" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
exclude_credentials: [ build_credential(id: "cred-1") ],
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:excludeCredentials]
end
private
def build_credential(id:, transports: [])
ActionPack::WebAuthn::PublicKeyCredential.new(
id: id,
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
sign_count: 0,
transports: transports
)
end
end
@@ -0,0 +1,82 @@
require "test_helper"
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
setup do
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
@credentials = [
build_credential(id: "credential-1"),
build_credential(id: "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 signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).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
credentials = [
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
build_credential(id: "cred-2", transports: [ "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
credentials = [ build_credential(id: "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
private
def build_credential(id:, transports: [])
ActionPack::WebAuthn::PublicKeyCredential.new(
id: id,
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
sign_count: 0,
transports: transports
)
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
@@ -0,0 +1,32 @@
require "test_helper"
class ActionPackPasskeyInferNameFromAaguidTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
end
test "authenticator lookup by known aaguid" do
authenticator = Passkey::Authenticator.find_by_aaguid("dd4ec289-e01d-41c9-bb89-70fa845d4bf2")
assert_equal "Apple Passwords", authenticator.name
end
test "authenticator lookup returns nil for unknown aaguid" do
assert_nil Passkey::Authenticator.find_by_aaguid("00000000-0000-0000-0000-000000000000")
end
test "authenticator lookup by aaguid on passkey" do
passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
aaguid: "dd4ec289-e01d-41c9-bb89-70fa845d4bf2"
)
assert_equal "Apple Passwords", passkey.authenticator.name
end
end
@@ -1,6 +1,6 @@
class MagicLinkMailerPreview < ActionMailer::Preview
def magic_link
identity = Identity.new email_address: "test@example.com"
identity = Identity.all.sample
magic_link = MagicLink.new(identity: identity)
magic_link.valid?
+94
View File
@@ -0,0 +1,94 @@
module WebauthnTestHelper
# Fixed EC P-256 key pair for WebAuthn tests.
WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(
[ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \
"0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \
"4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \
"542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
)
# Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.
# {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \
"e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \
"63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
# CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}.
# Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4)
# + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77).
ATTESTATION_OBJECT_CBOR_PREFIX =
[ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
private
def request_webauthn_challenge
untenanted { post my_passkey_challenge_url }
response.parsed_body["challenge"]
end
def webauthn_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge
end
def webauthn_private_key
WEBAUTHN_PRIVATE_KEY
end
def build_attestation_params(challenge:)
credential_id = SecureRandom.random_bytes(32)
auth_data = build_attestation_auth_data(credential_id: credential_id)
{
passkey: {
client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"),
attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),
transports: [ "internal" ]
}
}
end
def build_assertion_params(challenge:, credential:, sign_count: 1)
client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get")
authenticator_data = build_assertion_auth_data(sign_count: sign_count)
signature = webauthn_sign(authenticator_data, client_data_json)
{
passkey: {
id: credential.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
}
end
def webauthn_client_data_json(challenge:, type:)
{ challenge: challenge, origin: "http://www.example.com", type: type }.to_json
end
# Attestation auth data includes the attested credential (public key + credential ID).
def build_attestation_auth_data(credential_id:)
[
Digest::SHA256.digest("www.example.com"), # rp_id_hash
[ 0x45 ].pack("C"), # flags: UP + UV + AT
[ 0 ].pack("N"), # sign_count
"\x00" * 16, # aaguid
[ credential_id.bytesize ].pack("n"), # credential_id_length
credential_id,
COSE_PUBLIC_KEY
].join.b
end
# Assertion auth data is simpler — no attested credential.
def build_assertion_auth_data(sign_count:)
[
Digest::SHA256.digest("www.example.com"),
[ 0x05 ].pack("C"), # flags: UP + UV
[ sign_count ].pack("N")
].join.b
end
def webauthn_sign(authenticator_data, client_data_json)
signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)
WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data)
end
end