Engineering

Hashing a password in shared code is the one thing you should not share

Each platform has a vetted crypto provider. A pure-Kotlin implementation has none of their review.

MobileByteSensei 9 min read
kmpsecurityarchitecture

TL;DR

The sharing instinct is right almost everywhere and wrong for cryptography. A pure-Kotlin SHA-256 or AES implementation compiles on five targets and carries none of the properties you are actually buying from a crypto library: constant-time operations, a vetted random source, hardware-backed key storage, and years of adversarial review.

Each platform already ships a reviewed provider. Put the interface in commonMain and the implementation on the platform — and before that, check whether the operation belongs on the client at all. Password hashing does not.

The case this post is about

An app needing three things that all get labelled "crypto":

  1. A password sent to a login endpoint.
  2. A cached API response, encrypted at rest.
  3. A content hash for cache-key deduplication.

The shared-everything version, which is what a pure-Kotlin crypto library invites:

// commonMain — one implementation, five targets
object Crypto {
    fun sha256(bytes: ByteArray): ByteArray = PureKotlinSha256.digest(bytes)
    fun hashPassword(pw: String, salt: ByteArray): ByteArray =
        PureKotlinPbkdf2.derive(pw.encodeToByteArray(), salt, iterations = 10_000)
    fun encrypt(key: ByteArray, plaintext: ByteArray): ByteArray =
        PureKotlinAesGcm.encrypt(key, plaintext)
    fun randomKey(): ByteArray = ByteArray(32).also { Random.nextBytes(it) }
}

Compiles everywhere. Round-trips correctly in tests. Three of those four functions are serious problems, and they are serious for different reasons.

The symptom

Not a crash — this is the class of defect that works until someone attacks it. The findings came from a review rather than from a bug report.

Random.nextBytes is not cryptographically secure. kotlin.random.Random is a general-purpose PRNG. It is seeded from the clock on some targets, it is predictable from observed output, and it is not documented as a CSPRNG anywhere. Every AES key the app generated came from it:

fun randomKey(): ByteArray = ByteArray(32).also { Random.nextBytes(it) }   // predictable

The key had nowhere safe to live. An AES key in a ByteArray in shared code is a key in process memory and then in whatever storage the shared layer could reach — not the Keystore, not the Keychain, not hardware-backed anything.

Client-side password hashing bought nothing. The PBKDF2 output was sent to the server, which stored it. That makes the derived value the password — anyone who intercepts it can replay it — and 10,000 iterations is far below current guidance anyway.

And the pure-Kotlin AES was not constant-time. A table-driven implementation in Kotlin has data-dependent memory access, which is the textbook setup for a timing side channel. I could not demonstrate an exploit, and that is exactly the point: the property is unverifiable by testing, and a reviewed implementation is where it comes from.

Why the obvious fix failed

"Use a well-known pure-Kotlin crypto library." Better than hand-rolling, and it does not solve the two structural problems: it has no access to hardware-backed key storage, and the random source question remains per platform. Good libraries in this space are explicit about both.

"Increase the PBKDF2 iterations." Tuning a parameter on an operation that should not be on the client. I did this first, which is the shape of a lot of security work — optimising the wrong layer.

"Encrypt the key with another key." Key-wrapping is real and needs a root key that is actually protected. Without hardware-backed storage at the bottom, this is a chain of keys all equally reachable.

"Use SecureRandom in commonMain." It does not exist there; it is a JVM class. Which is the useful signal — the thing you need is per-platform by nature.

The actual mechanism

Three properties you are buying from a crypto provider, and none of them is "the algorithm".

Hardware-backed key storage. Android's Keystore and iOS's Secure Enclave can hold a key that your process cannot read — you ask the OS to perform an operation with it. A key in a ByteArray is a key an attacker with memory access has.

A vetted random source. SecureRandom, SecRandomCopyBytes, crypto.getRandomValues. Each is a documented CSPRNG backed by OS entropy. kotlin.random.Random is none of these and does not claim to be.

That middle one deserves emphasis because it is the failure with no symptom. A key derived from kotlin.random.Random encrypts correctly, decrypts correctly, round-trips through every test you write, and produces ciphertext that looks exactly like ciphertext. Nothing distinguishes it from a secure implementation except that the keyspace is searchable — and that is not observable from inside the system at all, at any point, until someone searches it.

Which is the general shape of this boundary: cryptographic defects are silent by construction. A wrong IV, a reused nonce, a non-constant-time comparison and a weak key all produce output that passes every functional test, because functional correctness and cryptographic soundness are independent properties. That is why "it works" carries no information here, and why the answer is to use the platform's audited implementation rather than to test your own more carefully.

Constant-time implementation and review. Platform providers go through audit and have known side-channel characteristics. A Kotlin implementation compiled to five different backends has five different timing profiles, none of them analysed.

And one question that sits above all three:

Where does the operation belong at all? Password hashing belongs on the server, because the server is the only party that can treat the input as a secret and the output as a verifier. A client that hashes and sends is just sending a different password.

Three properties a platform crypto provider supplies that portable code cannot: a key held in hardware the process cannot read, a documented CSPRNG backed by OS entropy, and a reviewed constant-time implementation — above the prior question of whether the operation belongs on the client at all, where password verification belongs to the server

