Files
Amber/app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt
T
greenart7c3 98dfca7095 Envelope-encrypt NIP-46 connection secrets at rest (GHSA-5fjp-ghh8-wch8)
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).
2026-08-10 14:55:51 -03:00

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)
}