Skip to content

YubicoLabs/android-prf-sample

Repository files navigation

Android PRF Sample

Welcome to our Android sample app, demonstrating how to use the Pseudo-Random Function (PRF) WebAuthn extension.

This sample aims to follow current best practices in security and Android development. We welcome your feedback.

Let's explore this sample from a user's perspective, following the UI. (Disclaimer: This sample prioritizes security aspects over UI/UX design.)


Getting Started

To get started, please download the APK from the latest release of this sample:

https://github.com/YubicoLabs/android-prf-sample/releases/latest

Screencast

If you prefer visual learning, watch our quick screencast of the main features here:

run-through.mp4

User Interface

Upon opening the app for the first time, you'll see a screen with three buttons: two purple (or default OS-colored) buttons labeled 'Register' and 'Login', and a Floating Action Button (FAB) in the lower right corner. The FAB displays logs from the sample, which are useful for identifying issues without an ADB connection or laptop.


Register

Once you click on the 'Register' button, you get warned, that a username must be entered. Tap the ' User Name' text field and enter your desired username. We recommend "David Hasselhoff."

Once you've entered your favorite singer, proceed by clicking the 'Register' button.


The app will then attempt to connect to your FIDO security key. Attach your YubiKey to your phone's USB port or tap it close to your NFC antenna.


This sample app now creates a new credential on your key, using your username and the app's name as the relying party ID.

Hopefully, your security key supports the WebAuthn PRF extension via CTAP's HMAC function, and the credential will be created successfully. If your key doesn't support this extension, you'll receive an error message and will need to acquire a YubiKey.


Upon successful creation, you can now log in and execute the extension for the first time.

Login

You've successfully created a new credential on your security key. To use it, you need to assert that credential (i.e. log in with it) to provide the parameters required to create your secret message.

Simply hit the "Login" button to log in.

If you've created only one credential, you should be immediately greeted with the "logged in screen."


If you've created multiple credentials, you'll need to choose the one you want:


Information and Log Messages

Dealing with hardware can sometimes be challenging, so we've added an information and log message display. Feel free to tap the Floating Action Button in the lower right corner (with the information icon and badge showing the number of messages) to access it.


Once tapped, the FAB magically transforms into a display of these messages. Click the share button in the top left corner to copy all messages to the clipboard (and share them with us in an issue if you want to report something that works differently than expected).

To close the information display, simply tap on the 'Info' title.

Encrypt

Now that you're logged in (if not, please log in), you can encrypt messages as you please. Enter your message or use the 🔮 button to request a random quote from https://zenquotes.io. (⚠️ Warning: You are rate-limited to 5 requests per minute.)


Once you're satisfied with your super-secret message, hit the button with the lock and key: 🔐. This will take the response from the PRF extension, create an AES key, and finally encrypt the message with that key, replacing the original message with the ciphertext in the message field.

If you want to encrypt the encryption, feel free to tap the encrypt 🔐 key repeatedly. It doesn't necessarily enhance security but is fun to do!


If you wish to store your secret message or encrypted Zen for posterity, hit the share button on the bottom right side of the message view. This will copy the text to the clipboard for further use.

Example

A cleartext of

Errors this request has.
java.io.FileNotFoundException: https://zenquotes.io/api/random
 ‒ https://zenquotes.io

will look like this encrypted:

dc6abb3961da5b189ca1884cb6d59efc1957828f0e5e89879982db6d19111cb2cbe7b0939031b0bc96a6340096ff774da84ccf151c8ccab30c6d37a7c1facbb37b1b2228c93dc4093a37701dee94b8da93939e1028f012ad1838ac23b0f82ba7d25021b401d81246e24111caea2efb4cc7e4ce98e9dc4e39c6f2b7e6121dfd6252

Note: Only the security key used for creation of the encrypted text can be used for decrypting it. If you encrypt the clear text from above, you will get a different encrypted text back. Same if you try to decrypt it: You won't get the presented clear text back, you need the security key and the credential it was created with.

Decrypt

If you've stored a secret text to be decrypted with your credential on your security key, paste it into the message view. If the pasted text is in clear text and not a string representing hex values, hitting the decrypt button (the second from the left on the lower end of the message view, symbolized by an open lock) will result in an error in the information view. Please only decrypt hex encoded strings, see the example from above.

