Sharing a Security Layer Across Three Platforms with Kotlin Multiplatform
Why NexusPass shares its domain and data layers across Android, desktop and iOS — where the line between shared and platform code falls, and what it costs.
غير مترجم بعد
لم تُترجم هذه الصفحة إلى العربية بعد. أنت تقرأ النسخة الإنجليزية.
EnglishCross-platform frameworks are usually sold on cost. Write once, ship three times, spend a third as long.
That was not the argument for NexusPass. The argument was correctness: a password manager has a small amount of code that must be exactly right, and three independent implementations of it means three chances to get it wrong — plus the near-certainty that a fix lands in one and is forgotten in the others.
The layers, and where the line falls
The codebase is split the way a lot of Kotlin applications are, with one deliberate rule about which side of the boundary each layer sits on.
┌─────────────────────────────────────────┐
│ UI Compose Multiplatform │ shared
├─────────────────────────────────────────┤
│ ViewModel state + coroutines │ shared
├─────────────────────────────────────────┤
│ Use cases one per user action │ shared
├─────────────────────────────────────────┤
│ Repository the only door to data │ shared
├─────────────────────────────────────────┤
│ Data SQLite, serialisation │ shared
├─────────────────────────────────────────┤
│ Security keystore, biometrics │ platform
└─────────────────────────────────────────┘Only the bottom layer is per-platform, and only where the platform genuinely differs. The Android Keystore and the iOS Keychain are not interchangeable; a desktop build has neither and needs a different answer again. Everything above that boundary is written once.
That inversion is worth stating explicitly, because it is the opposite of how cross-platform projects usually drift: the security-critical logic is the shared part, and the platform-specific part is the thin shell around it.
expect / actual, used sparingly
Kotlin's mechanism for platform differences is a declaration in common code with an implementation per target.
// commonMain — the shape the shared code programs against
expect class SecureKeyStore {
suspend fun store(alias: String, key: ByteArray)
suspend fun retrieve(alias: String): ByteArray?
suspend fun delete(alias: String)
}// androidMain — backed by the Android Keystore
actual class SecureKeyStore(private val context: Context) {
actual suspend fun store(alias: String, key: ByteArray) { /* … */ }
actual suspend fun retrieve(alias: String): ByteArray? { /* … */ }
actual suspend fun delete(alias: String) { /* … */ }
}It is tempting to reach for expect/actual constantly. Resist it. Every
expected declaration is a place where three implementations can quietly
diverge — exactly the failure mode the shared codebase was meant to prevent.
The rule that has held up: expect/actual only for capabilities the
platform owns. Secure storage, biometric prompts, file locations, clipboard
lifetimes. Anything expressible in pure Kotlin stays in commonMain, even when
a platform API would be marginally more convenient.
For dependencies that only need platform wiring rather than platform behaviour, plain constructor injection is better: it keeps the type in common code and moves the platform detail to the composition root.
What "shared" gets you in tests
The practical payoff shows up in the test suite rather than the line count.
Because use cases and the repository are pure Kotlin with no platform types
crossing their boundaries, they are testable in commonTest with no emulator,
no simulator and no device:
class UnlockVaultTest {
@Test
fun `wrong password does not expose entries`() = runTest {
val vault = FakeVault(entries = 3)
val unlock = UnlockVault(vault, FakeKeyStore())
val result = unlock(password = "incorrect")
assertTrue(result.isFailure)
assertEquals(0, vault.decryptedReads)
}
}Those tests run in seconds on every target, and — this is the part that matters — they assert the same behaviour on all three. A regression in the unlock path cannot ship to iOS while Android stays correct, because there is one unlock path.
Compose Multiplatform, and being honest about it
Sharing the UI layer is a bigger commitment than sharing the domain layer, and it deserves a more sceptical look.
What works well. The design system — buttons, fields, list rows, the vault list, entry detail — is genuinely written once. Layout, state handling and theming behave identically. For an application built from its own components rather than platform-standard controls, this is a large, real saving.
What needs attention per platform. Scroll physics, text selection behaviour, keyboard handling, back-navigation semantics and the shape of system dialogs are all things users notice when they are subtly wrong. They are not free, and treating the desktop or iOS build as "Android with a different window size" is how a cross-platform app comes to feel foreign on two of its three targets.
What stays platform-specific. Biometric prompts, share sheets and the autofill integrations are platform UI. They should look like the platform, because they are the platform.
The costs, plainly
Two are worth naming for anyone weighing the same decision.
iOS interop has edges. Kotlin/Native's Objective-C bridge is good, but not invisible. Some APIs need a hand-written Swift shim, and build times for the native targets are longer than the JVM ones.
The ecosystem is smaller. Multiplatform-ready libraries exist for the common cases — serialisation, coroutines, SQLite, date/time — but outside that core the choice narrows quickly, and "does it support all my targets?" becomes a real filter on dependency selection.
Neither undermines the original argument. For an application whose most important code is a few thousand lines of vault and key handling, writing that once and testing it once is worth a longer iOS build.
What the next post covers
Sharing the shape of the data layer across platforms is one problem; deciding what that layer actually does when there is no network is another. That is the subject of offline-first data.