Skip to content

Commit

Permalink
feat: add capability of handling appid extension (cedarcode#319)
Browse files Browse the repository at this point in the history
Co-authored-by: Gonzalo <[email protected]>
  • Loading branch information
santiagorodriguez96 and grzuy authored Nov 11, 2022
1 parent f8d44d7 commit b90c6fd
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 30 deletions.
35 changes: 14 additions & 21 deletions docs/u2f_migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ migrated_credential.authenticator_data.sign_count

## Authenticate migrated U2F credentials

Following the documentation on the [authentication initiation](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#authentication),
Following the documentation on the [authentication initiation](https://github.com/cedarcode/webauthn-ruby/blob/master/README.md#initiation-phase-1),
you need to specify the [FIDO AppID extension](https://www.w3.org/TR/webauthn/#sctn-appid-extension) for U2F migratedq
credentials. The WebAuthn standard explains:

Expand All @@ -65,33 +65,26 @@ For the earlier given example `domain` this means:
- FIDO AppID: `https://login.example.com`
- Valid RP IDs: `login.example.com` (default) and `example.com`

You can request the use of the `appid` extension by setting the AppID in the configuration, like this:

```ruby
WebAuthn.configure do |config|
config.legacy_u2f_appid = "https://login.example.com"
end
```

By doing this, the `appid` extension will be automatically requested when generating the options for get:

```ruby
credential_request_options = WebAuthn.credential_request_options
credential_request_options[:extensions] = { appid: domain.to_s }
options = WebAuthn::Credential.options_for_get
```

On the frontend, in the resolved value from `navigator.credentials.get({ "publicKey": credentialRequestOptions })` add
a call to [getClientExtensionResults()](https://www.w3.org/TR/webauthn/#dom-publickeycredential-getclientextensionresults)
and send its result to your backend alongside the `id`/`rawId` and `response` values. If the authenticator used the AppID
extension, the returned value will contain `{ "appid": true }`. In the example below, we use `clientExtensionResults`.
extension, the returned value will contain `{ "appid": true }`.

During authentication verification phase, you must pass either the original AppID or the RP ID as the `rp_id` argument:
During authentication verification phase, if you followed the [verification phase documentation](https://github.com/cedarcode/webauthn-ruby#verification-phase-1) and have set the AppID in the config, the method `PublicKeyCredentialWithAssertion#verify` will be smart enough to determine if it should use the AppID or the RP ID to verify the WebAuthn credential, depending on the output of the `appid` client extension:

> If true, the AppID was used and thus, when verifying an assertion, the Relying Party MUST expect the `rpIdHash` to be
> the hash of the _AppID_, not the RP ID.
```ruby
assertion_response = WebAuthn::AuthenticatorAssertionResponse.new(
user_handle: params[:response][:userHandle],
authenticator_data: params[:response][:authenticatorData],
client_data_json: params[:response][:clientDataJSON],
signature: params[:response][:signature],
)

assertion_response.verify(
expected_challenge,
public_key: credential.public_key,
sign_count: credential.count,
rp_id: params[:clientExtensionResults][:appid] ? domain.to_s : domain.host,
)
```
4 changes: 3 additions & 1 deletion lib/webauthn/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ class Configuration
:attestation_root_certificates_finders,
:attestation_root_certificates_finders=,
:encoder,
:encoder=
:encoder=,
:legacy_u2f_appid,
:legacy_u2f_appid=

attr_reader :relying_party

Expand Down
12 changes: 8 additions & 4 deletions lib/webauthn/public_key_credential.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def self.from_client(credential, relying_party: WebAuthn.configuration.relying_p
raw_id: relying_party.encoder.decode(credential["rawId"]),
client_extension_outputs: credential["clientExtensionResults"],
response: response_class.from_client(credential["response"], relying_party: relying_party),
encoder: relying_party.encoder
relying_party: relying_party
)
end

Expand All @@ -23,14 +23,14 @@ def initialize(
raw_id:,
response:,
client_extension_outputs: {},
encoder: WebAuthn.configuration.encoder
relying_party: WebAuthn.configuration.relying_party
)
@type = type
@id = id
@raw_id = raw_id
@client_extension_outputs = client_extension_outputs
@response = response
@encoder = encoder
@relying_party = relying_party
end

def verify(*_args)
Expand All @@ -50,7 +50,7 @@ def authenticator_extension_outputs

private

attr_reader :encoder
attr_reader :relying_party

def valid_type?
type == TYPE_PUBLIC_KEY
Expand All @@ -63,5 +63,9 @@ def valid_id?
def authenticator_data
response&.authenticator_data
end

def encoder
relying_party.encoder
end
end
end
6 changes: 5 additions & 1 deletion lib/webauthn/public_key_credential/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class Options
def initialize(timeout: nil, extensions: nil, relying_party: WebAuthn.configuration.relying_party)
@relying_party = relying_party
@timeout = timeout || default_timeout
@extensions = extensions
@extensions = default_extensions.merge(extensions || {})
end

def challenge
Expand Down Expand Up @@ -61,6 +61,10 @@ def default_timeout
relying_party.credential_options_timeout
end

def default_extensions
{}
end

def as_public_key_descriptors(ids)
Array(ids).map { |id| { type: TYPE_PUBLIC_KEY, id: id } }
end
Expand Down
10 changes: 10 additions & 0 deletions lib/webauthn/public_key_credential/request_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ def attributes
super.concat([:allow_credentials, :rp_id, :user_verification])
end

def default_extensions
extensions = super || {}

if relying_party.legacy_u2f_appid
extensions.merge!(appid: relying_party.legacy_u2f_appid)
end

extensions
end

def allow_credentials_from_allow
if allow
as_public_key_descriptors(allow)
Expand Down
15 changes: 14 additions & 1 deletion lib/webauthn/public_key_credential_with_assertion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ def verify(challenge, public_key:, sign_count:, user_verification: nil)
encoder.decode(challenge),
public_key: encoder.decode(public_key),
sign_count: sign_count,
user_verification: user_verification
user_verification: user_verification,
rp_id: appid_extension_output ? appid : nil
)

true
Expand All @@ -31,5 +32,17 @@ def user_handle
def raw_user_handle
response.user_handle
end

private

def appid_extension_output
return if client_extension_outputs.nil?

client_extension_outputs['appid']
end

def appid
URI.parse(relying_party.legacy_u2f_appid || raise("Unspecified legacy U2F AppID")).to_s
end
end
end
7 changes: 5 additions & 2 deletions lib/webauthn/relying_party.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def initialize(
credential_options_timeout: 120000,
silent_authentication: false,
acceptable_attestation_types: ['None', 'Self', 'Basic', 'AttCA', 'Basic_or_AttCA', 'AnonCA'],
attestation_root_certificates_finders: []
attestation_root_certificates_finders: [],
legacy_u2f_appid: nil
)
@algorithms = algorithms
@encoding = encoding
Expand All @@ -36,6 +37,7 @@ def initialize(
@credential_options_timeout = credential_options_timeout
@silent_authentication = silent_authentication
@acceptable_attestation_types = acceptable_attestation_types
@legacy_u2f_appid = legacy_u2f_appid
self.attestation_root_certificates_finders = attestation_root_certificates_finders
end

Expand All @@ -47,7 +49,8 @@ def initialize(
:verify_attestation_statement,
:credential_options_timeout,
:silent_authentication,
:acceptable_attestation_types
:acceptable_attestation_types,
:legacy_u2f_appid

attr_reader :attestation_root_certificates_finders

Expand Down
3 changes: 3 additions & 0 deletions spec/support/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,14 @@ def seeds
},
u2f_migration: {
stored_credential: {
app_id: "https://f69df4d9.ngrok.io/appid",
certificate: "MIIBNDCB26ADAgECAgp2ubKB51u9YwjcMAoGCCqGSM49BAMCMBUxEzARBgNVBAMTClUyRiBJc3N1ZXIwGhcLMDAwMTAxMDAwMFoXCzAwMDEwMTAwMDBaMBUxEzARBgNVBAMTClUyRiBEZXZpY2UwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQfqziP5Gobu7FmIoFH0WCaD15knMWpIiLgeero1dVBVt2qo62PNI6GktGDUkzCwoj5pENTzTFVDUqAZTHDHTN1oxcwFTATBgsrBgEEAYLlHAIBAQQEAwIFIDAKBggqhkjOPQQDAgNIADBFAiEAwaOmji8WpyFGJwV/YrtyjJ4D56G6YtBGUk5FbSwvP3MCIAtfeOURqhgSn28jbZITIn2StOZ+31PoFt+wXZ3IuQ/e",
key_handle: "1a9tIwwYiYNdmfmxVaksOkxKapK2HtDNSsL4MssbCHILhkMzA0xZYk5IHmBljyblTQ_SnsQea-QEMzgTN2L1Mw",
public_key: "BBbTnfbd5sY+rCxZDQi87+akvZedjIqR8567GfrsLR0Gnp4zBpD5zhdSq1wKPvhzEoKJvFuYel1cpdTCzpahrBA=",
counter: 41,
},
assertion: {
origin: "https://f69df4d9.ngrok.io",
challenge: "v7G2KR2NYPW6AWxfevjMYflTxbWQqLwEoaZkOnm25K8=",
id: "1a9tIwwYiYNdmfmxVaksOkxKapK2HtDNSsL4MssbCHILhkMzA0xZYk5IHmBljyblTQ/SnsQea+QEMzgTN2L1Mw==",
response: {
Expand Down
49 changes: 49 additions & 0 deletions spec/webauthn/public_key_credential/request_options_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,53 @@
expect(options.allow_credentials).to eq([{ type: "public-key", id: "id" }])
expect(options.as_json[:allowCredentials]).to eq([{ type: "public-key", id: "id" }])
end

context "when legacy_u2f_appid" do
context "is set in the configuration" do
before do
WebAuthn.configuration.legacy_u2f_appid = "https://u2f-login.example.com"
end

context "and appid extension is not requested in the options" do
it "automatically adds it with the value in the configuration" do
expect(request_options.extensions).not_to be_empty
expect(request_options.extensions[:appid]).to eq("https://u2f-login.example.com")
end
end

context "and appid extension is requested in the options" do
let(:request_options) do
WebAuthn::PublicKeyCredential::RequestOptions.new(
extensions: { appid: "https://another-login.example.com" }
)
end

it "leaves the value that was originally requested" do
expect(request_options.extensions).not_to be_empty
expect(request_options.extensions[:appid]).to eq("https://another-login.example.com")
end
end
end

context "is not set in the configuration" do
context "and appid extension is not requested in the options" do
it "does not adds it automatically" do
expect(request_options.extensions).to be_empty
end
end

context "and appid extension is requested in the options" do
let(:request_options) do
WebAuthn::PublicKeyCredential::RequestOptions.new(
extensions: { appid: "https://another-login.example.com" }
)
end

it "leaves the value that was originally requested" do
expect(request_options.extensions).not_to be_empty
expect(request_options.extensions[:appid]).to eq("https://another-login.example.com")
end
end
end
end
end
105 changes: 105 additions & 0 deletions spec/webauthn/public_key_credential_with_assertion_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
require "webauthn/authenticator_assertion_response"
require "webauthn/configuration"
require "webauthn/public_key_credential_with_assertion"
require "webauthn/u2f_migrator"
require "support/seeds"

RSpec.describe "PublicKeyCredentialWithAssertion" do
describe "#verify" do
Expand Down Expand Up @@ -233,5 +235,108 @@
end
end
end

context "when verifying a migrated U2F credential" do
let!(:credential) do
migrated_credential = WebAuthn::U2fMigrator.new(**seeds[:u2f_migration][:stored_credential])

[
migrated_credential.credential.id,
migrated_credential.credential.public_key,
migrated_credential.authenticator_data.sign_count
]
end

let(:public_key_credential) do
WebAuthn::PublicKeyCredentialWithAssertion.new(
type: credential_type,
id: credential_id,
raw_id: credential_raw_id,
client_extension_outputs: { "appid" => true },
response: assertion_response
)
end

let(:assertion_response) do
WebAuthn::AuthenticatorAssertionResponse.new(
**seeds[:u2f_migration][:assertion][:response].transform_values { |v| Base64.strict_decode64(v) }
)
end

let(:origin) { seeds[:u2f_migration][:assertion][:origin] }
let(:challenge) { seeds[:u2f_migration][:assertion][:challenge] }

context "and appid is set in configuration file" do
let(:legacy_u2f_appid) { seeds[:u2f_migration][:stored_credential][:app_id] }

before do
WebAuthn.configuration.legacy_u2f_appid = legacy_u2f_appid
end

it "works" do
expect(
public_key_credential.verify(
challenge,
public_key: credential_public_key,
sign_count: credential_sign_count
)
).to be_truthy
end

context "if appid extension is not requested" do
let(:public_key_credential) do
WebAuthn::PublicKeyCredentialWithAssertion.new(
type: credential_type,
id: credential_id,
raw_id: credential_raw_id,
response: assertion_response
)
end

it "fails" do
expect do
public_key_credential.verify(
challenge,
public_key: credential_public_key,
sign_count: credential_sign_count
)
end.to raise_error(WebAuthn::RpIdVerificationError)
end
end
end

context "and appid is not set in configuration file" do
it "raises an error" do
expect do
public_key_credential.verify(
challenge,
public_key: credential_public_key,
sign_count: credential_sign_count
)
end.to raise_error("Unspecified legacy U2F AppID")
end

context "if appid extension is not requested" do
let(:public_key_credential) do
WebAuthn::PublicKeyCredentialWithAssertion.new(
type: credential_type,
id: credential_id,
raw_id: credential_raw_id,
response: assertion_response
)
end

it "fails" do
expect do
public_key_credential.verify(
challenge,
public_key: credential_public_key,
sign_count: credential_sign_count
)
end.to raise_error(WebAuthn::RpIdVerificationError)
end
end
end
end
end
end

0 comments on commit b90c6fd

Please sign in to comment.