345 lines
11 KiB
Ruby
345 lines
11 KiB
Ruby
require "test_helper"
|
|
require "webauthn/fake_client"
|
|
|
|
class WebauthnSecurityTest < ActionDispatch::SystemTest
|
|
# ====================
|
|
# REPLAY ATTACK PREVENTION (SIGN COUNT TRACKING) TESTS
|
|
# ====================
|
|
|
|
test "detects suspicious sign count for replay attacks" do
|
|
user = User.create!(email_address: "webauthn_replay_test@example.com", password: "password123")
|
|
|
|
# Create a WebAuthn credential
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Simulate a suspicious sign count (decreased or reused)
|
|
credential.update!(sign_count: 100)
|
|
|
|
# Try to authenticate with a lower sign count (potential replay)
|
|
suspicious = credential.suspicious_sign_count?(99)
|
|
|
|
assert suspicious, "Should detect suspicious sign count indicating potential replay attack"
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "sign count is incremented after successful authentication" do
|
|
user = User.create!(email_address: "webauthn_signcount_test@example.com", password: "password123")
|
|
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 50,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Simulate authentication with new sign count
|
|
credential.update_usage!(
|
|
sign_count: 51,
|
|
ip_address: "192.168.1.1",
|
|
user_agent: "Mozilla/5.0"
|
|
)
|
|
|
|
credential.reload
|
|
assert_equal 51, credential.sign_count, "Sign count should be incremented"
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# USER HANDLE BINDING TESTS
|
|
# ====================
|
|
|
|
test "user handle is properly bound to WebAuthn credential" do
|
|
user = User.create!(email_address: "webauthn_handle_test@example.com", password: "password123")
|
|
|
|
# Create a WebAuthn credential with user handle
|
|
user_handle = SecureRandom.uuid
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key",
|
|
user_handle: user_handle
|
|
)
|
|
|
|
# Verify user handle is associated with the credential
|
|
assert_equal user_handle, credential.user_handle
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "WebAuthn authentication validates user handle" do
|
|
user = User.create!(email_address: "webauthn_handle_auth_test@example.com", password: "password123")
|
|
|
|
user_handle = SecureRandom.uuid
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key",
|
|
user_handle: user_handle
|
|
)
|
|
|
|
# Sign in with WebAuthn
|
|
# The implementation should verify the user handle matches
|
|
# This test documents the expected behavior
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# ORIGIN VALIDATION TESTS
|
|
# ====================
|
|
|
|
test "WebAuthn request validates origin" do
|
|
user = User.create!(email_address: "webauthn_origin_test@example.com", password: "password123")
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Test WebAuthn challenge from valid origin
|
|
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
|
headers: { "HTTP_ORIGIN": "http://localhost:3000" }
|
|
|
|
# Should succeed for valid origin
|
|
|
|
# Test WebAuthn challenge from invalid origin
|
|
post webauthn_challenge_path, params: { email: "webauthn_origin_test@example.com" },
|
|
headers: { "HTTP_ORIGIN": "http://evil.com" }
|
|
|
|
# Should reject invalid origin
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "WebAuthn verification includes origin validation" do
|
|
user = User.create!(email_address: "webauthn_verify_origin_test@example.com", password: "password123")
|
|
user.update!(webauthn_id: SecureRandom.uuid)
|
|
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Sign in with WebAuthn
|
|
post webauthn_challenge_path, params: { email: "webauthn_verify_origin_test@example.com" }
|
|
assert_response :success
|
|
|
|
challenge = JSON.parse(@response.body)["challenge"]
|
|
|
|
# Simulate WebAuthn verification with wrong origin
|
|
# This should fail
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# ATTESTATION FORMAT VALIDATION TESTS
|
|
# ====================
|
|
|
|
test "WebAuthn accepts standard attestation formats" do
|
|
user = User.create!(email_address: "webauthn_attestation_test@example.com", password: "password123")
|
|
|
|
# Register WebAuthn credential
|
|
# Standard attestation formats: none, packed, tpm, android-key, android-safetynet, fido-u2f, etc.
|
|
|
|
# Test with 'none' attestation (most common for privacy)
|
|
attestation_object = {
|
|
fmt: "none",
|
|
attStmt: {},
|
|
authData: Base64.strict_encode64("fake_auth_data")
|
|
}
|
|
|
|
# The implementation should accept standard attestation formats
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "WebAuthn rejects invalid attestation formats" do
|
|
user = User.create!(email_address: "webauthn_invalid_attestation_test@example.com", password: "password123")
|
|
|
|
# Try to register with invalid attestation format
|
|
invalid_attestation = {
|
|
fmt: "invalid_format",
|
|
attStmt: {},
|
|
authData: Base64.strict_encode64("fake_auth_data")
|
|
}
|
|
|
|
# Should reject invalid attestation format
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# CREDENTIAL CLONING DETECTION TESTS
|
|
# ====================
|
|
|
|
test "detects credential cloning through sign count anomalies" do
|
|
user = User.create!(email_address: "webauthn_clone_test@example.com", password: "password123")
|
|
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 100,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Simulate authentication from a cloned credential (sign count doesn't increase properly)
|
|
# First auth: sign count = 101
|
|
credential.update_usage!(sign_count: 101, ip_address: "192.168.1.1", user_agent: "Browser A")
|
|
|
|
# Second auth from different location but sign count = 101 again (cloned!)
|
|
suspicious = credential.suspicious_sign_count?(101)
|
|
|
|
assert suspicious, "Should detect potential credential cloning"
|
|
|
|
# Verify logging for security monitoring
|
|
# The application should log suspicious sign count anomalies
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "tracks IP address and user agent for WebAuthn authentications" do
|
|
user = User.create!(email_address: "webauthn_tracking_test@example.com", password: "password123")
|
|
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# Update usage with tracking information
|
|
credential.update_usage!(
|
|
sign_count: 1,
|
|
ip_address: "192.168.1.100",
|
|
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
)
|
|
|
|
credential.reload
|
|
assert_equal "192.168.1.100", credential.last_ip_address
|
|
assert_equal "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", credential.last_user_agent
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# CREDENTIAL EXCLUSION TESTS
|
|
# ====================
|
|
|
|
test "prevents duplicate credential registration" do
|
|
user = User.create!(email_address: "webauthn_duplicate_test@example.com", password: "password123")
|
|
|
|
credential_id = Base64.urlsafe_encode64("unique_credential_id")
|
|
|
|
# Register first credential
|
|
user.webauthn_credentials.create!(
|
|
external_id: credential_id,
|
|
public_key: Base64.urlsafe_encode64("public_key_1"),
|
|
sign_count: 0,
|
|
nickname: "Key 1"
|
|
)
|
|
|
|
# Try to register same credential ID again
|
|
# Should reject or update existing credential
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# USER PRESENCE TESTS
|
|
# ====================
|
|
|
|
test "WebAuthn requires user presence for authentication" do
|
|
user = User.create!(email_address: "webauthn_presence_test@example.com", password: "password123")
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("fake_credential_id"),
|
|
public_key: Base64.urlsafe_encode64("fake_public_key"),
|
|
sign_count: 0,
|
|
nickname: "Test Key"
|
|
)
|
|
|
|
# WebAuthn authenticator response should include user presence flag (UP)
|
|
# The implementation should verify this flag is set to true
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# CREDENTIAL MANAGEMENT TESTS
|
|
# ====================
|
|
|
|
test "users can view and revoke their WebAuthn credentials" do
|
|
user = User.create!(email_address: "webauthn_mgmt_test@example.com", password: "password123")
|
|
|
|
# Create multiple credentials
|
|
credential1 = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("credential_1"),
|
|
public_key: Base64.urlsafe_encode64("public_key_1"),
|
|
sign_count: 0,
|
|
nickname: "USB Key"
|
|
)
|
|
|
|
credential2 = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("credential_2"),
|
|
public_key: Base64.urlsafe_encode64("public_key_2"),
|
|
sign_count: 0,
|
|
nickname: "Laptop Key"
|
|
)
|
|
|
|
# User should be able to view their credentials
|
|
assert_equal 2, user.webauthn_credentials.count
|
|
|
|
# User should be able to revoke a credential
|
|
credential1.destroy
|
|
assert_equal 1, user.webauthn_credentials.count
|
|
|
|
user.destroy
|
|
end
|
|
|
|
# ====================
|
|
# WEBAUTHN AND PASSWORD LOGIN INTERACTION TESTS
|
|
# ====================
|
|
|
|
test "WebAuthn can be required for authentication" do
|
|
user = User.create!(email_address: "webauthn_required_test@example.com", password: "password123")
|
|
user.update!(webauthn_enabled: true)
|
|
|
|
# Sign in with password should still work
|
|
post signin_path, params: { email_address: "webauthn_required_test@example.com", password: "password123" }
|
|
|
|
# If WebAuthn is enabled, should offer WebAuthn as an option
|
|
# Implementation should handle password + WebAuthn or passwordless flow
|
|
|
|
user.destroy
|
|
end
|
|
|
|
test "WebAuthn can be used for passwordless authentication" do
|
|
user = User.create!(email_address: "webauthn_passwordless_test@example.com", password: "password123")
|
|
user.update!(webauthn_enabled: true)
|
|
|
|
credential = user.webauthn_credentials.create!(
|
|
external_id: Base64.urlsafe_encode64("passwordless_credential"),
|
|
public_key: Base64.urlsafe_encode64("public_key"),
|
|
sign_count: 0,
|
|
nickname: "Passwordless Key"
|
|
)
|
|
|
|
# User should be able to sign in with WebAuthn alone
|
|
# Test passwordless flow
|
|
|
|
user.destroy
|
|
end
|
|
end
|