Shared code gives you one implementation on every target. For cryptography that is the opposite of what you want: you want each target's reviewed, hardware-backed one. Share the interface, never the primitive.

The fix

Three different answers for the three operations, which is the actual lesson — they were never one problem.

1. Password handling moves to the server. The client sends the password over TLS and stores nothing:

// commonMain — no hashing on the client at all
suspend fun login(email: String, password: String): LoginResult =
    api.login(email, password)          // TLS. The server hashes with argon2id.

The deleted code is the fix. Client-side hashing was adding a step, not a protection.

2. Encryption at rest becomes a platform-backed key store. The interface says what is needed; the key never enters shared code:

// commonMain
interface SecureBox {
    /** Encrypts with a key held by the platform. The key never crosses this boundary. */
    suspend fun seal(plaintext: ByteArray): ByteArray
    suspend fun open(sealed: ByteArray): ByteArray?
}
// androidMain — Keystore-held AES key, non-exportable
actual class AndroidSecureBox(private val alias: String) : SecureBox {
    private fun key(): SecretKey =
        (KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
            .getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.secretKey
            ?: generate()

    private fun generate(): SecretKey = KeyGenerator.getInstance("AES", "AndroidKeyStore").apply {
        init(KeyGenParameterSpec.Builder(alias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
            .setBlockModes(BLOCK_MODE_GCM)
            .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
            .build())
    }.generateKey()
    …
}
// iosMain — Keychain item with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
// wasmJsMain — no hardware-backed store exists in a browser.
actual fun secureBox(): SecureBox =
    error("no platform secure store in a browser — do not cache sensitive data here")

That wasm error is deliberate and is the most useful line in the file. A browser has no hardware-backed key store; pretending otherwise with an IndexedDB-stored key would be security theatre. Refusing makes the architectural consequence explicit: that data does not get cached on web.

3. The content hash stays shared, because it is not a security operation. This is the distinction worth drawing carefully:

// commonMain — a cache key. NOT a security primitive.
fun cacheKey(bytes: ByteArray): String = fnv1a64(bytes).toHexString()

Deduplicating cache entries needs collision resistance against accident, not against an adversary. A fast non-cryptographic hash is the correct tool, and calling it cacheKey rather than sha256 stops someone reaching for it later when they need the real thing.

And where you do need randomness in shared code, make it a platform seam:

// commonMain
expect fun secureRandomBytes(size: Int): ByteArray

// androidMain / jvmMain  → SecureRandom
// iosMain                → SecRandomCopyBytes
// wasmJsMain             → crypto.getRandomValues

How we proved it

That the old random source was predictable. The most important measurement, and the easiest:

// commonTest — a sanity check, not a proof of cryptographic strength
@Test
fun randomSourceIsNotSeededIdentically() {
    val a = ByteArray(32).also { Random(0).nextBytes(it) }
    val b = ByteArray(32).also { Random(0).nextBytes(it) }
    assertContentEquals(a, b)        // same seed → same "key". By design.
}

That test passes, and its passing is the finding: a PRNG that reproduces its output from a seed is working correctly and is the wrong tool for a key. No exploit needed — the API contract says it.

That keys are now non-exportable. An assertion about the property, which is what you can actually check:

Android, after the change
$ adb shell dumpsys … / KeyStore entry inspection
  alias=cache_box  origin=GENERATED  exportable=false  hardwareBacked=true

before
  key material present in app-private file, readable with root

Asserting exportable=false is the honest check. "Is the encryption secure" is not testable; "can this process read the key" is.

That the round-trip still works on every supported target:

// commonTest
@Test
fun sealOpenRoundTrip() = runTest {
    val box = secureBox()
    val sealed = box.seal("secret".encodeToByteArray())
    assertEquals("secret", box.open(sealed)!!.decodeToString())
    assertFalse(sealed.decodeToString().contains("secret"))   // not plaintext
}
androidUnitTest        PASSED (Robolectric Keystore)
iosSimulatorArm64Test  PASSED
jvmTest(desktop)       PASSED (OS keyring)
wasmJsTest             SKIPPED — secureBox() refuses by design

That last row is the design being honest rather than a gap.

Two limits, stated plainly. I verified key non-exportability and round-tripping; I did not audit the platform providers or measure timing characteristics, and nothing here constitutes a security review. And this moves password verification to the server, which means the server's hashing parameters are now the thing that matters — a decision this post does not cover and which is where the real work is.

What this generalizes to

Portability and trust pull in opposite directions for security primitives. Everywhere else, one implementation on every target is the goal. Here, each platform's reviewed and hardware-backed implementation is the goal, and a portable one is a downgrade wearing the shape of an upgrade.

Ask where the operation belongs before asking how to implement it. The password work was deleted, not ported. A surprising amount of client-side security code is solving a problem that belongs to a party that can actually keep a secret.

Name things by their threat model. Crypto.sha256 invites use as a security primitive; cacheKey does not. A fast hash for deduplication and a cryptographic hash are different tools, and the name is what stops the wrong one being reached for in six months.

Refusing is a valid implementation. The wasm actual that throws is better than an insecure one that returns, because it surfaces the architectural consequence at build time. An implementation that cannot provide the property should say so.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

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

Follow by RSS