android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled

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>
This commit is contained in:
2026-05-09 17:38:15 +02:00
parent 1bd7037a6d
commit 188c3db56a
26 changed files with 3181 additions and 1 deletions

View File

@@ -0,0 +1,68 @@
# 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`).

View File

@@ -0,0 +1,59 @@
plugins {
id("com.android.library")
kotlin("android")
}
// V4.10 — Android-specific KeystoreStorage adapter.
//
// Lives as a sibling module to `:shade-android` so the JVM-only
// protocol code can keep running in CI without an Android SDK.
// This module pulls in `:shade-android` for `StorageProvider`,
// `IdentityKeyPair`, etc., and binds those types to a hardware-
// backed Android Keystore master key with biometric gating.
android {
namespace = "no.zyon.shade.keystore"
compileSdk = 35
defaultConfig {
minSdk = 28 // BiometricPrompt + StrongBox baseline
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
release {
isMinifyEnabled = false
}
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies {
// Sibling: protocol types + StorageProvider interface.
api(project(":shade-android"))
// androidx.biometric — fragment-safe BiometricPrompt wrapper.
// 1.2.0-alpha05 is the latest with stable BiometricPrompt API.
implementation("androidx.biometric:biometric:1.2.0-alpha05")
// androidx.fragment — BiometricPrompt requires FragmentActivity.
implementation("androidx.fragment:fragment-ktx:1.8.5")
// Coroutines for the suspend-function StorageProvider implementation.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -0,0 +1,112 @@
package no.zyon.shade.keystore
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import javax.crypto.Cipher
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Biometric unlock for a `KeystoreMasterKey`-bound `Cipher`.
*
* The Android keystore enforces that any operation on a
* user-authentication-required key must happen via a
* `BiometricPrompt.CryptoObject`-wrapped `Cipher`. The user sees a
* system biometric prompt; on success the same `Cipher` instance is
* usable for one operation (or one streaming session) before
* needing to re-prompt.
*
* This is a thin coroutine wrapper around `BiometricPrompt` that
* resolves to the authenticated cipher or throws on user
* cancellation. Callers typically run it once at app start to
* unlock the master key for the lifetime of the foreground session.
*/
class BiometricUnlock(
private val activity: FragmentActivity,
private val title: String,
private val subtitle: String? = null,
private val negativeButton: String = "Cancel",
) {
/**
* True if BIOMETRIC_STRONG is currently usable on this device.
* False means the user has no enrolled fingerprint/face that
* meets the class-3 assurance level — fall back to a credential
* recovery flow rather than crashing.
*/
fun canAuthenticate(): Boolean {
val mgr = BiometricManager.from(activity)
return mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Show the biometric prompt and return the authenticated cipher.
*
* Cancellation paths:
* - User taps the negative button → throws `BiometricCancelledException`.
* - System errors out (e.g. too many failures) → throws
* `BiometricFailedException` with the system error code.
*/
suspend fun unlock(cipher: Cipher): Cipher = suspendCancellableCoroutine { cont ->
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
val authedCipher = result.cryptoObject?.cipher
if (authedCipher == null) {
cont.resumeWithException(
BiometricFailedException(-1, "BiometricPrompt returned no cipher"),
)
} else {
cont.resume(authedCipher)
}
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
cont.resumeWithException(BiometricCancelledException(errString.toString()))
} else {
cont.resumeWithException(
BiometricFailedException(errorCode, errString.toString()),
)
}
}
override fun onAuthenticationFailed() {
// A single failed attempt — the prompt stays open and
// gives the user another try. Don't resume the
// continuation; let the system flow continue.
}
}
val prompt = BiometricPrompt(
activity,
activity.mainExecutor,
callback,
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.apply { if (subtitle != null) setSubtitle(subtitle) }
.setNegativeButtonText(negativeButton)
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
cont.invokeOnCancellation { prompt.cancelAuthentication() }
prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher))
}
}
/** User cancelled the biometric prompt. */
class BiometricCancelledException(message: String) : RuntimeException(message)
/**
* BiometricPrompt returned a non-cancellation error (lockout, hardware
* unavailable, no enrolled biometrics, etc.). Inspect `errorCode`
* against `BiometricPrompt.ERROR_*` constants to decide UX response.
*/
class BiometricFailedException(val errorCode: Int, message: String) :
RuntimeException("[$errorCode] $message")

