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).
This commit is contained in:
greenart7c3
2026-08-10 14:55:51 -03:00
parent 65fea7d6b3
commit 98dfca7095
9 changed files with 884 additions and 56 deletions
+1
View File
@@ -247,6 +247,7 @@ dependencies {
androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.ui.test.junit4) androidTestImplementation(libs.ui.test.junit4)
androidTestImplementation(libs.room.testing)
debugImplementation(libs.leakcanary) debugImplementation(libs.leakcanary)
debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.tooling)
debugImplementation(libs.ui.test.manifest) debugImplementation(libs.ui.test.manifest)
@@ -0,0 +1,235 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "4d6bba48f6829e611cf02157dcc20cf5",
"entities": [
{
"tableName": "application",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `relays` TEXT NOT NULL, `url` TEXT NOT NULL, `icon` TEXT NOT NULL, `description` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `isConnected` INTEGER NOT NULL, `secret` TEXT NOT NULL, `useSecret` INTEGER NOT NULL, `signPolicy` INTEGER NOT NULL, `closeApplication` INTEGER NOT NULL, `deleteAfter` INTEGER NOT NULL, `lastUsed` INTEGER NOT NULL, `localKey` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "relays",
"columnName": "relays",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pubKey",
"columnName": "pubKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isConnected",
"columnName": "isConnected",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "secret",
"columnName": "secret",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useSecret",
"columnName": "useSecret",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "signPolicy",
"columnName": "signPolicy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "closeApplication",
"columnName": "closeApplication",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleteAfter",
"columnName": "deleteAfter",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "lastUsed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localKey",
"columnName": "localKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [
{
"name": "index_key",
"unique": true,
"columnNames": [
"key"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_key` ON `${TABLE_NAME}` (`key`)"
},
{
"name": "index_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_name` ON `${TABLE_NAME}` (`name`)"
}
]
},
{
"tableName": "applicationPermission",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `acceptable` INTEGER NOT NULL, `rememberType` INTEGER NOT NULL, `acceptUntil` INTEGER NOT NULL, `rejectUntil` INTEGER NOT NULL, `relay` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`pkKey`) REFERENCES `application`(`key`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER"
},
{
"fieldPath": "pkKey",
"columnName": "pkKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "kind",
"columnName": "kind",
"affinity": "INTEGER"
},
{
"fieldPath": "acceptable",
"columnName": "acceptable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rememberType",
"columnName": "rememberType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "acceptUntil",
"columnName": "acceptUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rejectUntil",
"columnName": "rejectUntil",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "relay",
"columnName": "relay",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "permissions_by_pk_key",
"unique": false,
"columnNames": [
"pkKey"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `permissions_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)"
},
{
"name": "permissions_unique",
"unique": true,
"columnNames": [
"pkKey",
"type",
"kind",
"relay"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `permissions_unique` ON `${TABLE_NAME}` (`pkKey`, `type`, `kind`, `relay`)"
}
],
"foreignKeys": [
{
"table": "application",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"pkKey"
],
"referencedColumns": [
"key"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4d6bba48f6829e611cf02157dcc20cf5')"
]
}
}
@@ -0,0 +1,320 @@
package com.greenart7c3.nostrsigner.database
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.greenart7c3.nostrsigner.SecureCryptoHelper
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented coverage for the GHSA-5fjp-ghh8-wch8 fix — envelope encryption
* of [ApplicationEntity.secret] and [ApplicationEntity.localKey] at the DAO
* boundary, plus the [MIGRATION_18_19] row re-encryption.
*
* Lives under `androidTest/` because [SecureCryptoHelper] depends on the
* AndroidKeyStore provider, which is unavailable under JVM unit tests.
*/
@RunWith(AndroidJUnit4::class)
class ApplicationEntityCryptoTest {
private lateinit var db: AppDatabase
private lateinit var dao: ApplicationDao
private val pubKey = "ab".repeat(32)
private val sampleSecret = "11111111-2222-3333-4444-555555555555"
private val sampleLocalKey = "cd".repeat(32)
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = db.dao()
}
@After
fun tearDown() {
db.close()
}
private fun newEntity(
key: String = "app1",
secret: String = sampleSecret,
localKey: String = sampleLocalKey,
) = ApplicationEntity(
key = key,
name = "Test Bunker",
relays = emptyList(),
url = "",
icon = "",
description = "",
pubKey = pubKey,
isConnected = false,
secret = secret,
useSecret = true,
signPolicy = 1,
closeApplication = true,
deleteAfter = 0L,
lastUsed = 0L,
localKey = localKey,
)
@Test
fun insertAndRead_roundTrips_plaintextSecretAndLocalKey() = runBlocking {
dao.insertApplication(newEntity())
val read = dao.getByKey("app1")?.application
assertNotNull(read)
assertEquals(sampleSecret, read?.secret)
assertEquals(sampleLocalKey, read?.localKey)
}
@Test
fun insertPersistsCiphertext_rawColumnDiffersFromPlaintext() = runBlocking {
dao.insertApplication(newEntity())
val rawCursor = db.openHelper.readableDatabase.query(
"SELECT `secret`, `localKey` FROM application WHERE `key` = ?",
arrayOf("app1"),
)
rawCursor.use { c ->
assertTrue("row must exist", c.moveToFirst())
val rawSecret = c.getString(0)
val rawLocalKey = c.getString(1)
assertNotEquals("stored secret must not be plaintext", sampleSecret, rawSecret)
assertNotEquals("stored localKey must not be plaintext", sampleLocalKey, rawLocalKey)
// Sentinel format: Base64 NO_WRAP of IV(12) ‖ ciphertext+tag (>= 16 bytes)
assertTrue("raw secret looks like Base64", rawSecret.matches(Regex("^[A-Za-z0-9+/=]+$")))
assertTrue("raw localKey looks like Base64", rawLocalKey.matches(Regex("^[A-Za-z0-9+/=]+$")))
// Round-trips through SecureCryptoHelper to the original plaintext.
assertEquals(sampleSecret, SecureCryptoHelper.decryptBlocking(rawSecret))
assertEquals(sampleLocalKey, SecureCryptoHelper.decryptBlocking(rawLocalKey))
}
}
@Test
fun emptySentinel_storedAsEmpty_decryptedAsEmpty() = runBlocking {
dao.insertApplication(newEntity(secret = "", localKey = ""))
val read = dao.getByKey("app1")?.application
assertNotNull(read)
assertEquals("", read?.secret)
assertEquals("", read?.localKey)
// Raw column should also be empty (not encrypted to a Base64 token).
val rawCursor = db.openHelper.readableDatabase.query(
"SELECT `secret`, `localKey` FROM application WHERE `key` = ?",
arrayOf("app1"),
)
rawCursor.use { c ->
assertTrue(c.moveToFirst())
assertEquals("", c.getString(0))
assertEquals("", c.getString(1))
}
}
@Test
fun localPubKey_accessorReturnsDerivedPubkey() = runBlocking {
dao.insertApplication(newEntity())
val read = dao.getByKey("app1")?.application
assertNotNull(read)
assertEquals(
localPubKeyFromPrivKey(sampleLocalKey),
read?.localPubKey,
)
}
@Test
fun getAllWithLocalKey_onlyReturnsRowsWithNonEmptyLocalKey() = runBlocking {
dao.insertApplication(newEntity(key = "withKey", localKey = sampleLocalKey))
dao.insertApplication(newEntity(key = "withoutKey", localKey = ""))
val rows = dao.getAllWithLocalKey(pubKey)
assertEquals(1, rows.size)
assertEquals("withKey", rows.first().key)
assertEquals(sampleLocalKey, rows.first().localKey)
}
@Test
fun getBySecret_findsRowByPlaintextSecret() = runBlocking {
dao.insertApplication(newEntity(key = "bunker1", secret = sampleSecret))
val found = dao.getBySecret(sampleSecret)?.application
assertNotNull(found)
assertEquals("bunker1", found?.key)
assertEquals(sampleSecret, found?.secret)
}
@Test
fun getBySecret_returnsNullForUnknownSecret() = runBlocking {
dao.insertApplication(newEntity(secret = sampleSecret))
assertNull(dao.getBySecret("not-a-known-secret"))
}
@Test
fun getBySecret_findsRowWithEmptySecretWhenQueriedWithEmpty() = runBlocking {
dao.insertApplication(newEntity(key = "empty", secret = ""))
val found = dao.getBySecret("")?.application
assertNotNull(found)
assertEquals("empty", found?.key)
}
@Test
fun insertApplicationWithPermissions_encryptsAndReReadsPlaintext() = runBlocking {
val entity = newEntity(key = "permApp")
val perms = mutableListOf(
ApplicationPermissionsEntity(
id = null,
pkKey = "permApp",
type = "SIGN_EVENT",
kind = 1,
acceptable = true,
rememberType = 1,
acceptUntil = 0L,
rejectUntil = 0L,
),
)
dao.insertApplicationWithPermissions(
ApplicationWithPermissions(
application = entity,
permissions = perms,
),
)
val read = dao.getByKey("permApp")
assertNotNull(read)
assertEquals(sampleSecret, read?.application?.secret)
assertEquals(sampleLocalKey, read?.application?.localKey)
assertEquals(1, read?.permissions?.size)
}
@Test
fun getAll_decryptsAllRows() = runBlocking {
dao.insertApplication(newEntity(key = "a", secret = "secret-a", localKey = "11".repeat(32)))
dao.insertApplication(newEntity(key = "b", secret = "secret-b", localKey = "22".repeat(32)))
val rows = dao.getAll(pubKey)
assertEquals(2, rows.size)
val byKey = rows.associateBy { it.key }
assertEquals("secret-a", byKey["a"]?.secret)
assertEquals("secret-b", byKey["b"]?.secret)
assertEquals("11".repeat(32), byKey["a"]?.localKey)
assertEquals("22".repeat(32), byKey["b"]?.localKey)
}
private val dbName = "migration_test.db"
@get:Rule
val migrationHelper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(),
FrameworkSQLiteOpenHelperFactory(),
)
/**
* Verifies MIGRATION_18_19 envelope-encrypts existing plaintext columns.
*
* Creates a v18 DB (schema loaded from app/schemas/18.json), inserts
* plaintext `secret`/`localKey` rows directly via SQL, runs the migration
* to v19, then opens the DB via [AppDatabase] and asserts the DAO returns
* the original plaintext values while the raw columns now hold ciphertext.
*/
@Test
fun migration_18_19_encryptsPlaintextSecrets() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
context.deleteDatabase(dbName)
val v18 = migrationHelper.createDatabase(dbName, 18)
// v18 schema has the `application` table with `secret` and `localKey` TEXT columns.
v18.execSQL(
"INSERT INTO application " +
"(`key`, name, relays, url, icon, description, pubKey, isConnected, " +
"secret, useSecret, signPolicy, closeApplication, deleteAfter, lastUsed, localKey) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any>(
"migApp",
"Migration App",
"",
"",
"",
"",
pubKey,
0,
sampleSecret,
1,
1,
1,
0L,
0L,
sampleLocalKey,
),
)
v18.execSQL(
"INSERT INTO application " +
"(`key`, name, relays, url, icon, description, pubKey, isConnected, " +
"secret, useSecret, signPolicy, closeApplication, deleteAfter, lastUsed, localKey) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf<Any>(
"migAppEmpty",
"Empty App",
"",
"",
"",
"",
pubKey,
0,
"",
0,
1,
1,
0L,
0L,
"",
),
)
v18.close()
val migrated = migrationHelper.runMigrationsAndValidate(dbName, 19, false, MIGRATION_18_19)
// After migration the raw columns must be ciphertext for non-empty values
// and "" for the empty-sentinel row.
val rawCursor = migrated.query(
"SELECT `secret`, `localKey` FROM application WHERE `key` = ?",
arrayOf("migApp"),
)
rawCursor.use { c ->
assertTrue(c.moveToFirst())
val rawSecret = c.getString(0)
val rawLocalKey = c.getString(1)
assertNotEquals(sampleSecret, rawSecret)
assertNotEquals(sampleLocalKey, rawLocalKey)
assertEquals(sampleSecret, SecureCryptoHelper.decryptBlocking(rawSecret))
assertEquals(sampleLocalKey, SecureCryptoHelper.decryptBlocking(rawLocalKey))
}
val emptyCursor = migrated.query(
"SELECT `secret`, `localKey` FROM application WHERE `key` = ?",
arrayOf("migAppEmpty"),
)
emptyCursor.use { c ->
assertTrue(c.moveToFirst())
assertEquals("", c.getString(0))
assertEquals("", c.getString(1))
}
migrated.close()
context.deleteDatabase(dbName)
}
}
@@ -24,11 +24,27 @@ object SecureCryptoHelper {
private val mutex = Mutex() private val mutex = Mutex()
suspend fun encrypt(plainText: String): String = mutex.withLock { 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 key = getOrCreateSecretKey()
val cipher = Cipher.getInstance(TRANSFORMATION) val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key) cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv // System-generated, allowed val iv = cipher.iv
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
@@ -39,7 +55,11 @@ object SecureCryptoHelper {
return Base64.encodeToString(combined.array(), Base64.NO_WRAP) return Base64.encodeToString(combined.array(), Base64.NO_WRAP)
} }
suspend fun decrypt(encryptedText: String): String = mutex.withLock { /**
* Non-suspending equivalent of [decrypt]. See [encryptBlocking] for the
* rationale.
*/
fun decryptBlocking(encryptedText: String): String {
val key = getOrCreateSecretKey() val key = getOrCreateSecretKey()
val data = Base64.decode(encryptedText, Base64.NO_WRAP) val data = Base64.decode(encryptedText, Base64.NO_WRAP)
val buffer = ByteBuffer.wrap(data) val buffer = ByteBuffer.wrap(data)
@@ -9,6 +9,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.AmberLog import com.greenart7c3.nostrsigner.AmberLog
import com.greenart7c3.nostrsigner.SecureCryptoHelper
import java.util.concurrent.Executors import java.util.concurrent.Executors
val MIGRATION_1_2 = val MIGRATION_1_2 =
@@ -165,12 +166,61 @@ val MIGRATION_17_18 = object : Migration(17, 18) {
} }
} }
/**
* GHSA-5fjp-ghh8-wch8 (CWE-312): envelope-encrypt the two NIP-46 secret columns
* of the `application` table — `secret` (bunker shared secret) and `localKey`
* (a full Nostr private key used for the relay-side connection identity) —
* with the existing Keystore-backed AES-256-GCM key in [SecureCryptoHelper].
*
* Pre-migration these columns are ciphertext-unaware plaintext TEXT, so the
* migration reads each row, encrypts the non-empty values in place, and writes
* them back. Empty values stay `""` (sentinel — see
* [ApplicationEntity.encryptForStorage]) to preserve `WHERE localKey != ''`
* enumeration in `NotificationSubscription.kt:90-96`.
*
* Idempotency: Room migrations run exactly once per version bump. The
* migration is not safe to re-run on already-encrypted data (it would
* double-encrypt), so it must only run between schema 18 and 19.
*/
val MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.beginTransaction()
try {
val cursor = db.query("SELECT `key`, `secret`, `localKey` FROM application")
val updateStmt = db.compileStatement(
"UPDATE application SET `secret` = ?, `localKey` = ? WHERE `key` = ?",
)
cursor.use { c ->
while (c.moveToNext()) {
val key = c.getString(0)
val secret = c.getString(1)
val localKey = c.getString(2)
val newSecret = if (secret.isEmpty()) "" else SecureCryptoHelper.encryptBlocking(secret)
val newLocalKey = if (localKey.isEmpty()) "" else SecureCryptoHelper.encryptBlocking(localKey)
updateStmt.bindString(1, newSecret)
updateStmt.bindString(2, newLocalKey)
updateStmt.bindString(3, key)
updateStmt.executeUpdateDelete()
// Reuse the compiled statement for the next row.
updateStmt.clearBindings()
}
}
db.setTransactionSuccessful()
} catch (e: Exception) {
AmberLog.e(Amber.TAG, "MIGRATION_18_19: failed to envelope-encrypt application secrets", e)
throw e
} finally {
db.endTransaction()
}
}
}
@Database( @Database(
entities = [ entities = [
ApplicationEntity::class, ApplicationEntity::class,
ApplicationPermissionsEntity::class, ApplicationPermissionsEntity::class,
], ],
version = 18, version = 19,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -209,6 +259,7 @@ abstract class AppDatabase : RoomDatabase() {
.addMigrations(MIGRATION_15_16) .addMigrations(MIGRATION_15_16)
.addMigrations(MIGRATION_16_17) .addMigrations(MIGRATION_16_17)
.addMigrations(MIGRATION_17_18) .addMigrations(MIGRATION_17_18)
.addMigrations(MIGRATION_18_19)
.build() .build()
instance instance
@@ -7,21 +7,57 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import com.greenart7c3.nostrsigner.SecureCryptoHelper
/**
* Room DAO for the per-account `application` table.
*
* **At-rest envelope encryption** (GHSA-5fjp-ghh8-wch8, CWE-312): the two
* NIP-46 secret columns [ApplicationEntity.secret] and
* [ApplicationEntity.localKey] are persisted as Keystore-encrypted
* (`SecureCryptoHelper.encryptBlocking`) Base64 ciphertext. The non-`Raw`
* methods below are interface default methods that transparently encrypt on
* write and decrypt on read via the [ApplicationEntity.encryptForStorage] /
* [ApplicationEntity.decryptFromStorage] mappers, so every consumer of
* `ApplicationEntity` / `ApplicationWithPermissions` continues to see the
* plaintext values it already expects. The only at-rest reader is Room itself.
*
* The `*Raw` methods are Room-generated `@Query` / `@Insert` implementations
* operating on the raw (encrypted) column values. Callers must use the public
* default-method wrappers; the `Raw` methods are the persistence boundary.
*
* Sentinel: an empty `secret` or `localKey` is stored as `""` (not encrypted),
* matching `LocalPreferences.kt:661-681`'s WebDAV password idiom and preserving
* `WHERE localKey != ''` enumeration semantics in
* `NotificationSubscription.kt:90-96`.
*
* `getBySecret` cannot use a SQL `WHERE secret = :secret` predicate because
* GCM uses a random IV — the same plaintext produces a distinct ciphertext
* each call. The lookup is carried out in Kotlin by decrypting all rows of the
* (small, per-account) `application` table and filtering by the plaintext
* secret. Caller call-sites: `BunkerSingleEventHomeScreen.kt:83`,
* `BunkerConnectRequestScreen.kt:125`.
*/
@Dao @Dao
interface ApplicationDao { interface ApplicationDao {
@Query("SELECT signPolicy FROM application WHERE `key` = :key") @Query("SELECT signPolicy FROM application WHERE `key` = :key")
fun getSignPolicy(key: String): Int? fun getSignPolicy(key: String): Int?
@Query("SELECT * FROM application where pubKey = :pubKey order by name") @Query("SELECT * FROM application where pubKey = :pubKey order by name")
suspend fun getAll(pubKey: String): List<ApplicationEntity> suspend fun getAllRaw(pubKey: String): List<ApplicationEntity>
suspend fun getAll(pubKey: String): List<ApplicationEntity> = getAllRaw(pubKey).map { it.decryptFromStorage() }
@Query("SELECT * FROM application where isConnected = 0") @Query("SELECT * FROM application where isConnected = 0")
@Transaction @Transaction
suspend fun getAllNotConnected(): List<ApplicationWithPermissions> suspend fun getAllNotConnectedRaw(): List<ApplicationWithPermissions>
suspend fun getAllNotConnected(): List<ApplicationWithPermissions> = getAllNotConnectedRaw().map { it.decryptFromStorage() }
@Query("SELECT a.* FROM application a WHERE a.pubKey = :pubKey ORDER BY a.lastUsed DESC") @Query("SELECT a.* FROM application a WHERE a.pubKey = :pubKey ORDER BY a.lastUsed DESC")
fun getAllPaging(pubKey: String): PagingSource<Int, ApplicationEntity> fun getAllPagingRaw(pubKey: String): PagingSource<Int, ApplicationEntity>
fun getAllPaging(pubKey: String): PagingSource<Int, ApplicationEntity> = DecryptingPagingSource(getAllPagingRaw(pubKey))
@Query("SELECT DISTINCT relays FROM application") @Query("SELECT DISTINCT relays FROM application")
fun getAllRelayLists(): List<RelayListWrapper> fun getAllRelayLists(): List<RelayListWrapper>
@@ -31,22 +67,48 @@ interface ApplicationDao {
@Query("SELECT * FROM application WHERE `key` = :key") @Query("SELECT * FROM application WHERE `key` = :key")
@Transaction @Transaction
suspend fun getByKey(key: String): ApplicationWithPermissions? suspend fun getByKeyRaw(key: String): ApplicationWithPermissions?
suspend fun getByKey(key: String): ApplicationWithPermissions? = getByKeyRaw(key)?.decryptFromStorage()
@Query("SELECT * FROM application WHERE `key` = :key") @Query("SELECT * FROM application WHERE `key` = :key")
@Transaction @Transaction
fun getByKeySync(key: String): ApplicationWithPermissions? fun getByKeySyncRaw(key: String): ApplicationWithPermissions?
fun getByKeySync(key: String): ApplicationWithPermissions? = getByKeySyncRaw(key)?.decryptFromStorage()
@Query("SELECT * FROM application WHERE `name` = :name LIMIT 1") @Query("SELECT * FROM application WHERE `name` = :name LIMIT 1")
@Transaction @Transaction
suspend fun getByName(name: String): ApplicationWithPermissions? suspend fun getByNameRaw(name: String): ApplicationWithPermissions?
@Query("SELECT * FROM application WHERE secret = :secret") suspend fun getByName(name: String): ApplicationWithPermissions? = getByNameRaw(name)?.decryptFromStorage()
@Transaction
suspend fun getBySecret(secret: String): ApplicationWithPermissions? @Query("SELECT * FROM application")
suspend fun getAllApplicationsRaw(): List<ApplicationEntity>
/**
* Lookup by NIP-46 bunker connection `secret`. Cannot use SQL
* `WHERE secret = :secret` after envelope encryption (random GCM IV);
* decrypt all rows of the per-account table and filter in Kotlin.
* Tables are bounded by NIP-46 connection count (typically < 100).
*/
suspend fun getBySecret(secret: String): ApplicationWithPermissions? {
val matchKey = getAllApplicationsRaw().firstOrNull { row ->
if (row.secret.isEmpty()) {
secret.isEmpty()
} else {
runCatching { SecureCryptoHelper.decryptBlocking(row.secret) }.getOrDefault(null) == secret
}
}?.key ?: return null
// Eager-load the related permissions via the wrapped getByKey so callers
// receive the same shape the original @Transaction/SELECT joined query did.
return getByKey(matchKey)
}
@Query("SELECT * FROM application WHERE pubKey = :pubKey AND localKey != ''") @Query("SELECT * FROM application WHERE pubKey = :pubKey AND localKey != ''")
suspend fun getAllWithLocalKey(pubKey: String): List<ApplicationEntity> suspend fun getAllWithLocalKeyRaw(pubKey: String): List<ApplicationEntity>
suspend fun getAllWithLocalKey(pubKey: String): List<ApplicationEntity> = getAllWithLocalKeyRaw(pubKey).map { it.decryptFromStorage() }
@Query("UPDATE applicationPermission set acceptUntil = 0, rejectUntil = 0, rememberType = 0 where (acceptUntil < :time OR rejectUntil < :time) AND rememberType <> 4") @Query("UPDATE applicationPermission set acceptUntil = 0, rejectUntil = 0, rememberType = 0 where (acceptUntil < :time OR rejectUntil < :time) AND rememberType <> 4")
fun updateExpiredPermissions(time: Long) fun updateExpiredPermissions(time: Long)
@@ -107,13 +169,25 @@ interface ApplicationDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
@Transaction @Transaction
suspend fun insertApplication(event: ApplicationEntity): Long? suspend fun insertApplicationRaw(event: ApplicationEntity): Long?
suspend fun insertApplication(event: ApplicationEntity): Long? = insertApplicationRaw(event.encryptForStorage())
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(events: List<ApplicationEntity>): List<Long>? fun insertAllRaw(events: List<ApplicationEntity>): List<Long>?
fun insertAll(events: List<ApplicationEntity>): List<Long>? = insertAllRaw(events.map { it.encryptForStorage() })
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
suspend fun insertPermissions2Raw(permissions: List<ApplicationPermissionsEntity>): List<Long>?
suspend fun insertPermissions2(permissions: List<ApplicationPermissionsEntity>): List<Long>? = insertPermissions2Raw(permissions)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
@Transaction @Transaction
suspend fun insertPermissionsRaw(permissions: List<ApplicationPermissionsEntity>): List<Long>?
suspend fun insertPermissions(permissions: List<ApplicationPermissionsEntity>): List<Long>? { suspend fun insertPermissions(permissions: List<ApplicationPermissionsEntity>): List<Long>? {
permissions.forEach { permissions.forEach {
if (it.kind != null) { if (it.kind != null) {
@@ -132,12 +206,46 @@ interface ApplicationDao {
deletePermissions(it.pkKey, it.type) deletePermissions(it.pkKey, it.type)
} }
} }
return insertPermissions2(permissions) return insertPermissions2Raw(permissions)
} }
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction @Transaction
suspend fun insertPermissions2(permissions: List<ApplicationPermissionsEntity>): List<Long>? suspend fun insertApplicationWithPermissions(application: ApplicationWithPermissions) {
deletePermissions(application.application.key)
insertApplicationRaw(application.application.encryptForStorage())?.let {
application.permissions.forEach {
it.pkKey = application.application.key
}
insertPermissions(application.permissions)
}
}
@Delete
@Transaction
suspend fun deleteRaw(entity: ApplicationEntity)
suspend fun delete(entity: ApplicationEntity) = deleteRaw(entity)
@Query("DELETE FROM application WHERE `key` = :key")
@Transaction
suspend fun delete(key: String)
@Query("UPDATE application SET lastUsed = :time where `key` = :key")
@Transaction
suspend fun updateLastUsed(key: String, time: Long)
@Query("UPDATE application SET name = :name, icon = :icon WHERE `key` = :key")
@Transaction
suspend fun updateNameAndIcon(key: String, name: String, icon: String)
@Delete
@Transaction
suspend fun deletePermission(permission: ApplicationPermissionsEntity)
@Query("DELETE FROM application WHERE deleteAfter < :time AND deleteAfter > 0")
@Transaction
suspend fun deleteOldApplications(time: Long): Int
@Query("DELETE FROM applicationPermission WHERE pkKey = :key") @Query("DELETE FROM applicationPermission WHERE pkKey = :key")
@Transaction @Transaction
@@ -174,41 +282,4 @@ interface ApplicationDao {
type: String, type: String,
kind: Int, kind: Int,
) )
@Insert(onConflict = OnConflictStrategy.IGNORE)
@Transaction
suspend fun insertApplicationWithPermissions(application: ApplicationWithPermissions) {
deletePermissions(application.application.key)
insertApplication(application.application)?.let {
application.permissions.forEach {
it.pkKey = application.application.key
}
insertPermissions(application.permissions)
}
}
@Delete
@Transaction
suspend fun delete(entity: ApplicationEntity)
@Query("DELETE FROM application WHERE `key` = :key")
@Transaction
suspend fun delete(key: String)
@Query("UPDATE application SET lastUsed = :time where `key` = :key")
@Transaction
suspend fun updateLastUsed(key: String, time: Long)
@Query("UPDATE application SET name = :name, icon = :icon WHERE `key` = :key")
@Transaction
suspend fun updateNameAndIcon(key: String, name: String, icon: String)
@Delete
@Transaction
suspend fun deletePermission(permission: ApplicationPermissionsEntity)
@Query("DELETE FROM application WHERE deleteAfter < :time AND deleteAfter > 0")
@Transaction
suspend fun deleteOldApplications(time: Long): Int
} }
@@ -0,0 +1,92 @@
package com.greenart7c3.nostrsigner.database
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.AmberLog
import com.greenart7c3.nostrsigner.SecureCryptoHelper
/**
* Envelope-encrypts / envelope-decrypts the two NIP-46 secret columns of
* [ApplicationEntity] ([secret] and [localKey]) using the Keystore-backed
* AES-256-GCM key in [SecureCryptoHelper].
*
* Mitigates GHSA-5fjp-ghh8-wch8 (CWE-312): both columns previously lived as
* cleartext Room TEXT on the per-account `amber_db_<npub>` database, so any
* reader of the app's internal storage (rooted device, privilege-escalating
* malware, or a future bug exporting the DB) recovered every bunker connection
* secret and every locally generated connection private key.
*
* Sentinel rule for empty values: `""` stays `""` at rest. Mirrors the WebDAV
* password idiom at `LocalPreferences.kt:661-681` and preserves:
* - `ApplicationDao` `WHERE localKey != ''` semantics
* (used by `NotificationSubscription.kt:90-96` to enumerate connections).
* - `ApplicationEntity.localPubKey` derivation
* (`if (localKey.isNotEmpty()) localPubKeyFromPrivKey(localKey) else ""`).
* - `ApplicationEntity.shouldShowRelays` (`secret.isNotEmpty()`).
*
* The mappers are applied at the DAO boundary so every consumer of
* `ApplicationEntity` / `ApplicationWithPermissions` continues to see the
* plaintext values it already expects. The only at-rest reader is Room itself.
*
* Failure mode: if [SecureCryptoHelper.decryptBlocking] throws (e.g. the
* AndroidKeyStore key has become permanently unusable after a device KeyMint
* upgrade), the row is returned with the raw ciphertext in place. The caller
* will then treat the secret as a non-matching string (lookup miss) and the
* corrupted localKey as a non-hex value, which surfaces as a connection
* failure rather than a crash — matching the `InvalidKeyException`/log-and-skip
* convention in `LocalPreferences.loadFromEncryptedStorage`.
*/
private fun String.encryptField(): String = if (this.isEmpty()) this else SecureCryptoHelper.encryptBlocking(this)
private fun String.decryptField(): String {
if (this.isEmpty()) return this
return try {
SecureCryptoHelper.decryptBlocking(this)
} catch (e: Exception) {
// Leave the ciphertext in place rather than crashing the DAO read.
// See class kdoc for the recovery contract.
AmberLog.w(Amber.TAG, "ApplicationEntityCrypto: failed to decrypt field", e)
this
}
}
/** Encrypts [secret] and [localKey] for Room persistence. Inverse of [decryptFromStorage]. */
fun ApplicationEntity.encryptForStorage(): ApplicationEntity = copy(
secret = secret.encryptField(),
localKey = localKey.encryptField(),
)
/** Decrypts [secret] and [localKey] after a Room read. Inverse of [encryptForStorage]. */
fun ApplicationEntity.decryptFromStorage(): ApplicationEntity = copy(
secret = secret.decryptField(),
localKey = localKey.decryptField(),
)
/** Decrypts the embedded [ApplicationWithPermissions.application] after a Room read. */
fun ApplicationWithPermissions.decryptFromStorage(): ApplicationWithPermissions = copy(application = application.decryptFromStorage())
/**
* Wraps a raw (encrypted-column) [PagingSource] and decrypts every
* [ApplicationEntity] emitted by a successful [LoadResult.Page]. Error and
* Invalid results are passed through unchanged. Used by
* [ApplicationDao.getAllPaging] so the `ApplicationsScreen` Pager sees
* plaintext entities exactly like the non-paging DAO reads.
*/
internal class DecryptingPagingSource(
private val delegate: PagingSource<Int, ApplicationEntity>,
) : PagingSource<Int, ApplicationEntity>() {
override fun getRefreshKey(state: PagingState<Int, ApplicationEntity>): Int? = delegate.getRefreshKey(state)
override suspend fun load(params: PagingSource.LoadParams<Int>): PagingSource.LoadResult<Int, ApplicationEntity> = when (val result = delegate.load(params)) {
is PagingSource.LoadResult.Page -> PagingSource.LoadResult.Page(
data = result.data.map { it.decryptFromStorage() },
prevKey = result.prevKey,
nextKey = result.nextKey,
itemsBefore = result.itemsBefore,
itemsAfter = result.itemsAfter,
)
is PagingSource.LoadResult.Error -> result
is PagingSource.LoadResult.Invalid -> result
}
}
@@ -230,6 +230,43 @@ class CachingApplicationDao(
invalidateApp(key) invalidateApp(key)
} }
// -------- *Raw delegations (envelope-encrypted persistence boundary) --------
//
// ApplicationDao now splits methods that touch the [ApplicationEntity.secret]
// / [ApplicationEntity.localKey] columns into a Room-generated `*Raw`
// (encrypted column values) and a public default-method wrapper that
// envelope-encrypts/decrypts via [ApplicationEntity.encryptForStorage].
// CachingApplicationDao is a decorator over a Room-generated ApplicationDao,
// so its `*Raw` overrides simply forward to [delegate] — they are never
// invoked from the cache layer (the public wrappers above are the entry
// points), but Kotlin requires every interface member to be implemented.
override suspend fun getAllRaw(pubKey: String): List<ApplicationEntity> = delegate.getAllRaw(pubKey)
override suspend fun getAllNotConnectedRaw(): List<ApplicationWithPermissions> = delegate.getAllNotConnectedRaw()
override fun getAllPagingRaw(pubKey: String): PagingSource<Int, ApplicationEntity> = delegate.getAllPagingRaw(pubKey)
override suspend fun getByKeyRaw(key: String): ApplicationWithPermissions? = delegate.getByKeyRaw(key)
override fun getByKeySyncRaw(key: String): ApplicationWithPermissions? = delegate.getByKeySyncRaw(key)
override suspend fun getByNameRaw(name: String): ApplicationWithPermissions? = delegate.getByNameRaw(name)
override suspend fun getAllApplicationsRaw(): List<ApplicationEntity> = delegate.getAllApplicationsRaw()
override suspend fun getAllWithLocalKeyRaw(pubKey: String): List<ApplicationEntity> = delegate.getAllWithLocalKeyRaw(pubKey)
override suspend fun insertApplicationRaw(event: ApplicationEntity): Long? = delegate.insertApplicationRaw(event)
override fun insertAllRaw(events: List<ApplicationEntity>): List<Long>? = delegate.insertAllRaw(events)
override suspend fun insertPermissions2Raw(permissions: List<ApplicationPermissionsEntity>): List<Long>? = delegate.insertPermissions2Raw(permissions)
override suspend fun insertPermissionsRaw(permissions: List<ApplicationPermissionsEntity>): List<Long>? = delegate.insertPermissionsRaw(permissions)
override suspend fun deleteRaw(entity: ApplicationEntity) = delegate.deleteRaw(entity)
companion object { companion object {
private const val MAX_ENTRIES = 512 private const val MAX_ENTRIES = 512
} }
+1
View File
@@ -59,6 +59,7 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomKtx
room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomKtx" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomKtx" }
room-paging = { module = "androidx.room:room-paging", version.ref = "roomKtx" } room-paging = { module = "androidx.room:room-paging", version.ref = "roomKtx" }
room-testing = { module = "androidx.room:room-testing", version.ref = "roomKtx" }
security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoKtx" } security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCryptoKtx" }
security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" } security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" }
ui = { module = "androidx.compose.ui:ui", version.ref = "compose_ui" } ui = { module = "androidx.compose.ui:ui", version.ref = "compose_ui" }