69 lines
3.5 KiB
Markdown
69 lines
3.5 KiB
Markdown
|
|
# shade-android-keystore
|
||
|
|
|
||
|
|
Android-specific bindings for `shade-android`. Lives as a sibling Gradle module so the JVM-only protocol code can keep running in CI without an Android SDK install.
|
||
|
|
|
||
|
|
Provides:
|
||
|
|
|
||
|
|
- **`KeystoreMasterKey`** — hardware-backed AES-256-GCM master key in the Android Keystore. Optionally biometric-gated (BIOMETRIC_STRONG only — Class 3 assurance), StrongBox-backed when available, invalidated on new biometric enrollment.
|
||
|
|
- **`BiometricUnlock`** — coroutine wrapper around `BiometricPrompt` for unlocking a `Cipher` instance bound to the keystore key. Throws `BiometricCancelledException` / `BiometricFailedException` so callers can handle the auth flow without writing custom callbacks.
|
||
|
|
- **`KeystoreStorage`** — `StorageProvider` implementation that persists session/identity/prekey state to `SharedPreferences`, each row encrypted under the keystore key with the row's preference key bound as AAD.
|
||
|
|
|
||
|
|
## Usage
|
||
|
|
|
||
|
|
```kotlin
|
||
|
|
import androidx.fragment.app.FragmentActivity
|
||
|
|
import no.zyon.shade.ShadeSessionManager
|
||
|
|
import no.zyon.shade.crypto.TinkProvider
|
||
|
|
import no.zyon.shade.keystore.BiometricUnlock
|
||
|
|
import no.zyon.shade.keystore.KeystoreStorage
|
||
|
|
|
||
|
|
class MyActivity : FragmentActivity() {
|
||
|
|
private val crypto = TinkProvider()
|
||
|
|
private lateinit var storage: KeystoreStorage
|
||
|
|
private lateinit var manager: ShadeSessionManager
|
||
|
|
|
||
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||
|
|
super.onCreate(savedInstanceState)
|
||
|
|
|
||
|
|
storage = KeystoreStorage(this, crypto)
|
||
|
|
|
||
|
|
lifecycleScope.launch {
|
||
|
|
val unlock = BiometricUnlock(
|
||
|
|
activity = this@MyActivity,
|
||
|
|
title = "Unlock Shade",
|
||
|
|
subtitle = "Tap your fingerprint to access your messages",
|
||
|
|
)
|
||
|
|
try {
|
||
|
|
storage.unlock(unlock)
|
||
|
|
} catch (e: BiometricCancelledException) {
|
||
|
|
// user backed out — show a "tap to retry" UI
|
||
|
|
return@launch
|
||
|
|
}
|
||
|
|
|
||
|
|
manager = ShadeSessionManager(crypto, storage)
|
||
|
|
manager.initialize()
|
||
|
|
// ... use manager normally
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
For credential-driven bootstrap (V4.9 profile + V4.10 approval), pair this with `no.zyon.shade.blob.createProfileNamespace` and `no.zyon.shade.approval.signProxyApproval` — both pure-JVM (in `:shade-android`).
|
||
|
|
|
||
|
|
## Threat model
|
||
|
|
|
||
|
|
- **Compromised app process**: cannot read the AES key (it's in the secure environment). Can attempt to use the cipher only after the user has authenticated; biometric re-prompts are required after each biometric event.
|
||
|
|
- **Stolen device with known PIN**: cannot unlock — `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` excludes `DEVICE_CREDENTIAL`.
|
||
|
|
- **Attacker enrolls own biometric**: `setInvalidatedByBiometricEnrollment(true)` invalidates the key on enrollment, forcing a credential rebootstrap (which would need username + password + PIN).
|
||
|
|
- **Catastrophic recovery**: `forgetEverything()` deletes the master key and clears the SharedPreferences. Pair with `Profile.delete()` for full account erasure.
|
||
|
|
|
||
|
|
## Build
|
||
|
|
|
||
|
|
Requires an Android SDK. The Gradle build uses Android Gradle Plugin 8.7+, AGP minSdk 28 (Pie+ for BiometricPrompt baseline), targetSdk 35.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
JAVA_HOME=/path/to/jdk-21 ./gradlew :shade-android-keystore:assembleDebug
|
||
|
|
```
|
||
|
|
|
||
|
|
Unit tests: none yet — `KeystoreStorage` requires Android runtime. Robolectric or instrumented tests against an emulator are tracked as a follow-up. The pure-JVM `SessionStateJson` round-trip serializer is tested in `:shade-android` (`SessionStateJsonTest`).
|