mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
The per-account Room database (amber_db_<npub>) stored two NIP-46 secret values as cleartext TEXT columns: the bunker connection `secret` and the `localKey` — the latter being a full Nostr private key. Anyone with access to the app's internal storage (rooted device, privilege-escalating malware, or a future bug exporting the DB) could recover these values and bypass the Keystore protection the main account nsec enjoys (CWE-312). Fix: envelope-encrypt both columns with the existing Keystore-backed AES-256-GCM key (SecureCryptoHelper) before Room persistence, and decrypt on read, so every existing consumer continues to see the plaintext values it already expects. - SecureCryptoHelper: add non-suspend encryptBlocking/decryptBlocking so Migration.migrate() and getByKeySync() can call them without a runBlocking bridge; suspend variants now delegate to the blocking implementations. - ApplicationEntityCrypto.kt (new): encryptForStorage/decryptFromStorage mappers + DecryptingPagingSource. Sentinel rule: empty values stay "" at rest (matches the WebDAV password idiom in LocalPreferences.kt:661-681), preserving `WHERE localKey != ''` enumeration in NotificationSubscription and the `localPubKey` derivation on empty localKey. - ApplicationDao: split methods touching `secret`/`localKey` into Room- generated `*Raw` (encrypted columns) and default-method wrappers that apply the mappers. `getBySecret` rewritten to decrypt and filter in Kotlin (random GCM IV breaks `WHERE secret = :secret`). - CachingApplicationDao: add delegating `*Raw` overrides so the decorator still instantiates; cache logic unchanged. - AppDatabase: add MIGRATION_18_19 (in-place envelope-encrypt of existing plaintext rows via compiled statement + transaction; empty values stay empty). Bump @Database version to 19. - Backup/restore: no changes — ApplicationBackup.buildPayload reads via the wrapped DAO (plaintext) and the JSON is already NIP-44 encrypted by the account key; restore goes through the wrapped insert (auto-encrypts). - Tests: new androidTest ApplicationEntityCryptoTest covers round-trip, raw-column-ciphertext assertion, empty sentinel, localPubKey derivation, getAllWithLocalKey filter, getBySecret (hit/miss/empty), insertApplicationWithPermissions, getAll, and the MIGRATION_18_19 row re-encryption. Requires a device/emulator (AndroidKeyStore unavailable under JVM test). - New room-testing androidTestImplementation dependency. Verified: ktlintCheck, lint (no issues), testFreeDebugUnitTest (0 failures), compileFreeDebugAndroidTestKotlin, assembleFreeDebug, assembleOfflineDebug, and the offline merged manifest check (no INTERNET/ACCESS_NETWORK_STATE/ CHANGE_NETWORK_STATE permissions leaked).
125 lines
4.8 KiB
Kotlin
125 lines
4.8 KiB
Kotlin
package com.greenart7c3.nostrsigner
|
|
|
|
import android.content.Context
|
|
import android.content.pm.PackageManager
|
|
import android.os.Build
|
|
import android.security.keystore.KeyGenParameterSpec
|
|
import android.security.keystore.KeyProperties
|
|
import android.util.Base64
|
|
import java.nio.ByteBuffer
|
|
import java.security.KeyStore
|
|
import javax.crypto.Cipher
|
|
import javax.crypto.KeyGenerator
|
|
import javax.crypto.SecretKey
|
|
import javax.crypto.spec.GCMParameterSpec
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
|
|
object SecureCryptoHelper {
|
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
private const val KEY_ALIAS = "AMBER_AES_KEY"
|
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
private const val IV_SIZE = 12 // 96 bits
|
|
private const val TAG_SIZE = 128 // bits
|
|
private val mutex = Mutex()
|
|
|
|
suspend fun encrypt(plainText: String): String = mutex.withLock {
|
|
encryptBlocking(plainText)
|
|
}
|
|
|
|
suspend fun decrypt(encryptedText: String): String = mutex.withLock {
|
|
decryptBlocking(encryptedText)
|
|
}
|
|
|
|
/**
|
|
* Non-suspending equivalent of [encrypt] for callers that cannot suspend
|
|
* (Room [Migration.migrate] callbacks, [getByKeySync] synchronous DAO
|
|
* reads). The AndroidKeyStore Cipher instance is thread-safe to obtain and
|
|
* use; the suspend variant's [mutex] only guards against concurrent
|
|
* in-flight cipher init within coroutines and is intentionally omitted
|
|
* here so blocking callers do not need a [kotlinx.coroutines.runBlocking]
|
|
* bridge.
|
|
*/
|
|
fun encryptBlocking(plainText: String): String {
|
|
val key = getOrCreateSecretKey()
|
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
|
val iv = cipher.iv
|
|
|
|
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
|
|
|
val combined = ByteBuffer.allocate(iv.size + cipherText.size)
|
|
combined.put(iv)
|
|
combined.put(cipherText)
|
|
|
|
return Base64.encodeToString(combined.array(), Base64.NO_WRAP)
|
|
}
|
|
|
|
/**
|
|
* Non-suspending equivalent of [decrypt]. See [encryptBlocking] for the
|
|
* rationale.
|
|
*/
|
|
fun decryptBlocking(encryptedText: String): String {
|
|
val key = getOrCreateSecretKey()
|
|
val data = Base64.decode(encryptedText, Base64.NO_WRAP)
|
|
val buffer = ByteBuffer.wrap(data)
|
|
|
|
val iv = ByteArray(IV_SIZE).also { buffer.get(it) }
|
|
val cipherText = ByteArray(buffer.remaining()).also { buffer.get(it) }
|
|
|
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
val spec = GCMParameterSpec(TAG_SIZE, iv)
|
|
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
|
|
|
val plainBytes = cipher.doFinal(cipherText)
|
|
return String(plainBytes, Charsets.UTF_8)
|
|
}
|
|
|
|
private fun getOrCreateSecretKey(): SecretKey {
|
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
if (keyStore.containsAlias(KEY_ALIAS)) {
|
|
val entry = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
|
|
if (entry != null) {
|
|
return entry.secretKey
|
|
}
|
|
}
|
|
|
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
|
val paramsBuilder = KeyGenParameterSpec.Builder(
|
|
KEY_ALIAS,
|
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
)
|
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
.setKeySize(256)
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
try {
|
|
if (Amber.instance.hasStrongBox()) {
|
|
paramsBuilder.setIsStrongBoxBacked(true)
|
|
}
|
|
keyGenerator.init(paramsBuilder.build())
|
|
return keyGenerator.generateKey()
|
|
} catch (e: Exception) {
|
|
AmberLog.w("SecureCryptoHelper", "StrongBox generation failed, falling back to TEE", e)
|
|
paramsBuilder.setIsStrongBoxBacked(false)
|
|
keyGenerator.init(paramsBuilder.build())
|
|
return keyGenerator.generateKey()
|
|
}
|
|
} else {
|
|
keyGenerator.init(paramsBuilder.build())
|
|
return keyGenerator.generateKey()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun Context.hasStrongBox(): Boolean {
|
|
val isMediaTek = Build.HARDWARE.lowercase().contains("mt") ||
|
|
Build.BOARD.lowercase().contains("mt") ||
|
|
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Build.SOC_MANUFACTURER.lowercase().contains("mediatek")) // usually mediatek contains broken strongbox support
|
|
|
|
return !isMediaTek &&
|
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
|
packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
}
|