Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow using FIDO2 authenticators with a PIN #2194

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

TheMartinizer
Copy link

@TheMartinizer TheMartinizer commented Feb 22, 2024

Added PIN verification for FIDO2 authenticators. I am not a great Kotlin or Android programmer, so this could probably have been done in a more elegant way.

When the AuthenticatorActivityFragment is started, a ModelView is created which contains two variables: a boolean that says whether the user has input a PIN, and a nullable string containing the PIN. These variables are then passed to the TransportHandler. If the authenticator wants a client PIN and the user has not yet been asked for a PIN (ie. the boolean is false), a MissingPin exception is thrown up to the AuthenticatorActivity. This makes the activity redirect the user to a PinFragment, where the user can enter a pin that is stored in the ModelView. If the user has previously cancelled entering the pin, the code tries to authenticate without a PIN (since some authenticators still seem to return something when no PIN is entered). If the authenticator returns the status code for wrong pin, the user is redirected to the PIN fragment with a message saying wrong pin.

This code has only been tested on a Yubikey over NFC, but seems to work reasonably well for both signing and registering. It is not tested for USB
To be implemented:

  • Telling the user how many PIN retries are left
  • Telling the user when the PIN has been wrongly entered too many times, and the authenticator reset
  • Probably a whole lot of error handling

): Pair<AuthenticatorMakeCredentialResponse, ByteArray?> {
connection.capabilities
val reqOptions = AuthenticatorMakeCredentialRequest.Companion.Options(
when (options.registerOptions.authenticatorSelection?.residentKeyRequirement) {
RESIDENT_KEY_REQUIRED -> true
RESIDENT_KEY_PREFERRED -> connection.hasResidentKey
RESIDENT_KEY_DISCOURAGED -> false
else -> options.registerOptions.authenticatorSelection?.requireResidentKey == true
else -> when(options.registerOptions.authenticatorSelection?.requireResidentKey) {
Copy link
Member

@mar-v-in mar-v-in Feb 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original code was directly derived from the specification, see https://www.w3.org/TR/webauthn-3/#effective-resident-key-requirement-for-credential-creation. With your changes, the behavior doesn't match the specification anymore in two cases:

  • if requireResidentKey is set to true, but the authenticator doesn't support resident keys, it would be ignored (instead of requiring it)
  • if requireResidentKey is unset and the authenticator does support resident keys, it would require a resident key (instead of using the default of false)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right! I have reverted this to your original way of calculating the request option in the latest version of the code. It was a stupid decision to change it in the first place; I was getting unexpected results when testing an NFC authenticator with Firefox, so I changed the code until I got the results I expected. What I didn't realise was that there seems to be a bug in Firefox (at least on Android) where the register options and signing options aren't provided, and so the code would always revert to not requiring a resident key. I changed it to defaulting to requiring a resident key, which as you point out is not in accordance with WebAuthn.

when (options.registerOptions.authenticatorSelection?.requireUserVerification) {
REQUIRED -> true
DISCOURAGED -> false
else -> connection.hasUserVerificationSupport
}
// According to newer drafts of CTAP2.1, the user verification key MUST NOT be included
// if the authenticator is not capable of built-in verification
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you refer to where the specification says this? If the requirement is stripped if the authenticator is not capable, I wonder what the difference between requireUserVerification values of preferred and required is supposed to mean.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is stated in the v2.2 review draft: https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#authenticatorGetAssertion. However, since that is still a review draft, and it deprecates the use of the "uv" flag and mandates the use of a pinUvAuthParam option (which I have not implemented), I have dropped this from the code

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. In this case we should probably just bail out early if requireUserVerification is set to required and the authenticator doesn't support it. That way we still honor the requirement from WebAuthN correctly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have written it so that if no PIN is provided, the requireUserVerification variable is passed to the authenticator in the "uv" flag. On my Yubikey, this leads to a status code of 0x2b (unsupported error). I haven't changed the code that deals with CTAP error messages, so this is currently logged as an exception and then the program continues.

): ByteArray? {
// Ask for shared secret from authenticator
val sharedSecretRequest = AuthenticatorClientPINRequest(
0x01,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use named constants (or enums where applicable) for values where the meaning of such value is not trivially derivable from context, so that when reading the code, one is not required to open the specification to have an idea what this even means (also applies elsewhere in this PR)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created named constants for the subcommands of the PIN Request, as well for the PIN protocol versions (for future expansion).

) {
Log.d(TAG, "Using CTAP1/U2F for PIN-less registration")
if (connection.hasCtap1Support
&& options.registerOptions.authenticatorSelection?.residentKeyRequirement == RESIDENT_KEY_DISCOURAGED) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally want to register with CTAP1 if possible given the provided constraints. This is for backwards compatibility with other clients only implementing CTAP1 and many authenticators not supporting sign in with CTAP1 if registration happened with CTAP2.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I've understood CTAP1 correctly, it can do a lot of the same things as CTAP2, but it cannot handle resident keys or PIN authentication. If I'm not mistaken, user verification works in CTAP1 if the verification is built in to the authenticator. The code is now written to default to CTAP1 when registering unless resident keys and/or PIN authentication is required.

@TheMartinizer
Copy link
Author

Thank you for the very comprehensive feedback. I have rewritten and refactored the code a bit. The PIN protocol version and PIN subcommands are now named variables. The requireResidentKey variable should now be set in accordance with the WebAuthn standard. However, I moved the code where the variable is determined to a different place in the code, so it could be used to determine whether to use CTAP1 or CTAP2.

If I've understood it correctly, CTAP1 is not capable of handling resident keys, or of handling user verification with a PIN (although as I understand it built-in user verification on the authenticator itself can still be handled). I've therefore set it so that CTAP2 registering is only used if the requesting party requires resident keys, or if user verification is required and the only available method of user verification is client PIN. Otherwise, it attempts CTAP1 registering.

As the code is currently written, the first credential matching the requesting party ID is returned if the allowList is empty. I think this a good behaviour for the moment, since most sites where the passkey is registered with a resident key will send an empty allowList. Using the first available credential at least allows the user to log in with one credential, although if the user has several credentials they will not be able to choose. I can look into creating a menu for choosing the correct credential later.

@mar-v-in mar-v-in added this to the 0.3.1 milestone Mar 3, 2024
@mar-v-in mar-v-in modified the milestones: 0.3.1, 0.3.2 Mar 19, 2024
@BryanJacobs
Copy link

If I've understood CTAP1 correctly, it can do a lot of the same things as CTAP2, but it cannot handle resident keys or PIN authentication

This is correct. CTAP1/U2F can use built-in user verification but not clientPin. It also can't handle any extensions, so your logic should be to use CTAP2 when any of the following is true:

  • UV required and built-in UV unavailable
  • Resident key / discoverable credential required
  • Extensions present that are passed to the authenticator (ie, not the appId extension)

I have written it so that if no PIN is provided, the requireUserVerification variable is passed to the authenticator in the "uv" flag

For clarity, setting uv=true means you want the authenticator to do on-board user verification (NOT USING A PIN). If the PIN is being passed you should not pass uv=true to the authenticator, but it will still return uv=true in the response.

This is a bit confusing, yes. UV in the INput to the authenticator means that the authenticator should pop up a prompt or use its own fingerprint or something like that. UV in the OUTput means the user was verified by any means, including the given clientPin.

As the code is currently written, the first credential matching the requesting party ID is returned if the allowList is empty. I think this a good behaviour for the moment, since most sites where the passkey is registered with a resident key will send an empty allowList. Using the first available credential at least allows the user to log in with one credential, although if the user has several credentials they will not be able to choose. I can look into creating a menu for choosing the correct credential later.

Authenticators return the most recently created or updated credential first, so this is a good way to do it if you have no menu.

@BryanJacobs
Copy link

Just want to add that I built this branch (merged with microg master) and it works pretty darn well with a https://github.com/BryanJacobs/FIDO2Applet install over NFC. I can do webauthn in Fennec with PINs.

@mar-v-in mar-v-in modified the milestones: 0.3.2, 0.3.3 Apr 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants