Polish up Passkey interface
- Fix indentation - Split awkward initializer into separate methods - Rename credentials to passkeys - Simplify authenticator registry loading - Flatten nested errors under ActionPack::WebAuthn - Set passkey current params only requests that use it - Inline anemic methods - Replace custom validation with ActiveModel::Validation - Rename identity to holder in Passkey - Rename credentials to passkeys in JS - Extract framework library out of controllers - Pass params hashes down to ActionPack - Attempt to simplify public interface - Push data decoding down to the classes representing the data - Introduce has_passkeys - Add CBOR bigint support - Rename ActionPack::WebAuthn::Passkey to ActionPack::Passkey - Add create_passkey_button helper - Rename public-key to creation-options - Add sign_in_with_passkey_button helper - Dispatch events for the whole Passkey lifecycle - Add ED25519 support - Prevent crash on missing meta tag - Validate resident key options - Don't clobber existing ActionPack config options - Validate cryptographic params - Move CurrentWebAuthnRequest into ActionPack::Passkey - Use ActiveModel::Attributes for Options objects - Implement expiring challanges - Add lifecycle events - Extract param helpers into Request - Add passkey_creation_options and passkey_request_options helpers - Add create_passkey_challenge to make it easier to override the create method if needed - Prefix all view helpers with passkey_ - Auto-include ActionPack::Passkey::Holder - Make the passkey challange url configurable - Add a reminder about Passkeys to the magic link email
This commit is contained in:
@@ -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
|
||||
@@ -1,14 +1,15 @@
|
||||
require "test_helper"
|
||||
|
||||
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
@identity = identities(:kevin)
|
||||
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
|
||||
|
||||
@credential = @identity.credentials.create!(
|
||||
@credential = @identity.passkeys.create!(
|
||||
name: "Test Passkey",
|
||||
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
|
||||
public_key: @private_key.public_to_der,
|
||||
public_key: webauthn_private_key.public_to_der,
|
||||
sign_count: 0,
|
||||
transports: [ "internal" ]
|
||||
)
|
||||
@@ -16,10 +17,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "successful authentication" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
challenge = session[:webauthn_challenge]
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: assertion_params(challenge: challenge)
|
||||
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)
|
||||
|
||||
assert_response :redirect
|
||||
assert cookies[:session_token].present?
|
||||
@@ -29,10 +29,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "updates sign count" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
challenge = session[:webauthn_challenge]
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: assertion_params(challenge: challenge, sign_count: 1)
|
||||
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)
|
||||
|
||||
assert_equal 1, @credential.reload.sign_count
|
||||
end
|
||||
@@ -40,10 +39,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "rejects invalid signature" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
challenge = session[:webauthn_challenge]
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
params = assertion_params(challenge: challenge)
|
||||
params = build_assertion_params(challenge: challenge, credential: @credential)
|
||||
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
|
||||
|
||||
post session_passkey_url, params: params
|
||||
@@ -56,7 +54,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "rejects unknown credential" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
request_webauthn_challenge
|
||||
|
||||
post session_passkey_url, params: {
|
||||
passkey: {
|
||||
@@ -74,10 +72,9 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "successful authentication via JSON" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
challenge = session[:webauthn_challenge]
|
||||
challenge = request_webauthn_challenge
|
||||
|
||||
post session_passkey_url(format: :json), params: assertion_params(challenge: 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?
|
||||
@@ -86,7 +83,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "failed authentication via JSON" do
|
||||
untenanted do
|
||||
get new_session_url
|
||||
request_webauthn_challenge
|
||||
|
||||
post session_passkey_url(format: :json), params: {
|
||||
passkey: {
|
||||
@@ -101,44 +98,4 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def assertion_params(challenge:, sign_count: 1)
|
||||
origin = "http://www.example.com"
|
||||
|
||||
client_data_json = {
|
||||
challenge: challenge,
|
||||
origin: origin,
|
||||
type: "webauthn.get"
|
||||
}.to_json
|
||||
|
||||
authenticator_data = build_authenticator_data(sign_count: sign_count)
|
||||
signature = 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 build_authenticator_data(sign_count:)
|
||||
rp_id_hash = Digest::SHA256.digest("www.example.com")
|
||||
flags = 0x01 | 0x04 # user present + user verified
|
||||
|
||||
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,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
|
||||
@@ -1,10 +1,12 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = "test-challenge-123"
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
@@ -24,7 +26,9 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: @signature,
|
||||
credential: @credential
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
@@ -36,7 +40,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, type, and signature" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!(challenge: @challenge, origin: @origin)
|
||||
@response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,11 +55,13 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: sign(@authenticator_data, client_data_json),
|
||||
credential: @credential
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(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
|
||||
@@ -65,28 +71,34 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data,
|
||||
signature: "invalid-signature",
|
||||
credential: @credential
|
||||
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(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
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
||||
@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
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
@@ -98,11 +110,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :preferred
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,11 +127,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
response.validate!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -126,11 +144,14 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: authenticator_data,
|
||||
signature: sign(authenticator_data, @client_data_json),
|
||||
credential: @credential
|
||||
credential: @credential,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(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
|
||||
@@ -146,7 +167,7 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
bytes.pack("C*")
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
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 = "test-challenge-123"
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
@@ -14,7 +47,9 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
||||
|
||||
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true)
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
@@ -24,40 +59,49 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
||||
|
||||
test "validate! succeeds with valid challenge, origin, and type" do
|
||||
assert_nothing_raised do
|
||||
@response.validate!(challenge: @challenge, origin: @origin)
|
||||
@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: build_attestation_object(user_verified: false)
|
||||
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :preferred
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :preferred)
|
||||
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: build_attestation_object(user_verified: true)
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
assert_nothing_raised do
|
||||
response.validate!(challenge: @challenge, origin: @origin, user_verification: :required)
|
||||
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: build_attestation_object(user_verified: false)
|
||||
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin,
|
||||
user_verification: :required
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(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
|
||||
@@ -72,27 +116,33 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true)
|
||||
attestation_object: ATTESTATION_NONE_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(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
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: "wrong-challenge", origin: @origin)
|
||||
@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
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
@@ -101,11 +151,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
||||
test "validate! raises when attestation format is not registered" do
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true, format: "packed")
|
||||
attestation_object: ATTESTATION_PACKED_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Unsupported attestation format: packed", error.message
|
||||
@@ -120,94 +172,14 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
|
||||
|
||||
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
attestation_object: build_attestation_object(user_verified: true, format: "packed")
|
||||
attestation_object: ATTESTATION_PACKED_VERIFIED,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
response.validate!
|
||||
assert verified
|
||||
ensure
|
||||
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
|
||||
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
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
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)
|
||||
# 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*")
|
||||
|
||||
# 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
|
||||
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
SIGN_COUNT = 42
|
||||
|
||||
test "decodes attestation object" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal "none", attestation.format
|
||||
assert_equal({}, attestation.attestation_statement)
|
||||
@@ -28,108 +28,20 @@ class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::Test
|
||||
end
|
||||
|
||||
test "delegates credential_id to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal Base64.urlsafe_encode64(@credential_id, padding: false), attestation.credential_id
|
||||
assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id
|
||||
end
|
||||
|
||||
test "delegates sign_count to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
assert_equal @sign_count, attestation.sign_count
|
||||
assert_equal SIGN_COUNT, attestation.sign_count
|
||||
end
|
||||
|
||||
test "delegates public_key to authenticator_data" do
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(@attestation_object)
|
||||
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
|
||||
|
||||
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
|
||||
|
||||
@@ -24,7 +24,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < Acti
|
||||
test "verify! raises with non-empty attestation statement" do
|
||||
attestation = stub(attestation_statement: { "sig" => "abc" })
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@verifier.verify!(attestation, client_data_json: "")
|
||||
end
|
||||
|
||||
|
||||
@@ -1,46 +1,64 @@
|
||||
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)
|
||||
# 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>}
|
||||
|
||||
# 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]
|
||||
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
|
||||
SIGN_COUNT = 42
|
||||
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
|
||||
|
||||
@cose_key = build_cose_key(x_coord, y_coord)
|
||||
end
|
||||
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
|
||||
flags = 0x01 # user present only
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||
|
||||
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 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
|
||||
flags = 0x41 # user present + attested credential data
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: true)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
|
||||
|
||||
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
|
||||
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
|
||||
@@ -84,91 +102,44 @@ class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
|
||||
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)
|
||||
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
|
||||
flags = 0x01
|
||||
bytes = build_authenticator_data(flags: flags, include_credential: false)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
|
||||
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
|
||||
|
||||
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*")
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
require "test_helper"
|
||||
|
||||
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
|
||||
include WebauthnTestHelper
|
||||
|
||||
setup do
|
||||
ActionPack::WebAuthn::Current.host = "example.com"
|
||||
|
||||
@challenge = "test-challenge-123"
|
||||
@challenge = webauthn_challenge
|
||||
@origin = "https://example.com"
|
||||
@client_data_json = {
|
||||
challenge: @challenge,
|
||||
@@ -15,7 +17,9 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
@authenticator_data = build_authenticator_data
|
||||
@response = TestableResponse.new(
|
||||
client_data_json: @client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
end
|
||||
|
||||
@@ -34,28 +38,34 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
end
|
||||
|
||||
test "valid? returns true when challenge and origin match" do
|
||||
assert @response.valid?(challenge: @challenge, origin: @origin)
|
||||
assert @response.valid?
|
||||
end
|
||||
|
||||
test "valid? returns false when challenge does not match" do
|
||||
assert_not @response.valid?(challenge: "wrong-challenge", origin: @origin)
|
||||
@response.challenge = "wrong-challenge"
|
||||
assert_not @response.valid?
|
||||
end
|
||||
|
||||
test "valid? returns false when origin does not match" do
|
||||
assert_not @response.valid?(challenge: @challenge, origin: "https://evil.com")
|
||||
@response.origin = "https://evil.com"
|
||||
assert_not @response.valid?
|
||||
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)
|
||||
@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
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
@response.validate!(challenge: @challenge, origin: "https://evil.com")
|
||||
@response.origin = "https://evil.com"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
@response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Origin does not match", error.message
|
||||
@@ -71,11 +81,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Cross-origin requests are not supported", error.message
|
||||
@@ -89,17 +101,19 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
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
|
||||
authenticator_data: wrong_rp_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Relying party ID does not match", error.message
|
||||
@@ -115,11 +129,13 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
|
||||
response = TestableResponse.new(
|
||||
client_data_json: client_data_json,
|
||||
authenticator_data: @authenticator_data
|
||||
authenticator_data: @authenticator_data,
|
||||
challenge: @challenge,
|
||||
origin: @origin
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
|
||||
response.validate!(challenge: @challenge, origin: @origin)
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
|
||||
response.validate!
|
||||
end
|
||||
|
||||
assert_equal "Token binding is not supported", error.message
|
||||
@@ -134,7 +150,7 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
|
||||
bytes = []
|
||||
bytes.concat(rp_id_hash.bytes)
|
||||
bytes << flags
|
||||
bytes.concat([sign_count].pack("N").bytes)
|
||||
bytes.concat([ sign_count ].pack("N").bytes)
|
||||
|
||||
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
|
||||
end
|
||||
|
||||
@@ -152,7 +152,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises error for reserved additional info values" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("1c")
|
||||
end
|
||||
end
|
||||
@@ -165,6 +165,10 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
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
|
||||
@@ -228,7 +232,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises error for unsupported simple value" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("e0")
|
||||
end
|
||||
end
|
||||
@@ -243,28 +247,28 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises error for empty input" do
|
||||
assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) 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::CborDecoder::DecodeError) do
|
||||
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::CborDecoder::DecodeError) do
|
||||
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::CborDecoder::DecodeError) do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode("8201")
|
||||
end
|
||||
end
|
||||
@@ -274,7 +278,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
# 0x81 = array of 1 item
|
||||
deeply_nested = "81" * 20 + "01"
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
decode(deeply_nested)
|
||||
end
|
||||
|
||||
@@ -282,7 +286,7 @@ class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises error for input exceeding max size" do
|
||||
error = assert_raises(ActionPack::WebAuthn::CborDecoder::DecodeError) do
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
|
||||
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,33 +1,64 @@
|
||||
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/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
|
||||
-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
|
||||
-1 => RSA_N,
|
||||
-2 => RSA_E
|
||||
}
|
||||
|
||||
@okp_parameters = {
|
||||
1 => 1, # kty: OKP
|
||||
3 => -8, # alg: EdDSA
|
||||
-1 => 6, # crv: Ed25519
|
||||
-2 => ED25519_X
|
||||
}
|
||||
end
|
||||
|
||||
@@ -44,16 +75,21 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "decodes EC2/ES256 key from CBOR" do
|
||||
cbor = encode_cbor(@ec2_parameters)
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
||||
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
|
||||
cbor = encode_cbor(@rsa_parameters)
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(cbor)
|
||||
key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)
|
||||
|
||||
assert_equal 3, key.key_type
|
||||
assert_equal(-257, key.algorithm)
|
||||
@@ -72,6 +108,18 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
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,
|
||||
@@ -92,13 +140,28 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
parameters: {}
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
|
||||
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(
|
||||
@@ -107,55 +170,86 @@ class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
|
||||
parameters: parameters
|
||||
)
|
||||
|
||||
error = assert_raises(ActionPack::WebAuthn::CoseKey::UnsupportedKeyTypeError) do
|
||||
error = assert_raises(ActionPack::WebAuthn::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
|
||||
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
|
||||
)
|
||||
|
||||
hash.each do |key, value|
|
||||
bytes.concat(encode_cbor_integer(key))
|
||||
bytes.concat(encode_cbor_value(value))
|
||||
end
|
||||
|
||||
bytes.pack("C*")
|
||||
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
|
||||
key.to_openssl_key
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
||||
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
|
||||
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
|
||||
@@ -39,6 +42,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
||||
|
||||
assert_equal [
|
||||
{ type: "public-key", alg: -7 },
|
||||
{ type: "public-key", alg: -8 },
|
||||
{ type: "public-key", alg: -257 }
|
||||
], @options.as_json[:pubKeyCredParams]
|
||||
|
||||
@@ -101,7 +105,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
|
||||
end
|
||||
|
||||
test "raises with invalid attestation preference" do
|
||||
assert_raises(ArgumentError) do
|
||||
assert_raises(ActionPack::WebAuthn::InvalidOptionsError) do
|
||||
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
|
||||
id: "user-123",
|
||||
name: "user@example.com",
|
||||
|
||||
@@ -22,9 +22,12 @@ class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupp
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user