Re-encrypt Room application secrets during Keystore key rotation

Toggling "require unlocked device for key access" rotates the
AMBER_AES_KEY Keystore key, but rotateKey only re-encrypted the
DataStore secrets (account keys, PIN, WebDAV password). The
envelope-encrypted `secret`/`localKey` columns of the per-account
`application` Room tables were left under the old key, making every
NIP-46 connection row undecryptable after the toggle: getBySecret
lookups miss and localKey turns into undecryptable ciphertext.

rotateKey now stages those rows before deleting the old key and
rewrites them re-encrypted with the new key via the new
updateEncryptedColumnsRaw DAO query (ciphertext-in/ciphertext-out,
empty sentinel preserved, no schema change). Columns that fail
old-key decryption are written back verbatim so rotation never
destroys data it cannot recover, matching the decryptField failure
contract in ApplicationEntityCrypto.kt.
This commit is contained in:
greenart7c3
2026-08-14 13:23:13 -03:00
parent 50ec57b5bc
commit 41a37aacdc
4 changed files with 135 additions and 5 deletions
@@ -173,6 +173,51 @@ class ApplicationEntityCryptoTest {
assertEquals("empty", found?.key)
}
/**
* Key-rotation write path (SecureCryptoHelper.rotateKey step 4): the DAO
* must accept already-encrypted column values written under the target
* Keystore key so the wrapper reads decrypt them back to plaintext.
*/
@Test
fun updateEncryptedColumnsRaw_rewritesRowCiphertext_readBackThroughWrapper() = runBlocking {
dao.insertApplication(newEntity(key = "rot"))
val newSecret = "99999999-8888-7777-6666-555555555555"
val newLocalKey = "ef".repeat(32)
dao.updateEncryptedColumnsRaw(
key = "rot",
secret = SecureCryptoHelper.encryptBlocking(newSecret),
localKey = SecureCryptoHelper.encryptBlocking(newLocalKey),
)
val read = dao.getByKey("rot")?.application
assertNotNull(read)
assertEquals(newSecret, read?.secret)
assertEquals(newLocalKey, read?.localKey)
// Raw columns were replaced, not duplicated/concatenated.
val rawCursor = db.openHelper.readableDatabase.query(
"SELECT `secret` FROM application WHERE `key` = ?",
arrayOf("rot"),
)
rawCursor.use { c ->
assertTrue(c.moveToFirst())
assertNotEquals(newSecret, c.getString(0))
assertEquals(newSecret, SecureCryptoHelper.decryptBlocking(c.getString(0)))
}
}
@Test
fun updateEncryptedColumnsRaw_emptySentinel_roundTripsEmpty() = runBlocking {
dao.insertApplication(newEntity(key = "rotEmpty"))
dao.updateEncryptedColumnsRaw(key = "rotEmpty", secret = "", localKey = "")
val read = dao.getByKey("rotEmpty")?.application
assertNotNull(read)
assertEquals("", read?.secret)
assertEquals("", read?.localKey)
}
@Test
fun insertApplicationWithPermissions_encryptsAndReReadsPlaintext() = runBlocking {
val entity = newEntity(key = "permApp")
@@ -18,6 +18,7 @@ import kotlinx.coroutines.sync.withLock
object SecureCryptoHelper {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "AMBER_AES_KEY"
private const val TAG = "SecureCryptoHelper"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val IV_SIZE = 12 // 96 bits
private const val TAG_SIZE = 128 // bits
@@ -117,7 +118,7 @@ object SecureCryptoHelper {
keyGenerator.init(paramsBuilder.build())
return keyGenerator.generateKey()
} catch (e: Exception) {
AmberLog.w("SecureCryptoHelper", "StrongBox generation failed, falling back to TEE", e)
AmberLog.w(TAG, "StrongBox generation failed, falling back to TEE", e)
paramsBuilder.setIsStrongBoxBacked(false)
keyGenerator.init(paramsBuilder.build())
return keyGenerator.generateKey()
@@ -130,7 +131,9 @@ object SecureCryptoHelper {
/**
* Rotates the [KEY_ALIAS] Keystore key, re-encrypting every stored secret
* (per-account DataStore keys, app DataStore PIN, and WebDAV password).
* (per-account DataStore keys, app DataStore PIN, WebDAV password, and the
* per-account Room `application` tables' envelope-encrypted `secret` /
* `localKey` columns — see `ApplicationEntityCrypto.kt`).
*
* The Keystore `setUnlockedDeviceRequired` flag is set at key-generation
* time, so toggling the opt-in policy requires destroying and recreating
@@ -140,8 +143,8 @@ object SecureCryptoHelper {
* it runs after all decryptions succeed).
*
* Uses internal non-locking cipher methods ([encryptWithKey]/
* [decryptWithKey]) and raw DataStore helpers to avoid mutex reentrancy
* (Kotlin's [Mutex] is not reentrant).
* [decryptWithKey]) and raw DataStore/DAO helpers to avoid mutex
* reentrancy (Kotlin's [Mutex] is not reentrant).
*/
suspend fun rotateKey(context: Context, requireUnlockedDevice: Boolean) = mutex.withLock {
val keyStore = getKeyStore()
@@ -198,6 +201,28 @@ object SecureCryptoHelper {
null
}
// 1b. Read the per-account Room `application` tables and decrypt the
// envelope-encrypted `secret`/`localKey` columns (GHSA-5fjp-ghh8-wch8)
// with the old key — still before any destructive step. Rows whose
// columns fail to decrypt keep their raw ciphertext on write-back
// (rotation must not destroy data it cannot recover).
val decryptedAppRows = mutableMapOf<String, List<RotatedAppRow>>()
for (acc in accounts) {
val rows = runCatching { Amber.instance.dao(acc.npub).getAllApplicationsRaw() }
.onFailure { AmberLog.w(TAG, "Key rotation: failed to read application rows", it) }
.getOrElse { emptyList() }
decryptedAppRows[acc.npub] = rows.mapNotNull { row ->
if (row.secret.isEmpty() && row.localKey.isEmpty()) return@mapNotNull null
RotatedAppRow(
key = row.key,
secretPlain = row.secret.decryptOrNullWith(oldKey),
localKeyPlain = row.localKey.decryptOrNullWith(oldKey),
secretRaw = row.secret,
localKeyRaw = row.localKey,
)
}
}
// 2. Delete the old key and generate a new one with the new policy.
keyStore.deleteEntry(KEY_ALIAS)
val newKey = generateSecretKey(requireUnlockedDevice)
@@ -218,8 +243,52 @@ object SecureCryptoHelper {
LocalPreferences.saveWebDavPasswordEncrypted(context, encryptWithKey(newKey, it))
}
AmberLog.d("SecureCryptoHelper", "Key rotation complete (requireUnlockedDevice=$requireUnlockedDevice)")
// 4. Re-encrypt the per-account `application` rows with the new key
// and write them back through the raw (ciphertext-in, ciphertext-out)
// update. Columns whose old-key decryption failed are written back
// verbatim, matching the `decryptField` failure contract in
// ApplicationEntityCrypto.kt.
for ((npub, rows) in decryptedAppRows) {
val dao = runCatching { Amber.instance.dao(npub) }.getOrNull() ?: continue
for (row in rows) {
runCatching {
dao.updateEncryptedColumnsRaw(
key = row.key,
secret = row.secretPlain?.let { encryptWithKey(newKey, it) } ?: row.secretRaw,
localKey = row.localKeyPlain?.let { encryptWithKey(newKey, it) } ?: row.localKeyRaw,
)
}.onFailure {
AmberLog.w(TAG, "Key rotation: failed to re-encrypt application row", it)
}
}
}
AmberLog.d(TAG, "Key rotation complete (requireUnlockedDevice=$requireUnlockedDevice)")
}
private fun String.decryptOrNullWith(key: SecretKey): String? = if (isEmpty()) {
null
} else {
try {
decryptWithKey(key, this)
} catch (_: Exception) {
null
}
}
/**
* One `application`-table row staged during key rotation: [secretPlain] /
* [localKeyPlain] hold the old-key decryption result (or `null` when the
* ciphertext was unreadable — those rows keep [secretRaw]/[localKeyRaw]
* verbatim on write-back so no data is lost).
*/
private data class RotatedAppRow(
val key: String,
val secretPlain: String?,
val localKeyPlain: String?,
val secretRaw: String,
val localKeyRaw: String,
)
}
fun Context.hasStrongBox(): Boolean {
@@ -239,6 +239,18 @@ interface ApplicationDao {
@Transaction
suspend fun updateNameAndIcon(key: String, name: String, icon: String)
/**
* Rewrites the two envelope-encrypted columns for one row. Key-rotation
* write path ([SecureCryptoHelper.rotateKey]): the values must already be
* ciphertext encrypted with the TARGET Keystore key — or the unchanged raw
* value when the old ciphertext could not be decrypted, so rotation never
* destroys data it cannot recover. Not wrapped by a public default method
* because callers deal exclusively in ciphertext (empty-sentinel included).
*/
@Query("UPDATE application SET secret = :secret, localKey = :localKey WHERE `key` = :key")
@Transaction
suspend fun updateEncryptedColumnsRaw(key: String, secret: String, localKey: String)
@Delete
@Transaction
suspend fun deletePermission(permission: ApplicationPermissionsEntity)
@@ -257,6 +257,10 @@ class CachingApplicationDao(
override suspend fun getAllWithLocalKeyRaw(pubKey: String): List<ApplicationEntity> = delegate.getAllWithLocalKeyRaw(pubKey)
// Key-rotation write path: secret/localKey never feed a cached read, so a
// plain delegation with no invalidation is correct.
override suspend fun updateEncryptedColumnsRaw(key: String, secret: String, localKey: String) = delegate.updateEncryptedColumnsRaw(key, secret, localKey)
override suspend fun insertApplicationRaw(event: ApplicationEntity): Long? = delegate.insertApplicationRaw(event)
override fun insertAllRaw(events: List<ApplicationEntity>): List<Long>? = delegate.insertAllRaw(events)