Engineering

There is no shared way to store a secret, and that is the correct answer

Keystore and Keychain hold a key your process cannot read.

MobileByteSensei 12 min read
kmpsecuritystorage

TL;DR

  • A shared SecureStore that encrypted a refresh token with AES-GCM and kept the key in a ByteArray — portable, correct, and extractable in 39 seconds, with no root, by reading the same preferences file the ciphertext was in.
  • Keystore and the Secure Enclave do not give you a key. They hold one your process cannot read and perform operations with it on request. That difference is the entire product.
  • So the portable interface is not encrypt(bytes, key) — it is encrypt(bytes), with the key never appearing in shared code at all, and one target declaring that it cannot deliver the guarantee.
  • What reading on buys: the extraction, what each platform actually offers, and why the web target has to store nothing rather than store it weakly.

The case this post is about

A refresh token kept across launches, with encryption written once:

// commonMain/kotlin/secure/SecureStore.kt
class SecureStore(private val prefs: Prefs) {

    /** Derived once and cached, because deriving per call was slow. */
    private val key: ByteArray by lazy { loadOrCreateKey() }

    fun put(name: String, value: String) {
        val nonce = secureRandomBytes(12)
        val cipher = aesGcmEncrypt(key, nonce, value.encodeToByteArray())
        prefs.putString(name, (nonce + cipher).toBase64())
    }

    fun get(name: String): String? = prefs.getString(name)?.let { stored ->
        val raw = stored.fromBase64()
        aesGcmDecrypt(key, raw.copyOfRange(0, 12), raw.copyOfRange(12, raw.size)).decodeToString()
    }

    private fun loadOrCreateKey(): ByteArray =
        prefs.getString("sk")?.fromBase64()
            ?: secureRandomBytes(32).also { prefs.putString("sk", it.toBase64()) }
}

One implementation, five targets, AES-GCM with a 256-bit key from a platform CSPRNG. Every test passed, every round trip worked, and a security review had signed off on the algorithm.

The symptom

A penetration test, one finding, with a reproduction:

F-01  Refresh token recoverable from device storage.
      The encryption key is stored alongside the ciphertext in the same
      preferences file. Decryption requires no privileged access beyond
      read access to the app's data directory.
$ adb shell run-as com.example.app cat shared_prefs/app_prefs.xml | grep '"sk"'
<string name="sk">k7Jx2fQ8…</string>
$ # and the token, in the same file
<string name="refresh_token">AAAAAA…</string>

The key was in the same file as the ciphertext. Which is not an encryption bug — AES-GCM did exactly what it should — it is that loadOrCreateKey() had to put the key somewhere a ByteArray could come back from, and the only portable somewhere was the storage it was protecting.

Even with the key moved to a separate file, the finding stands: anything the app can read, an attacker with the app's data directory can read.

The extraction reproduced step by step in thirty-nine seconds, reading the key from the same preferences file as the ciphertext with no privileged access beyond the application data directory

Why the obvious fix failed

The first fix was to derive the key rather than store it:

private val key: ByteArray by lazy {
    pbkdf2(password = deviceId(), salt = APP_SALT, iterations = 100_000)
}

deviceId() is readable by any app on the device on older Android versions, and on iOS it is stable per vendor per install — so the key is a function of inputs an attacker also has. A hundred thousand iterations of PBKDF2 over a known input is a hundred thousand iterations an attacker performs once.

The second fix was a key derived from a user passcode, which is genuinely stronger and changes the product:

private fun key(passcode: String) = pbkdf2(passcode, salt, 100_000)

That requires the user to enter something to refresh a token in the background, which is the thing the refresh token exists to avoid. We do use this shape for an explicit "export my data" flow, where a prompt is acceptable.

The third fix was to obfuscate the key's storage — split it, XOR it with a build-time constant, hide it in a resource. I spent half a day on this before stating the obvious out loud: every one of those is a transformation the app performs at runtime, so the inputs are all in the binary, and the binary is on the attacker's device. It raises the time to extract and not the capability required.

The actual mechanism

Hardware-backed key storage does not store a key for you. It stores a key you cannot retrieve and exposes operations that use it.

Android's Keystore generates the key inside a TEE or StrongBox. You get a handle. Cipher initialised with that handle performs the encryption in the secure environment; the key material never enters your process's address space. A memory dump contains ciphertext and a handle.

iOS's Keychain with kSecAttrTokenIDSecureEnclave is the same shape. The key is generated in the Enclave, and SecKeyCreateEncryptedData asks the Enclave to use it. There is no API that returns the bytes, deliberately.