If you hit the decrypt button with correct ciphertext, you will either receive a completely legible text (congratulations!) or you might see no change whatsoever.


In that case, please check your messages. Usually, this means you tried to decrypt a text from a different credential. The messages view will show something like 'mac check in GCM failed.'


Log out and log in with a different credential to try again.

If you successfully decrypted the ciphertext, congratulations again! You've navigated through the entire UI of this sample. Now it's time to log out and explore the source code to understand how it works.


Code

The time has come; let's highlight how this sample app works.

PRF Extension

As mentioned before, we are using the WebAuthn PRF extension. The PRF extension is specified here and is one of the more readable extensions. So, how do we translate those specifications into Android? We opted to use the YubiKit-Android SDK. This implements the necessary extensions and processing for us, so we don't have to delve too deeply into understanding hardware and the underlying protocols.

To verify the existence of the PRF extension, we need to attempt to create a credential. The handling of credentials and interaction with the SDK is abstracted in the CredentialContainer class, located at CredentialContainer.kt. It handles user interaction ("What is your PIN?") by reading the response and the requirements of the YubiKey. Additionally, it nicely abstracts the subtle differences between NFC and USB communication. (Please don't examine the activity handling too closely; it's needed for NFC interactions.)

You would ideally create a CredentialContainer within a dependency injection framework, but here we opted for direct, lazy instantiation in the ViewModel at MainViewModel.kt. Once created, you can call its get and create methods.

You can create a credential using that container with a snippet similar to this:

credentialContainer.create(
    options = options,
    successCallback = { response ->
    },
    failureCallback = { throwable ->
    },
)

The snippet shows that creating a credential requires a set of credential creation options and invokes either the success or failure callback.

The aforementioned options conform to the mentioned specifications and can be created following this snippet:

val options = PublicKeyCredentialCreationOptions(
    /*rp = */ PublicKeyCredentialRpEntity(
        /*name = */ "PRF / HMAC Sample App",
        /*id = */ "yubico.labs.prf.sample",
    ),
    /*user = */ PublicKeyCredentialUserEntity(
        /*name = */ username,
        /*id = */ Uuid.random().toByteArray(),
        /*displayName = */ username
    ),
    /*challenge = */ createChallenge(),
    /*pubKeyCredParams = */ listOf(
        PublicKeyCredentialParameters(
            /*type = */ "public-key",
            /*alg = */ -7
        )
    ),
    /*timeout = */ null,
    /*excludeCredentials = */ emptyList(),
    /*authenticatorSelection = */ AuthenticatorSelectionCriteria(
        /*authenticatorAttachment = */ null,
        /*residentKey = */ ResidentKeyRequirement.REQUIRED,
        /*userVerification = */ UserVerificationRequirement.REQUIRED,
    ),
    /*attestation =*/ null,
    /*extensions =*/ Extensions.fromMap(
        mapOf(
            "prf" to emptyMap<String, String>()
        )
    )
)

Many fields in the options object are self-explanatory or require more explanation than this README can offer. If you have questions, feel free to ask us.

As an overview, the rp field describes the relying party. In this case, it's the name of this application. In a client/server context, you would describe the backend service here.

Our user-entered information can also be found here: The username is stored as user.name. Notice that the userid is randomized? This allows different users to have the same username but distinct IDs.

A challenge is a set of random bytes that should be returned by the YubiKey, transformed in a way that allows us to verify the credential stored on the YubiKey is correctly implemented. Checking this challenge is left as an exercise for the user.

Also interesting is the list of PublicKeyCredentialParameters: These parameters define which algorithm should be used for the creation of the public/private keypair. Decrypting the algorithm values here, the one algorithm we request is an ECDSA w/ SHA-256, i.e., an elliptical curve using SHA-256. More specific information can be found in its Request For Comments.

A timeout of null means the app uses the default timeout. A more detailed description can be found in the specifications, meaning it's implementation-dependent.

You've probably guessed what the excludeCredentials field stands for: It's a list of credentials we don't want the YubiKey to return.

Since we want to be sure the user is present when using the YubiKey with the PRF extension, we need to provide the correct authenticatorSelection parameters: A resident key ResidentKeyRequirement.REQUIRED and the user needs to verify the key with UserVerificationRequirement.REQUIRED. A user verifies a key by either entereing a PIN or using their fingerprint, depending on the actual key used.

