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.)
To get started, please download the APK from the latest release of this sample:
https://github.com/YubicoLabs/android-prf-sample/releases/latest
If you prefer visual learning, watch our quick screencast of the main features here:
run-through.mp4
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.
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.
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:
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.
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. (
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.
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.
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.
The time has come; let's highlight how this sample app works.
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.
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.
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 algo
rithm 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.
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
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.
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.
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.
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.
Thank you for your time reading this description. We hope you have learned, shared, and explored something valuable from here.