Desktop keyrings are weaker and still better than a file. The OS holds the secret, unlocks it with the user's login credential, and your process asks for it — so the material does enter your process, but it is protected at rest by something outside your application.

And a browser has nothing equivalent. CryptoKey with extractable: false prevents your JavaScript from reading the key, and the key lives in IndexedDB under the origin — so it is protected from your own code and not from anyone with access to the profile. There is no hardware boundary available to a page.

So the operation a portable interface can offer is not "give me a key" but "encrypt this", and the difference is what the hardware is selling:

  a key in a ByteArray        in your address space       a memory dump has it
  a Keystore handle           in the TEE                   a memory dump has a handle
  a Keychain Enclave ref      in the Enclave               a memory dump has a reference
  a browser CryptoKey         in the browser's storage     the profile has it

And the shared-code version is strictly worse than each platform's own, which is the uncomfortable part: sharing the implementation gave us one consistent weakness instead of four strong implementations. Portability was achieved by targeting the lowest capability, and the lowest capability is "a file".

What a memory dump contains under each key store, with a portable byte array yielding the key itself while a hardware-backed store yields only a handle or a reference the process cannot resolve

The fix

Share the interface and never the key. The shared API takes plaintext and returns ciphertext; each actual uses its platform's own key store; the one platform that cannot deliver the guarantee says so and the app stores nothing there.

// commonMain/kotlin/secure/SecureStore.kt
//
// No key parameter, no key return, nowhere for key material to exist in shared code. That is
// the whole design: an interface that could accept a key is one a platform can satisfy with a
// file, and five implementations will then do the easy thing.
interface SecureStore {

    /** Backed by hardware the process cannot read from, or absent. */
    val backing: Backing

    suspend fun put(name: String, value: String): Boolean
    suspend fun get(name: String): String?
    suspend fun remove(name: String)

    enum class Backing {
        /** Key in a TEE or Secure Enclave. Not extractable by any process. */
        Hardware,
        /** OS-held, unlocked by the user's login. Material enters the process in use. */
        OsKeyring,
        /** No boundary available. Callers must not persist secrets here. */
        None,
    }
}
// androidMain
class AndroidSecureStore(
    private val ctx: Context,
    private val prefs: Prefs,          // ciphertext only — the key never comes back here
) : SecureStore {

    // Hardware either way, and deliberately NOT two values. StrongBox is a discrete secure
    // element and the TEE is not, so these are different strengths — but neither lets the key
    // into this process, which is the only distinction a CALLER can act on. Splitting the enum
    // would ask every call site to make a decision it has no basis for. The cost is recorded
    // in the limits section: one value covers two strengths and we have not measured the split.
    override val backing = Backing.Hardware

    // Generated IN the Keystore. There is no variable here that holds key material, which is
    // the property the shared version could not have — `.also { prefs.put(it) }` was only
    // possible because the key was a value.
    private fun secretKey(): SecretKey {
        val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }

        return KeyGenerator.getInstance("AES", "AndroidKeyStore").apply {
            init(
                KeyGenParameterSpec.Builder(ALIAS, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
                    .setBlockModes(BLOCK_MODE_GCM)
                    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
                    // StrongBox where present: a discrete secure element rather than the
                    // TEE. Requested, not required — many devices lack it and failing the
                    // whole store on its absence would be worse than the TEE.
                    .setIsStrongBoxBacked(hasStrongBox())
                    .build()
            )
        }.generateKey()
    }

    override suspend fun put(name: String, value: String): Boolean = runCatching {
        val c = Cipher.getInstance("AES/GCM/NoPadding").apply { init(ENCRYPT_MODE, secretKey()) }
        // The IV is GENERATED BY the cipher, not by us. A caller-chosen IV reused across two
        // puts under one GCM key is a catastrophic failure, and the platform's own IV removes
        // the opportunity to make it.
        prefs.putString(name, (c.iv + c.doFinal(value.encodeToByteArray())).toBase64())
        true
    }.getOrElse { logger.warn("keystore put failed: ${it.message}"); false }
}
// wasmJsMain — the honest actual
class WebSecureStore : SecureStore {
    override val backing = Backing.None

    // Refuses rather than storing weakly. An implementation that encrypted with a
    // non-extractable CryptoKey would LOOK like the others and offer a different guarantee,
    // which is how one consistent weakness gets shipped as portability.
    override suspend fun put(name: String, value: String): Boolean = false
    override suspend fun get(name: String): String? = null
    override suspend fun remove(name: String) = Unit
}

And the call site, where Backing.None changes the product decision rather than being absorbed:

// commonMain/kotlin/auth/TokenStore.kt
class TokenStore(private val secure: SecureStore, private val session: InMemorySession) {

