From a474d2d5bfd693494e9eb04f5fe916a954f03af3 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Fri, 29 Sep 2023 14:45:55 -0700 Subject: [PATCH] Support hmac_secret rotation in webauthn feature This only checks that the submitted challenges match either the current or previous hmac. The only time you would need to support long hmac rotation period for webauthn is if you wanted to allow users to go to the webauthn setup/auth page and then actually submit the form on that page much later. --- CHANGELOG | 2 ++ lib/rodauth/features/webauthn.rb | 4 +-- spec/webauthn_spec.rb | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index adbd44f2..a9ee01e9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === master +* Support hmac_secret rotation in webauthn feature (jeremyevans) (#365) + * Support hmac_secret rotation in jwt_refresh feature (jeremyevans) (#365) * Support hmac_secret rotation in single_session feature (jeremyevans) (#365) diff --git a/lib/rodauth/features/webauthn.rb b/lib/rodauth/features/webauthn.rb index a235c361..20c09ff9 100644 --- a/lib/rodauth/features/webauthn.rb +++ b/lib/rodauth/features/webauthn.rb @@ -320,7 +320,7 @@ def valid_new_webauthn_credential?(webauthn_credential) (challenge = param_or_nil(webauthn_setup_challenge_param)) && (hmac = param_or_nil(webauthn_setup_challenge_hmac_param)) && - timing_safe_eql?(compute_hmac(challenge), hmac) && + (timing_safe_eql?(compute_hmac(challenge), hmac) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(challenge), hmac))) && webauthn_credential.verify(challenge) end @@ -376,7 +376,7 @@ def valid_webauthn_credential_auth?(webauthn_credential) (challenge = param_or_nil(webauthn_auth_challenge_param)) && (hmac = param_or_nil(webauthn_auth_challenge_hmac_param)) && - timing_safe_eql?(compute_hmac(challenge), hmac) && + (timing_safe_eql?(compute_hmac(challenge), hmac) || (hmac_secret_rotation? && timing_safe_eql?(compute_old_hmac(challenge), hmac))) && webauthn_credential.verify(challenge, public_key: pub_key, sign_count: sign_count) && ds.update( webauthn_keys_sign_count_column => Integer(webauthn_credential.sign_count), diff --git a/spec/webauthn_spec.rb b/spec/webauthn_spec.rb index 572ab951..f1c0a856 100644 --- a/spec/webauthn_spec.rb +++ b/spec/webauthn_spec.rb @@ -9,6 +9,7 @@ it "should handle webauthn authentication" do hmac_secret = '123' + hmac_old_secret = nil before_setup = nil before_remove = nil rodauth do @@ -16,6 +17,9 @@ hmac_secret do hmac_secret end + hmac_old_secret do + hmac_old_secret + end before_webauthn_setup do before_setup.call if before_setup end @@ -90,6 +94,29 @@ hmac_secret = '123' visit page.current_path + challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] + fill_in 'Password', :with=>'0123456789' + fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json + hmac_secret = '321' + hmac_old_secret = '333' + click_button 'Setup WebAuthn Authentication' + page.find('#error_flash').text.must_equal 'Error setting up WebAuthn authentication' + hmac_secret = '123' + visit page.current_path + setup_path = page.current_path + + hmac_secret = '321' + hmac_old_secret = '123' + challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] + fill_in 'Password', :with=>'0123456789' + fill_in 'webauthn_setup', :with=>webauthn_client.create(challenge: challenge).to_json + click_button 'Setup WebAuthn Authentication' + page.find('#notice_flash').text.must_equal 'WebAuthn authentication is now setup' + DB[:account_webauthn_keys].delete + + hmac_secret = '123' + hmac_old_secret = nil + visit setup_path challenge = JSON.parse(page.find('#webauthn-setup-form')['data-credential-options'])['challenge'] fill_in 'Password', :with=>'0123456789' webauthn_hash = webauthn_client.create(challenge: challenge) @@ -140,6 +167,28 @@ hmac_secret = '123' visit page.current_path + challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] + fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json + hmac_secret = '321' + hmac_old_secret = '333' + click_button 'Authenticate Using WebAuthn' + page.find('#error_flash').text.must_equal 'Error authenticating using WebAuthn' + hmac_secret = '123' + visit page.current_path + auth_path = page.current_path + + hmac_secret = '321' + hmac_old_secret = '123' + challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] + fill_in 'webauthn_auth', :with=>valid_webauthn_client.get(challenge: challenge).to_json + click_button 'Authenticate Using WebAuthn' + page.find('#notice_flash').text.must_equal 'You have been multifactor authenticated' + + hmac_secret = '123' + hmac_old_secret = nil + logout + login + visit auth_path challenge = JSON.parse(page.find('#webauthn-auth-form')['data-credential-options'])['challenge'] fill_in 'webauthn_auth', :with=>webauthn_client.get(challenge: challenge).to_json sign_count = DB[:account_webauthn_keys].get(:sign_count)