We set attestation to null to ignore it and let the defaults apply.

But we need to highlight the extensions: This field declares which extensions this WebAuthn credential should contain and be able to process. If you don't add them here, you won't be able to add them later. So, we provide a map of "prf" to emptyMap<String, String>(), which means we want the credential to contain the PRF extension.

After all of that, we call the CredentialContainer::create(...) method and wait until one of the callbacks (successCallback or failureCallback) is invoked.

The easiest and most unfortunate response is in the failureCallback: the throwable parameter contains what exactly went wrong and what we can do to mitigate it. Usually, the PIN was incorrect, or the connection to the security key was interrupted. Reposition the key and try again; if it persists, we need to investigate further.

If the successCallback is invoked, we know several things: We successfully called the credential container, and we have created a credential! But to see if we can use it, we need to check if the PRF extension is supported.

val extensionResults =
    response.clientExtensionResults
        ?.toMap(SerializationType.JSON)

val prf = extensionResults?.getOrDefault("prf", null) as? Map<*, *>
val enabled = prf?.getOrDefault("enabled", null) as? Boolean

This snippet checks if the map of the clientExtensionResults contains a prf entry, if that in turn contains an enabled value, and if it's set to true.

If it is indeed true, we proceed and ask the user to log in. Otherwise, we error out and inform the user.

Login User to Evaluate PRF

Once you have created at least one credential on your security key, you can start evaluating the pseudo-random function for the created credential. To do so, you need to add the PRF extension to the get call of the CredentialContainer like so:

credentialContainer.get(
    options = options,
    successCallback = { credential, username ->
    },
    failureCallback = { throwable ->
    },
)

This looks very similar to the create method: It takes options, this time of type PublicKeyCredentialRequestOptions, and two callbacks indicating success or failure.

The options are created like this:

val options = PublicKeyCredentialRequestOptions(
    /*challenge = */ createChallenge(),
    /*timeout = */ null,
    /*rpId = */ "yubico.labs.prf.sample",
    /*allowCredentials = */
    mutableListOf(
        PublicKeyCredentialDescriptor(
            /*type = */ "public-key",
            /*id = */ _credentialResponse.value?.rawId,
        )
    ),
    /*userVerification = */ null,
    /*extensions = */
    Extensions.fromMap(
        mapOf(
            "prf" to mapOf(
                "eval" to mapOf(
                    "first" to createPrfInput()
                )
            )
        )
    )
)

Again, we create a challenge to be checked as an exercise for the user, use the default timeout, and set the rpId (relying party ID) to the name of our app.

The allowCredentials field gets interesting: This will be set to the just-created credential, or set to null if the app hasn't recently created a credential.

If no credential is selected, the SDK asks for all credentials created on the security key and lets the user pick the right one.

Once more, we set userVerification to use the defaults and instead focus on the extensions: This field is set to evaluate the PRF. The following snippet provides the pseudo-JSON we supplied it with before:

{
  "prf": {
    "eval": {
      "first": "{bytes}"
    }
  }
}

This means we set the first value of the PRF's eval field to be evaluated. The same values here will produce the same responses for the same credential. Different credentials but the same first values will create different values.

You might think that first implies the existence of second, and you would be correct: The second parameter of the PRF's eval field can be used to roll credentials over, updating them from the first to the second.

But what are those {bytes}? These are used as a unique initial value to create the pseudo-random function from. In our example, it's set to the following:

private fun createPrfInput() = 0xC0FFEBEA51.toHexString()

This creates a constant hex string, so the results are reusable.

With the above steps, we successfully asked the SDK to log in a user, and the user might be asked to provide their security key and a PIN. Once either of them are provided, we can look at the results of evaluating the pseudo-random function.

32-Byte Key Derivation Function using HMAC

The result of getting a credential from the security key is of type PublicKeyCredential and contains our extensions' PRF result. To get it, a snippet like the following can be used:

(SerializationType.JSON)
val prf = extensions?.getNested("prf.results.first") as? String
if (prf != null) {
    // use prf
} else {
    Log.e(tagForLog, "No prf found.")
}

