Eine Sicherheitsschicht für drei Plattformen mit Kotlin Multiplatform
Warum NexusPass seine Domänen- und Datenschicht zwischen Android, Desktop und iOS teilt – wo die Grenze zwischen geteiltem und plattformspezifischem Code verläuft und was das kostet.
Plattformübergreifende Frameworks werden meist über Kosten verkauft: einmal schreiben, dreimal ausliefern, ein Drittel der Zeit aufwenden.
Für NexusPass war das nicht das Argument. Das Argument war Korrektheit: Ein Passwortmanager enthält wenig Code, der exakt richtig sein muss – und drei unabhängige Implementierungen davon bedeuten drei Gelegenheiten, ihn falsch zu machen, plus die Beinahe-Gewissheit, dass eine Korrektur in einer Variante landet und in den anderen vergessen wird.
Die Schichten und wo die Grenze verläuft
Die Codebasis ist so aufgeteilt wie viele Kotlin-Anwendungen, mit einer bewussten Regel darüber, auf welcher Seite der Grenze jede Schicht liegt.
┌─────────────────────────────────────────┐
│ UI Compose Multiplatform │ geteilt
├─────────────────────────────────────────┤
│ ViewModel Zustand + Coroutines │ geteilt
├─────────────────────────────────────────┤
│ Use Cases eine pro Nutzeraktion │ geteilt
├─────────────────────────────────────────┤
│ Repository die einzige Datentür │ geteilt
├─────────────────────────────────────────┤
│ Daten SQLite, Serialisierung │ geteilt
├─────────────────────────────────────────┤
│ Sicherheit Keystore, Biometrie │ plattform
└─────────────────────────────────────────┘Nur die unterste Schicht ist plattformspezifisch, und auch nur dort, wo sich die Plattformen tatsächlich unterscheiden. Android Keystore und iOS Keychain sind nicht austauschbar; ein Desktop-Build hat weder das eine noch das andere und braucht wieder eine eigene Antwort. Alles oberhalb dieser Grenze wird einmal geschrieben.
Diese Umkehrung sollte man ausdrücklich festhalten, denn sie ist das Gegenteil dessen, wohin plattformübergreifende Projekte üblicherweise driften: Der sicherheitskritische Teil ist der geteilte, und der plattformspezifische Teil ist die dünne Hülle darum.
expect / actual, sparsam eingesetzt
Kotlins Mechanismus für Plattformunterschiede ist eine Deklaration im gemeinsamen Code mit einer Implementierung pro Ziel.
// commonMain — die Form, gegen die der geteilte Code programmiert
expect class SecureKeyStore {
suspend fun store(alias: String, key: ByteArray)
suspend fun retrieve(alias: String): ByteArray?
suspend fun delete(alias: String)
}// androidMain — gestützt auf den 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) { /* … */ }
}Es ist verlockend, ständig zu expect/actual zu greifen. Widerstehen Sie.
Jede erwartete Deklaration ist eine Stelle, an der drei Implementierungen still
auseinanderlaufen können – genau der Fehlerfall, den die gemeinsame Codebasis
verhindern sollte.
Die Regel, die sich bewährt hat: expect/actual nur für Fähigkeiten, die
der Plattform gehören. Sicherer Speicher, biometrische Abfragen,
Dateipfade, Lebensdauer der Zwischenablage. Alles, was sich in reinem Kotlin
ausdrücken lässt, bleibt in commonMain – auch wenn eine Plattform-API
geringfügig bequemer wäre.
Für Abhängigkeiten, die lediglich plattformspezifische Verdrahtung brauchen und kein plattformspezifisches Verhalten, ist einfache Konstruktorinjektion besser: Der Typ bleibt im gemeinsamen Code, das Plattformdetail wandert an die Composition Root.
Was „geteilt“ in Tests einbringt
Der praktische Gewinn zeigt sich in der Testsuite, nicht in der Zeilenzahl.
Weil Use Cases und Repository reines Kotlin sind und keine Plattformtypen ihre
Grenzen überschreiten, sind sie in commonTest testbar – ohne Emulator, ohne
Simulator, ohne Gerät:
class UnlockVaultTest {
@Test
fun `falsches Passwort legt keine Eintraege offen`() = runTest {
val vault = FakeVault(entries = 3)
val unlock = UnlockVault(vault, FakeKeyStore())
val result = unlock(password = "incorrect")
assertTrue(result.isFailure)
assertEquals(0, vault.decryptedReads)
}
}Diese Tests laufen in Sekunden auf jedem Ziel – und, das ist der springende Punkt, sie prüfen auf allen dreien dasselbe Verhalten. Eine Regression im Entsperrpfad kann nicht nach iOS ausgeliefert werden, während Android korrekt bleibt, denn es gibt nur einen Entsperrpfad.
Compose Multiplatform – und Ehrlichkeit dazu
Die UI-Schicht zu teilen ist eine größere Festlegung als die Domänenschicht zu teilen und verdient einen skeptischeren Blick.
Was gut funktioniert. Das Design-System – Buttons, Felder, Listenzeilen, Tresorliste, Eintragsdetails – wird tatsächlich einmal geschrieben. Layout, Zustandsverwaltung und Theming verhalten sich identisch. Für eine Anwendung aus eigenen Komponenten statt plattformüblicher Bedienelemente ist das eine große, echte Ersparnis.
Was pro Plattform Aufmerksamkeit braucht. Scrollverhalten, Textauswahl, Tastaturhandling, Zurück-Navigation und die Gestalt von Systemdialogen fallen Nutzenden auf, sobald sie subtil falsch sind. Sie sind nicht kostenlos – und den Desktop- oder iOS-Build als „Android mit anderer Fenstergröße“ zu behandeln ist der Weg, auf dem sich eine App auf zwei ihrer drei Ziele fremd anfühlt.
Was plattformspezifisch bleibt. Biometrische Abfragen, Teilen-Dialoge und die Autofill-Integrationen sind Plattform-UI. Sie sollen aussehen wie die Plattform, weil sie die Plattform sind.
Die Kosten, klar benannt
Zwei sind für alle erwähnenswert, die vor derselben Entscheidung stehen.
Die iOS-Interoperabilität hat Kanten. Die Objective-C-Brücke von Kotlin/Native ist gut, aber nicht unsichtbar. Manche APIs brauchen einen handgeschriebenen Swift-Shim, und die Build-Zeiten der nativen Ziele sind länger als die der JVM-Ziele.
Das Ökosystem ist kleiner. Für die üblichen Fälle gibt es multiplattformfähige Bibliotheken – Serialisierung, Coroutines, SQLite, Datum/Zeit –, doch außerhalb dieses Kerns wird die Auswahl schnell schmal, und „unterstützt das alle meine Ziele?“ wird zu einem echten Filter bei der Auswahl von Abhängigkeiten.
Keines der beiden entkräftet das ursprüngliche Argument. Für eine Anwendung, deren wichtigster Code aus einigen Tausend Zeilen Tresor- und Schlüsselhandling besteht, ist es einen längeren iOS-Build wert, diesen Code einmal zu schreiben und einmal zu testen.
Worum es im nächsten Beitrag geht
Die Form der Datenschicht über Plattformen zu teilen ist das eine Problem; zu entscheiden, was diese Schicht ohne Netzwerk eigentlich tut, ein anderes. Darum geht es in Offline-first.