View File

@@ -0,0 +1,172 @@
package no.zyon.shade.keystore
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import java.security.KeyStore
/**
* Hardware-backed AES-256-GCM master key in the Android Keystore.
*
* The key never leaves the secure environment — Android's keystore
* implementation enforces that all encrypt/decrypt operations
* happen inside the TEE (or StrongBox if present), and the raw
* key bytes are never returned to userspace.
*
* The key is created on first use with these properties:
*
* - AES-256-GCM, no padding
* - User authentication required: opt-in via the `requireBiometric`
* flag. When true, every encrypt/decrypt operation must be wrapped
* in a `BiometricPrompt.authenticate(CryptoObject(cipher))` call
* that succeeds within the same `Cipher` instance.
* - StrongBox-backed if available (Pixel 3+, most Samsung flagships).
* Falls back to TEE on devices without StrongBox.
* - InvalidatedByBiometricEnrollment(true): a newly enrolled
* fingerprint/face invalidates the key, forcing the user to
* re-bootstrap from credentials. Defends against a thief who
* enrolls their own biometric.
*
* Mirrors the role `KeyManager` plays in `@shade/storage-encrypted`'s
* V4.5 KDF chain: this is the *encryption-at-rest* master key, not
* the X3DH identity key. The Shade protocol's identity keys are
* stored encrypted under THIS key.
*/
class KeystoreMasterKey(
private val alias: String,
private val requireBiometric: Boolean = true,
) {
init {
require(alias.isNotEmpty()) { "alias must be non-empty" }
}
/**
* Build a `Cipher` initialized for encryption with the master key.
*
* If the key requires user auth, the returned cipher is *not yet
* usable* — the caller MUST wrap it in a
* `BiometricPrompt.authenticate(CryptoObject(cipher))` and use
* the cipher exposed by the auth-success callback. Calling
* `cipher.doFinal(...)` before authentication throws
* `UserNotAuthenticatedException`.
*/
fun cipherForEncrypt(): Cipher {
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key)
return cipher
}
/**
* Build a `Cipher` initialized for decryption with the master key
* and a previously-stored 12-byte nonce. Same authentication
* requirement as `cipherForEncrypt`.
*/
fun cipherForDecrypt(nonce: ByteArray): Cipher {
require(nonce.size == 12) { "GCM nonce must be 12 bytes" }
val key = getOrCreateKey()
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, nonce))
return cipher
}
/** True if the key already exists in the Android Keystore. */
fun exists(): Boolean {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
return ks.containsAlias(alias)
}
/**
* Delete the master key. Catastrophic — all data encrypted under
* it becomes unrecoverable. Used by the "forget everything" flow
* (paired with `Profile.delete()` in the V4.9 namespace).
*/
fun deleteKey() {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
if (ks.containsAlias(alias)) ks.deleteEntry(alias)
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
ks.getEntry(alias, null)?.let { entry ->
return (entry as KeyStore.SecretKeyEntry).secretKey
}
return generateKey()
}
private fun generateKey(): SecretKey {
val builder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
// Each encrypt operation generates a fresh IV in the secure
// env; we read it back via `cipher.iv` after init.
.setRandomizedEncryptionRequired(true)
if (requireBiometric) {
builder.setUserAuthenticationRequired(true)
// BIOMETRIC_STRONG only — class 3, the highest assurance
// level (Class 3 = false-accept rate < 1/50 000 per BiometricPrompt).
// DEVICE_CREDENTIAL is intentionally NOT included: a stolen
// device with a known PIN should not unlock Shade.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
builder.setUserAuthenticationParameters(
/* timeout = */ 0,
KeyProperties.AUTH_BIOMETRIC_STRONG,
)
} else {
@Suppress("DEPRECATION")
builder.setUserAuthenticationValidityDurationSeconds(-1)
}
builder.setInvalidatedByBiometricEnrollment(true)
}
// StrongBox if available — bumps key storage to a dedicated
// tamper-resistant chip on Pixel 3+ / most Samsung flagships.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
builder.setIsStrongBoxBacked(true)
}
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
return try {
gen.init(builder.build())
gen.generateKey()
} catch (_: Exception) {
// StrongBox not present or full → retry without StrongBox.
// Same for older devices that don't honor
// setUserAuthenticationParameters.
val fallback = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setRandomizedEncryptionRequired(true)
.apply {
if (requireBiometric) {
setUserAuthenticationRequired(true)
@Suppress("DEPRECATION")
setUserAuthenticationValidityDurationSeconds(-1)
setInvalidatedByBiometricEnrollment(true)
}
}
.build()
gen.init(fallback)
gen.generateKey()
}
}
companion object {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
}
}

View File

@@ -0,0 +1,229 @@
package no.zyon.shade.keystore
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import no.zyon.shade.crypto.CryptoProvider
import no.zyon.shade.serialization.SessionStateJson
import no.zyon.shade.storage.StorageProvider
import no.zyon.shade.types.IdentityKeyPair
import no.zyon.shade.types.OneTimePreKey
import no.zyon.shade.types.SessionState
import no.zyon.shade.types.SignedPreKey
/**
* `StorageProvider` implementation that gates all reads/writes through
* a biometric-locked `KeystoreMasterKey`. Mirrors `MemoryStorage` for
* the API surface but persists state to `SharedPreferences` with each
* row encrypted under the keystore key.
*
* Lifecycle:
*
* 1. App start → construct `KeystoreStorage(context, alias)`.
* 2. `unlock(BiometricUnlock)` runs the system biometric prompt.
* The Android keystore caches the auth state under the key's
* `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)` policy
* until the next biometric event (re-enrollment, etc.).
* 3. While unlocked, `getSession`/`saveSession` etc. work normally.
* 4. `lock()` clears the in-memory unlocked flag so a future
* operation triggers another biometric prompt.
*
* Wire layout per row:
* `<base64(nonce(12))>:<base64(ct||tag)>`
*
* Stored as `String` SharedPreferences entries. AAD = the row's
* preference key (`session:<address>`, `signedPreKey:<id>`, etc.) so
* a substituted-prefs swap fails to open.
*/
class KeystoreStorage(
context: Context,
private val crypto: CryptoProvider,
keyAlias: String = DEFAULT_KEY_ALIAS,
prefsName: String = DEFAULT_PREFS_NAME,
requireBiometric: Boolean = true,
) : StorageProvider {
private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
private val masterKey = KeystoreMasterKey(keyAlias, requireBiometric = requireBiometric)
private val writeMutex = Mutex()
@Volatile
private var unlocked: Boolean = false
/**
* Unlock the keystore via biometric prompt. Idempotent — calling
* twice without a `lock()` between is a no-op.
*/
suspend fun unlock(unlock: BiometricUnlock) {
if (unlocked) return
// The biometric flow returns an authenticated *encrypt*
// cipher; we discard it after a one-shot probe to confirm
// the master key is reachable. The actual encrypt/decrypt
// ciphers in the I/O path use the authentication state
// established here (Android Keystore caches the auth for
// the user-authentication-required key under the
// `setUserAuthenticationParameters(0, BIOMETRIC_STRONG)`
// policy until the next biometric event).
val probe = masterKey.cipherForEncrypt()
unlock.unlock(probe)
unlocked = true
}
/** Unlock without biometric — only valid for keys constructed with `requireBiometric=false`. */
fun unlockNoBiometric() {
unlocked = true
}
/** Wipe in-memory unlock state. The key itself stays in the keystore. */
fun lock() {
unlocked = false
}
/**
* Catastrophic reset: deletes the master key + all encrypted
* preferences. Used by the "forget everything" / 3-strikes-wipe
* path. The next bootstrap rebuilds from credentials.
*/
fun forgetEverything() {
masterKey.deleteKey()
prefs.edit().clear().apply()
unlocked = false
}
// ─── Identity ──────────────────────────────────────────────
override suspend fun getIdentityKeyPair(): IdentityKeyPair? {
val json = readDecrypted(KEY_IDENTITY) ?: return null
return SessionStateJson.deserializeIdentityKeyPair(json)
}
override suspend fun saveIdentityKeyPair(keyPair: IdentityKeyPair) {
writeEncrypted(KEY_IDENTITY, SessionStateJson.serializeIdentityKeyPair(keyPair))
}
override suspend fun getLocalRegistrationId(): Int {
return readDecrypted(KEY_REGISTRATION_ID)?.toIntOrNull() ?: 0
}
override suspend fun saveLocalRegistrationId(id: Int) {
writeEncrypted(KEY_REGISTRATION_ID, id.toString())
}
// ─── Signed prekeys ────────────────────────────────────────
override suspend fun getSignedPreKey(keyId: Int): SignedPreKey? {
val json = readDecrypted("$KEY_SIGNED_PREKEY:$keyId") ?: return null
return SessionStateJson.deserializeSignedPreKey(json)
}
override suspend fun saveSignedPreKey(key: SignedPreKey) {
writeEncrypted(
"$KEY_SIGNED_PREKEY:${key.keyId}",
SessionStateJson.serializeSignedPreKey(key),
)
}
override suspend fun removeSignedPreKey(keyId: Int) {
writeMutex.withLock { prefs.edit().remove("$KEY_SIGNED_PREKEY:$keyId").apply() }
}
// ─── One-time prekeys ──────────────────────────────────────
override suspend fun getOneTimePreKey(keyId: Int): OneTimePreKey? {
val json = readDecrypted("$KEY_ONETIME_PREKEY:$keyId") ?: return null
return SessionStateJson.deserializeOneTimePreKey(json)
}
override suspend fun saveOneTimePreKey(key: OneTimePreKey) {
writeEncrypted(
"$KEY_ONETIME_PREKEY:${key.keyId}",
SessionStateJson.serializeOneTimePreKey(key),
)
}
override suspend fun removeOneTimePreKey(keyId: Int) {
writeMutex.withLock { prefs.edit().remove("$KEY_ONETIME_PREKEY:$keyId").apply() }
}
override suspend fun getOneTimePreKeyCount(): Int {
return prefs.all.keys.count { it.startsWith("$KEY_ONETIME_PREKEY:") }
}
// ─── Sessions ──────────────────────────────────────────────
override suspend fun getSession(address: String): SessionState? {
val json = readDecrypted("$KEY_SESSION:$address") ?: return null
return SessionStateJson.deserialize(json)
}
override suspend fun saveSession(address: String, state: SessionState) {
writeEncrypted("$KEY_SESSION:$address", SessionStateJson.serialize(state))
}
override suspend fun removeSession(address: String) {
writeMutex.withLock { prefs.edit().remove("$KEY_SESSION:$address").apply() }
}
// ─── Trust ─────────────────────────────────────────────────
override suspend fun isTrustedIdentity(address: String, identityKey: ByteArray): Boolean {
val stored = readDecrypted("$KEY_TRUSTED:$address") ?: return true // TOFU
val storedBytes = Base64.decode(stored, Base64.NO_WRAP)
return crypto.constantTimeEqual(storedBytes, identityKey)
}
override suspend fun saveTrustedIdentity(address: String, identityKey: ByteArray) {
writeEncrypted(
"$KEY_TRUSTED:$address",
Base64.encodeToString(identityKey, Base64.NO_WRAP),
)
}
// ─── Encrypted-row plumbing ────────────────────────────────
private fun ensureUnlocked() {
check(unlocked) {
"KeystoreStorage is locked — call unlock(BiometricUnlock) first"
}
}
private suspend fun readDecrypted(prefKey: String): String? {
ensureUnlocked()
val raw = prefs.getString(prefKey, null) ?: return null
val parts = raw.split(":", limit = 2)
require(parts.size == 2) { "malformed encrypted row at $prefKey" }
val nonce = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = masterKey.cipherForDecrypt(nonce)
cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ct).toString(Charsets.UTF_8)
}
private suspend fun writeEncrypted(prefKey: String, plaintext: String) {
ensureUnlocked()
writeMutex.withLock {
val cipher = masterKey.cipherForEncrypt()
cipher.updateAAD(prefKey.toByteArray(Charsets.UTF_8))
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val nonce = cipher.iv
val nonceB64 = Base64.encodeToString(nonce, Base64.NO_WRAP)
val ctB64 = Base64.encodeToString(ct, Base64.NO_WRAP)
prefs.edit().putString(prefKey, "$nonceB64:$ctB64").apply()
}
}
companion object {
const val DEFAULT_KEY_ALIAS = "shade-master-v1"
const val DEFAULT_PREFS_NAME = "shade-keystore-storage-v1"
private const val KEY_IDENTITY = "identity"
private const val KEY_REGISTRATION_ID = "registrationId"
private const val KEY_SIGNED_PREKEY = "signedPreKey"
private const val KEY_ONETIME_PREKEY = "oneTimePreKey"
private const val KEY_SESSION = "session"
private const val KEY_TRUSTED = "trusted"
}
}