But what is that prf value returned? It contains the evaluation of the pseudo-random function based on the selected credential and the input value from the get-options. It's a well-distributed array of 32 bytes. Those bytes can be used to create an AES key for symmetric encryption and decryption. Unfortunately, it isn't the right size yet, and a new key needs to be derived from the PRF response: This is to ensure the base minimum of security for creating the AES key.

To derive a new key from the PRF result, we use the YubiKit-Android implementation of a HMAC (Hash-based Message Authentication Code)-based Extract-and-Expand Key Derivation Function (HKDF). ( Indeed, we just extracted the Java file from the SDK and included it here.) The code for doing so looks like the next snippet:

val secret = HKDF(
    /*algo =*/ "HmacSHA256"
).digest(
    /*ikm =*/ prf.toByteArray(),
    /*salt =*/ createHkdfSalt(),
    /*info =*/ createHkdfInfo(),
    /*length =*/ 32
)

val key = SecretKeySpec(secret, "AES")

As you can see from the snippet above, we use the same algorithm as for the credential created and provide the HKDF with the PRF result as a byte array to the ikm (Input Key Material). An application-specific salt is created for the salt parameters (32 times a 0x00 byte).

Additionally, we create an info byte array set to another constant of "BEST SECURITY".toByteArray(StandardCharsets.UTF_8).

Finally, we use a length input of 32. This is the length of the provided input key.

With this, we can finally start thinking about creating the AES key to encrypt and decrypt our secret messages.

AES Encryption

Once we have "bumped up" our PRF response through the HKDF into a valid key, we can create the necessary AES ciphers as follows:

val key = SecretKeySpec(hkdfResponse, "AES")
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(createCipherIV()))

val output = cipher.doFinal(input.toByteArray())

That snippet creates a cipher for the AES/GCM/NoPadding algorithm and initializes it with the just-derived key. Additionally, it takes an input vector IvParameterSpec of the first 16 numbers, expressed as bytes: ByteArray(16) { it.toByte() }.

With all of that setup, we can call cipher.doFinal with our input message and create the output message. #magic

Decryption

Assuming you want to get the cleartext message back from an encrypted one, you simply do the same as for encryption above, but replace the mode of the cipher.init with Cipher.DECRYPT_MODE:

decipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(createCipherIV()))

It's important here to keep the credential, input vectors, and salts the same; otherwise, you'll lose the ability to decrypt your super-secret messages.

Thank you for following so far! We are now exploring extra details of this sample, not necessarily needed to understand the PRF usage.


Extra: Sample App Constraints, or How to Salt

Please note that the functions createPrfInput, createHkdfSalt, createHkdfInfo and createCipherIV (essentially all functions returning a constant byte array) are here for ease of building a sample app and should be replaced with random byte arrays in production ready applications.

Please also refer to the specification text Cryptographic Challenges.


Extra: ViewModels and Android Basics

This app uses Jetpack Compose, ViewModels, and Coroutines - nothing too unexpected in Android app development. We should investigate using a Dependency Injection(DI) framework but opted against it due to the rather small size of this example.

We also use the YubiKit-Android to communicate with FIDO2 security keys and gracefully "stole" the HKDF.java file from there.


Extra: Random Quotes

Since we often lack good text examples for encryption and decryption, we opted to use the marvels of zenquotes.io. To get a quote, we need to call the api/random/ endpoint and parse its response into an author and quote. The following snippet summarizes the flow:

val stream = URL("https://zenquotes.io/api/random").openStream()
val reader = BufferedReader(InputStreamReader(stream))
val response: String = reader.readText()
val jsonResponseArray = JSONArray(response)
val firstJson = jsonResponseArray.getJSONObject(0)

if (firstJson.has("q") && firstJson.has("a")) {
    val quote = firstJson.getString("q")
    val author = firstJson.getString("a")

    _message.update { "$quote\n$author\n\n(provided by https://zenquotes.io/)" }
} else {
    _message.update {
        "Learning never exhausts the mind.\n" +
                " ‒ Leonardo da Vinci\n\n" +
                "(provided by https://zenquotes.io/)"
    }
}

We are using URL::openStream to avoid adding another dependency for a relatively small use case. Please respect the 5 requests/minute rate limit.


Closing

Thank you for your time reading this description. We hope you have learned, shared, and explored something valuable from here.

About

Android Sample app demonstrating how to use the Pseudo Random Function WebAuthn extension

Resources

License

Stars

Watchers

Forks

Packages

No packages published