    suspend fun persist(refresh: RefreshToken) {
        when (secure.backing) {
            Backing.Hardware, Backing.OsKeyring ->
                if (!secure.put(KEY, refresh.value)) session.hold(refresh)   // fall back in-memory

            // No persistence at all. The web session lives in memory and ends when the tab
            // does, which is a worse experience and an honest one — and it is why the web
            // client's access tokens are short-lived with a server-side refresh cookie.
            Backing.None -> session.hold(refresh)
        }
    }
}

And the output it produced, re-running the extraction:

$ adb shell run-as com.example.app cat shared_prefs/app_prefs.xml | grep -c '"sk"'
0
$ adb shell run-as com.example.app ls shared_prefs/
app_prefs.xml                 # ciphertext only
$ # and the key, from the one place it could be
$ adb shell run-as com.example.app ls /data/data/com.example.app/ -R | grep -ci keystore
0

No key in the app's data directory, because there is no key in the app's data directory to find.

How we proved it

That the original key was extractable, with the time it took — because "theoretically recoverable" and "40 seconds" are different findings:

  step                                                        time
  adb shell run-as, read shared_prefs/app_prefs.xml            6 s
  base64-decode the "sk" value                                 2 s
  decrypt the token with it (openssl, aes-256-gcm)            31 s
  total                                                       39 s

No root required on a debuggable build; on a release build, root or a backup extraction, which is minutes rather than seconds.

That the obfuscation attempts raised time and not capability. Measured, because half a day went into them:

  key storage                            extraction time   capability required
  plain, same prefs file                        39 s        read the data dir
  separate file                                 44 s        read the data dir
  XOR with a build constant                     11 min      read the data dir + read the binary
  split across three resources                  26 min      read the data dir + read the binary
  Keystore                                    not achieved  defeat the TEE

The first four rows are the same attack with more steps. The last row is a different attack.

That the key never enters the process under Keystore. The measurement that distinguishes this from a better hiding place:

$ # a heap dump during an active encrypt, searched for the key's bytes
  original implementation      key bytes found at 3 offsets
  Keystore implementation      key bytes found at 0 offsets
  (the SecretKey object is present; it holds an alias and no material)

That Backing.None had to refuse rather than degrade. This was argued, and the fixture settled it — a web implementation using a non-extractable CryptoKey:

  non-extractable CryptoKey in IndexedDB
    readable by page JavaScript?            no
    readable from the browser profile?      YES — IndexedDB is a file
    reported Backing                        Hardware?  ← what it would have claimed

It genuinely protects against one threat and not the one the interface implies, and an actual that reports a stronger backing than it has is worse than one that refuses — because the caller's when would then persist a refresh token into a readable file.

That the platform-generated IV is doing real work. A fixture with a caller-supplied IV, reused:

$ ./gradlew :secure:test --tests '*ReusedNonceIsCatastrophic*'
  two plaintexts, same key, same nonce
  → XOR of the ciphertexts equals XOR of the plaintexts
  → the authentication key is recoverable
PASSED (the test asserts this IS the outcome, as a reason the API forbids it)

SecureStore.put itself was fine: it called secureRandomBytes(12) per put and exposed no nonce parameter. The problem was one level down — aesGcmEncrypt(key, nonce, bytes) was internal-but-reachable, and a second feature had called it directly with a nonce it derived once and held. Two plaintexts, one key, one nonce. The portable interface was safe and the portable primitive underneath it was not, which is why the fix removes the key from shared code entirely rather than documenting how to use it.

What I cannot tell you is how StrongBox availability is distributed across our install base. We request it and fall back to the TEE, and the fallback is silent by design — a device without StrongBox is not a device we want to refuse. So Backing.Hardware covers two meaningfully different strengths and I have not instrumented the split.

And an honest product cost. The web client no longer persists a session, so web users re-authenticate on every new tab. That is worse, it was a deliberate decision made once with the backing enum in front of us, and the mitigation — short access tokens plus a server-side HttpOnly refresh cookie — is a different architecture rather than a client-side fix.

Five key-storage approaches measured by extraction time and by the capability an attacker needs, where four of them are the same attack with more steps and only the hardware-backed one requires a different attack

What this generalizes to

Some capabilities are not implementations you can port but guarantees a platform provides, and an interface shaped like the implementation will be satisfiable everywhere at the weakest level. The test for any security abstraction is whether its signature could be implemented badly: a function that accepts or returns a key can always be backed by a file, so five implementations will eventually include one that is. Shape the interface around the operation instead, and let the platform that cannot provide the guarantee say so in the type rather than in a comment.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS