android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
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:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user