Pure-JVM additions to shade-android (no Android SDK needed): - V4.9 blob primitives: BlobKdf (HKDF deriveBlobSlotId/Key/SigningSeed), BlobAead (nonce||ct||tag with shade-profile-aad-v1:<slot> AAD), BlobClient (java.net.http with hand-written canonical JSON signing matching TS signPayload output), Profile high-level namespace. - V4.10 approval helpers: CanonicalProfileBlob schema with denormalized trustedApproverFingerprints, build/sign/verify proxy approvals via length-prefixed u16 BE UTF-8 canonical signing payload. - Password KDFs: scrypt + argon2id via Bouncy Castle, NFKC-normalized. - SessionStateJson at-rest serializer for persistence layer. Cross-platform vectors (test-vectors/blob.json, approval.json) gate byte-identical output between TS and Kotlin, including a TS-signed Ed25519 signature the Kotlin port verifies and reproduces (Ed25519 is deterministic). New shade-android-keystore sibling Gradle module (Android-specific): - KeystoreMasterKey: hardware-backed AES-256-GCM with BIOMETRIC_STRONG gating, StrongBox-backed when available, invalidated on enrollment. - BiometricUnlock: coroutine wrapper around BiometricPrompt with tagged cancellation/failure exceptions. - KeystoreStorage: StorageProvider over biometric-gated AES-encrypted SharedPreferences with AAD-bound row keys. All 25 SDK packages typecheck clean; 104 SDK tests + 24 new Kotlin tests + 11 cross-platform vector tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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`).
|