Rely solely on the challange signature for verification

The cookie approach seems like the more secure aproach because it ties the authentication or registration attempt to the user's browser session, but it doesn't work reliably on Chrome for Windows. Also, a simila problem pops up on Chrome for Linux if the session is used instead of a separate cookie. It looks like the browser doesn't propagate the state change through fast enough which results in some requests contaiining the new/updated cookie, and others don't, which results in sporadic failures. Since we use a signed and expiring challange we still get protection from replay attacks and tampering which enables us to omit the cookie entierly and rely on the challange's signature to prove expiration and authenticity. The only thing we lose is the ability to tie and attemp to a single browser session.

See: https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-challengeURL which proposes to add the same challange fetching logic as part of the standard

See: https://github.com/w3c/webauthn/issues/1856 which discusses issues that arise from having expiring challanges (which the spec recommends)

See: https://github.com/OneUptime/oneuptime/security/advisories/GHSA-gjjc-pcwp-c74m which is an explot that can happen if the server isn't able to verify the authenticity of challanges that are sent outside of a cookie
This commit is contained in:
Stanko K.R.
2026-03-25 11:15:18 +01:00
parent 4fb1cddb4c
commit 1983014be6
13 changed files with 89 additions and 95 deletions
+3 -3
View File
@@ -20,7 +20,7 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
result = @passkey.authenticate(assertion, challenge: challenge)
result = @passkey.authenticate(assertion)
assert_equal @passkey, result
end
@@ -30,14 +30,14 @@ class ActionPack::PasskeyTest < ActiveSupport::TestCase
assertion = build_assertion(challenge: challenge)
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
assert_nil @passkey.authenticate(assertion, challenge: challenge)
assert_nil @passkey.authenticate(assertion)
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)
@passkey.authenticate(assertion)
assert_equal 5, @passkey.reload.sign_count
assert @passkey.backed_up?
@@ -27,7 +27,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: @signature,
credential: @credential,
challenge: @challenge,
origin: @origin
)
end
@@ -56,7 +55,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: sign(@authenticator_data, client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin
)
@@ -73,7 +71,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: @authenticator_data,
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
credential: @credential,
challenge: @challenge,
origin: @origin
)
@@ -84,14 +81,28 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.get"
}.to_json
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,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -111,7 +122,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
@@ -128,7 +138,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -145,7 +154,6 @@ class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -48,7 +48,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
end
@@ -67,7 +66,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
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
)
@@ -81,7 +79,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
@@ -95,7 +92,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
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
)
@@ -117,7 +113,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -128,14 +123,24 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
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"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-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,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -152,7 +157,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -173,7 +177,6 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
@@ -18,7 +18,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
@response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
end
@@ -41,9 +40,20 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
assert @response.valid?
end
test "valid? returns false when challenge does not match" do
@response.challenge = "wrong-challenge"
assert_not @response.valid?
test "valid? returns false when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.create"
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
origin: @origin
)
assert_not response.valid?
end
test "valid? returns false when origin does not match" do
@@ -51,14 +61,24 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
assert_not @response.valid?
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
test "validate! raises when challenge in client data is invalid" do
client_data_json = {
challenge: "not-a-valid-signed-challenge",
origin: @origin,
type: "webauthn.create"
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
response.validate!
end
assert_equal "Challenge does not match", error.message
assert_match /Challenge (is invalid|has expired)/, error.message
end
test "validate! raises when origin does not match" do
@@ -82,7 +102,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
@@ -108,7 +127,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: wrong_rp_data,
challenge: @challenge,
origin: @origin
)
@@ -130,7 +148,6 @@ class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCas
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)