Oya Canli
2 september 2025
Android Keystore(AKS from now on) is currently the most secure way of saving your master keys in an Android device. For a client who has high security concerns due to the sensitive nature of the app data, we recommended to use AKS. As a business requirement, the client also wanted us to use an external master key coming from the server during initial verification of the user on the device. That makes it possible to recover user’s data in case they lose their pin. That would also let us take extra security measures like deleting local master keys in case of suspicious activity, and if it is a false alarm, possibility to recover user’s data by re-importing the same external master key.
So far so good. We had saved external master keys with other keystores in the past for other clients. It was just the first time with AKS. But AKS implements the same java interface as the other keystores under the hood (java.security.KeyStore), it has a setEntry method that expects a secure key. So it seemed similar. Unfortunately, that was not the case. The fact that AKS uses the same interface and functions like setEntry() was giving the illusion that we can do it in a similar fashion, however AKS implementation had more sophisticated requirements under the hood. Documentation was assuring us that it is possible to import external keys into AKS (for Android 9 or above and provided that the device is shipped with Keymaster 4 or above), but it was not possible to find a tutorial or code example on the subject. Today, after figuring out how this works the hard way, I would like to share our implementation and code snippets with the community.
Transfer your external key securely:
AKS accepts to save an external master key, but only in a very specific format. The external master key has to be wrapped as an ASN.1 message, which contains some information (or metadata) about the external master key besides the encrypted master key itself.
First of all, we need to use an RSA public-private key pair to transfer the external master key securely. This is a common way of transferring sensitive data over the network. How it works is basically as follows:
- You generate a public-private key pair, you sent the public key to your trusted server
- The server uses your public key to encrypt the sensitive data and sends it back to you
- Once you get the encrypted response from the server, you can decrypt it locally with your private key
In Android, this is how we can generate an RSA public-private key pair with AKS:
private fun generateWrappingKeyPair(wrappingKeyAlias: String): KeyPair {
val attestationChallenge = ByteArray(256 / 8)
SecureRandom().nextBytes(attestationChallenge)
val keyPairGenerator =
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
keyPairGenerator.initialize(
KeyGenParameterSpec.Builder(
wrappingKeyAlias,
KeyProperties.PURPOSE_WRAP_KEY
)
.setKeySize(2048)
.setDigests(KeyProperties.DIGEST_SHA256)
.setBlockModes(KeyProperties.BLOCK_MODE_ECB)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.setAttestationChallenge(attestationChallenge)
.build()
)
return keyPairGenerator.generateKeyPair()
}
Once you generate this pair, you will attach the public key in your request to the server. Google’s documentation also recommends attaching certificates to this request. So here is an example of how we use this to make a network request:
val wrappingKeyAlias = appSecretProvider.getWrappedKeyAlias(linkCode, userName)
val keyPair = generateWrappingKeyPair(wrappingKeyAlias)
val publicKey = keyPair.public
val attestationCertificates = getAttestationCertificates(wrappingKeyAlias)
val result = authenticationRepository.validateAndLinkUser(
userName = userName,
uniqueCode = linkCode,
publicKey = publicKey,
attestationCertificates = attestationCertificates,
)
And here is the getAttestationCertificates function (this step is optional):
private fun getAttestationCertificates(wrappingKeyAlias: String): List<String> {
return keyStore.getCertificateChain(wrappingKeyAlias)
.mapNotNull { cert ->
(cert as? X509Certificate)?.encoded?.let { certBytes ->
Base64.encodeToString(certBytes, Base64.NO_WRAP)
}
}
}
The server will check if this user is valid, will check the certificates if you have attached them, and then use this user’s public key for encrypting their master key and send it back to the app in a specific format (will come to that format in a minute). Once we got the response successfully, we can save it to AKS:
// As each user has their own master key, alias would be also per user
val importedKeyAlias =
appSecretProvider.getMasterKeyAlias(response.user)
try {
importWrappedKey(
response.wrappedMasterKey,
wrappingKeyAlias,
importedKeyAlias
)
} catch (e: KeyStoreException) {
// log the error and fall to error state
}
And here is the importWrappedKey function:
@Throws(KeyStoreException::class)
private fun importWrappedKey(
wrappedKey: String,
wrappingKeyAlias: String,
importedKeyAlias: String,
) {
val wrappedKeyBytes = Base64.decode(wrappedKey, Base64.NO_WRAP)
val spec: AlgorithmParameterSpec = KeyGenParameterSpec.Builder(
wrappingKeyAlias,
KeyProperties.PURPOSE_WRAP_KEY
)
.setDigests(KeyProperties.DIGEST_SHA256)
.build()
val wrappedKeyEntry = WrappedKeyEntry(
wrappedKeyBytes,
wrappingKeyAlias,
"RSA/ECB/OAEPPadding",
spec
)
keyStore.setEntry(importedKeyAlias, wrappedKeyEntry, null)
}
Once this is done, the master key is ready to use for the user, using the same master key alias (attention, not the alias of the wrapped key). You can try to grab it like this:
private fun getMasterKey(): SecretKey {
val masterKeyAlias = appSecretProvider.getMasterKeyAlias(user)
val existingKey = keyStore.getEntry(masterKeyAlias, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: throw IllegalStateException("Master key not found")
}
(As a side note: the aliases we use for master keys are not random hardcoded strings, but strings generated per user, in a secure fashion. But it is out of the scope of this article)
So from the Android side, this is all. We send the public key to the server, server does its magic and sends us back the wrapped key and we save it locally. As an Android developer, what the server does for you is a black box, right? However, it is pretty important how the server wraps the key for you. If it doesn’t have the exact format you are expecting, it will crash when you try to save it to AKS.
Android documentation specifies the format of the wrapped key: it has to be an ASN.1 message with the format below.
KeyDescription ::= SEQUENCE {
keyFormat INTEGER,
authorizationList AuthorizationList
}
SecureKeyWrapper ::= SEQUENCE {
wrapperFormatVersion INTEGER,
encryptedTransportKey OCTET_STRING,
initializationVector OCTET_STRING,
keyDescription KeyDescription,
secureKey OCTET_STRING,
tag OCTET_STRING
}
If your backend developers take a quick look at this and tell you “no need to tell more, I got this”, lucky you! But they are likely to request more details. Besides, as we were doing this the first time, I wanted to first try this out locally myself and confirm that it works, before requesting from backenders to build this for us. So here is the helper class I used to fake this wrapping during development process, with lots of comments and explanations. This also helped our backend developers understand what we expect and build the real implementation later:
/**
* For the secure transfer of the external master key coming from the server
* The client app will generate an RSA key-pair and send the public key to the server
* together with the AppLinkRequest. The client can also attach the attestation certificate
* to the request for an improved security which the server can check before giving back the
* master key
* The server should encrypted user's master key on the server with the client's public key
* to transfer it securely. Then encrypted master key and some extra metadata about the key are
* wrapped together in an ASN1 message, in the format that can be found in docs:
* https://developer.android.com/reference/android/security/keystore/WrappedKeyEntry
*
* Unfortunately there is not much resources about this. Only useful example I found was
* from the Android SDK unit tests where they have locally generated the ASN1 message for testing:
* https://android.googlesource.com/platform/cts/+/master/tests/tests/keystore/src/android/keystore/cts/ImportWrappedKeyTest.java#135
*
* Below code is assuming that the server will provide an AES 256 key. If we use a different
* key, some parameters might need to change.
*/
object ServerMasterKeySimulator {
private var GCM_TAG_SIZE: Int = 128
// All constants used here can be found here:
// https://android.googlesource.com/platform/hardware/interfaces/+/master/keymaster/4.0/types.hal
private const val KM_KEY_FORMAT_RAW = 3L
fun requestWrappedMasterKey(
publicKeyInString: String,
attestationCertificates: List<String>
): String {
// assume we check here attestation certificates
// convert to key object
val publicKeyInBytes = Base64.decode(publicKeyInString, Base64.NO_WRAP)
val keySpec = X509EncodedKeySpec(publicKeyInBytes)
val keyFactory = KeyFactory.getInstance("RSA")
val publicKey = keyFactory.generatePublic(keySpec)
// assume this is the master key of the user
val externalMasterKey = "5zts4Cc/5jLTYET1biClMB1+6bfEYpUaaKbXdv0oL/k="
val externalKeyInBytes = Base64.decode(externalMasterKey, Base64.NO_WRAP)
val wrappedKey = wrapKey(publicKey, externalKeyInBytes)
return Base64.encodeToString(wrappedKey, Base64.NO_WRAP)
}
private fun wrapKey(
publicKey: PublicKey,
keyMaterial: ByteArray
): ByteArray {
// wrapperFormatVersion, first argument in the sequence
val WRAPPED_FORMAT_VERSION = 0L
// Generate and encrypt an ephemeral key used to encrypt the secure key.
// This key will be encrypted with the wrapping public key of the user
val aesKeyBytes = ByteArray(32)
SecureRandom().nextBytes(aesKeyBytes)
val spec = OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT
)
val encryptedEphemeralKeys = Cipher.getInstance("RSA/ECB/OAEPPadding").run {
init(Cipher.ENCRYPT_MODE, publicKey, spec)
doFinal(aesKeyBytes)
}
// InitializationVector
val iv = ByteArray(12)
SecureRandom().nextBytes(iv)
// wrappedKeyDescription is a KeyDescription object seen in ASN1 format
// build with keyFormat and authorizationList
val descriptionItems = ASN1EncodableVector()
descriptionItems.add(ASN1Integer(KM_KEY_FORMAT_RAW))
descriptionItems.add(makeAesKeyAuthList())
val wrappedKeyDescription = DERSequence(descriptionItems)
// Encrypt secure key
val secretKeySpec = SecretKeySpec(aesKeyBytes, "AES")
val gcmParameterSpec = GCMParameterSpec(GCM_TAG_SIZE, iv)
val aad: ByteArray = wrappedKeyDescription.getEncoded()
var encryptedSecureKey = Cipher.getInstance("AES/GCM/NoPadding").run {
init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmParameterSpec)
updateAAD(aad)
doFinal(keyMaterial)
}
// Get GCM tag. Java puts the tag at the end of the ciphertext data :(
val len = encryptedSecureKey.size
val tagSize: Int = (GCM_TAG_SIZE / 8)
val tag = Arrays.copyOfRange(encryptedSecureKey, len - tagSize, len)
// Remove GCM tag from end of output
encryptedSecureKey = Arrays.copyOfRange(encryptedSecureKey, 0, len - tagSize)
// Build ASN.1 DER encoded sequence WrappedKeyWrapper
val items = ASN1EncodableVector()
items.add(ASN1Integer(WRAPPED_FORMAT_VERSION))
items.add(DEROctetString(encryptedEphemeralKeys))
items.add(DEROctetString(iv))
items.add(wrappedKeyDescription)
items.add(DEROctetString(encryptedSecureKey))
items.add(DEROctetString(tag))
return DERSequence(items).getEncoded(ASN1Encoding.DER)
}
private fun removeTagType(tag: Int): Int {
val kmTagTypeMask = 0x0FFFFFFF
return tag and kmTagTypeMask
}
// list of purpose constants:
// https://android.googlesource.com/platform/hardware/interfaces/+/refs/heads/main/security/keymint/aidl/android/hardware/security/keymint/KeyPurpose.aidl
private const val KM_PURPOSE_ENCRYPT = 0L
private const val KM_PURPOSE_DECRYPT = 1L
// List of algorithm constants:
// https://android.googlesource.com/platform/hardware/interfaces/+/refs/heads/main/security/keymint/aidl/android/hardware/security/keymint/Algorithm.aidl
private const val KM_ALGORITHM_AES = 32
// List of block mode constants:
// https://android.googlesource.com/platform/hardware/interfaces/+/refs/heads/main/security/keymint/aidl/android/hardware/security/keymint/BlockMode.aidl
private const val KM_MODE_CBC = 2L
private const val KM_MODE_ECB = 1L
// List of padding mode constants:
// https://android.googlesource.com/platform/hardware/interfaces/+/refs/heads/main/security/keymint/aidl/android/hardware/security/keymint/PaddingMode.aidl
private const val KM_PAD_PKCS7 = 64L
private const val KM_PAD_NONE = 1L
// List of tag constants:
// https://android.googlesource.com/platform/hardware/interfaces/+/refs/heads/main/security/keymint/aidl/android/hardware/security/keymint/Tag.aidl
private const val KM_TAG_PURPOSE = 1
private const val KM_TAG_ALGORITHM = 2
private const val KM_TAG_KEY_SIZE = 3
private const val KM_TAG_PADDING = 6
private const val KM_TAG_BLOCK_MODE = 4
private const val KM_TAG_NO_AUTH_REQUIRED = 503
private fun makeAesKeyAuthList(): DERSequence {
// add authorized purposes
val allPurposes = ASN1EncodableVector()
allPurposes.add(ASN1Integer(KM_PURPOSE_ENCRYPT))
allPurposes.add(ASN1Integer(KM_PURPOSE_DECRYPT))
val purposeSet = DERSet(allPurposes)
val purpose =
DERTaggedObject(true, removeTagType(KM_TAG_PURPOSE), purposeSet)
// add the algorithm used
val algorithm =
DERTaggedObject(true, removeTagType(KM_TAG_ALGORITHM), ASN1Integer(KM_ALGORITHM_AES.toLong()))
// add key size used
val keySize =
DERTaggedObject(true, removeTagType(KM_TAG_KEY_SIZE), ASN1Integer(256L))
// add authorized block modes
val allBlockModes = ASN1EncodableVector()
allBlockModes.add(ASN1Integer(KM_MODE_ECB))
allBlockModes.add(ASN1Integer(KM_MODE_CBC))
val blockModeSet = DERSet(allBlockModes)
val blockMode =
DERTaggedObject(true, removeTagType(KM_TAG_BLOCK_MODE), blockModeSet)
// add authorized paddings
val allPaddings = ASN1EncodableVector()
allPaddings.add(ASN1Integer(KM_PAD_PKCS7))
allPaddings.add(ASN1Integer(KM_PAD_NONE))
val paddingSet = DERSet(allPaddings)
val padding =
DERTaggedObject(true, removeTagType(KM_TAG_PADDING), paddingSet)
val noAuthRequired =
DERTaggedObject(true, removeTagType(KM_TAG_NO_AUTH_REQUIRED), DERNull.INSTANCE)
// Build sequence
val allItems = ASN1EncodableVector()
allItems.add(purpose)
allItems.add(algorithm)
allItems.add(keySize)
allItems.add(blockMode)
allItems.add(padding)
allItems.add(noAuthRequired)
return DERSequence(allItems)
}
}
As noted in the comments, this code is assuming that the external master key is an AES 256 key. You can of course use another format, but then you would need to modify relevant parameters accordingly. And I would like to warn you that this might be tedious: if the things you specified while wrapping the key doesn’t match the things you used while generating the key, it will crash when you try to save this wrapped master key to AKS. And unfortunately, error messages are not helpful and can be pretty frustrating! But at least now you have a working example which you can get as a starting point. I hope this helps you save some precious time!
Thanks for reading and happy coding!
(Special thanks to my colleague Iulia Stana for reviewing this article and for the tons of things she taught me about encryption and security on the way.)
Curious about our latest updates?
Stay up-to-date in the digital landscape with our newsletter. Enrol and get the latest updates in your mail
Looking for trusted expertise in mobile security?
From key management to full mobile app development, we help you design and implement secure, scalable technology.