From c37f5291e831bfb3d740f84ffe11b059346f1ff1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:21:23 +0000 Subject: [PATCH] Store encrypt/decrypt payloads as ciphertext, decrypt on demand History/activity rows previously persisted the plaintext of every NIP-04/NIP-44(/v3) encrypt and decrypt operation (encrypt = plaintext input, decrypt = plaintext output) and the decrypted private zap. That left sensitive cleartext at rest in history_db_$npub. Now the encrypted form is stored instead and the plaintext is recovered on demand only when the activity/history screen renders a row: - Encrypt requests store their ciphertext output; decrypt requests store the ciphertext input that arrived in the request. Rejected encrypts store nothing (no ciphertext exists, so no plaintext is leaked). - HistoryEntity gains encryptionPubKey + encryptionScope; the existing kind column carries the NIP-44 v3 kind. ActivityRow decrypts via account.decrypt / nip44v3Decrypt / decryptZapEvent, falling back to the stored value for pre-migration rows or missing context. - Ciphertext rows are exempted from content truncation so they remain decryptable. Covers all three ingestion paths (ContentProvider/relay via SignerProviderQuery, nostrsigner:// intents, NIP-46 bunker approvals) and both accept and reject flows. Adds HistoryDatabase migration 3->4. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WABMihCLr9XN51uLWj93oX --- .../4.json | 123 ++++++++++++++++++ .../nostrsigner/SignerProviderQuery.kt | 32 ++--- .../nostrsigner/database/HistoryDao.kt | 6 +- .../nostrsigner/database/HistoryDatabase.kt | 11 +- .../nostrsigner/database/HistoryEntity.kt | 6 + .../nostrsigner/models/EncryptedDataKind.kt | 13 ++ .../nostrsigner/service/BunkerRequestUtils.kt | 52 +++++--- .../nostrsigner/service/IntentUtils.kt | 53 +++++--- .../nostrsigner/ui/ActivitiesScreen.kt | 50 ++++++- 9 files changed, 288 insertions(+), 58 deletions(-) create mode 100644 app/schemas/com.greenart7c3.nostrsigner.database.HistoryDatabase/4.json diff --git a/app/schemas/com.greenart7c3.nostrsigner.database.HistoryDatabase/4.json b/app/schemas/com.greenart7c3.nostrsigner.database.HistoryDatabase/4.json new file mode 100644 index 00000000..9a176821 --- /dev/null +++ b/app/schemas/com.greenart7c3.nostrsigner.database.HistoryDatabase/4.json @@ -0,0 +1,123 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9c7e0cf73a7c502f89cfc6dc97b0b56c", + "entities": [ + { + "tableName": "history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pkKey` TEXT NOT NULL, `type` TEXT NOT NULL, `kind` INTEGER, `time` INTEGER NOT NULL, `accepted` INTEGER NOT NULL, `translatedPermission` TEXT NOT NULL, `content` TEXT NOT NULL, `encryptionPubKey` TEXT NOT NULL, `encryptionScope` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pkKey", + "columnName": "pkKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER" + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accepted", + "columnName": "accepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translatedPermission", + "columnName": "translatedPermission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptionPubKey", + "columnName": "encryptionPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptionScope", + "columnName": "encryptionScope", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "history_by_pk_key", + "unique": false, + "columnNames": [ + "pkKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `history_by_pk_key` ON `${TABLE_NAME}` (`pkKey`)" + }, + { + "name": "history_by_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `history_by_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "history_by_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `history_by_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "history_by_key_and_time", + "unique": false, + "columnNames": [ + "pkKey", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `history_by_key_and_time` ON `${TABLE_NAME}` (`pkKey`, `time`)" + } + ] + } + ], + "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, '9c7e0cf73a7c502f89cfc6dc97b0b56c')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt index e0c774ed..377d4ae9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProviderQuery.kt @@ -8,6 +8,7 @@ import com.greenart7c3.nostrsigner.database.HistoryEntity import com.greenart7c3.nostrsigner.database.LogEntity import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.SignerType +import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.permissionTypeFromContent import com.greenart7c3.nostrsigner.service.AmberUtils @@ -50,14 +51,6 @@ object SignerProviderQuery { } } - // Decodes the Base64 v3 wire value to readable plaintext for history. - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) - private fun nip44v3Plaintext(wireValue: String): String = try { - kotlin.io.encoding.Base64.decode(wireValue).toString(Charsets.UTF_8) - } catch (_: Exception) { - wireValue - } - /** * Runs a signer operation and returns its result as a [Cursor], mirroring the * column layout the [SignerProvider] ContentProvider exposes to external apps. @@ -367,6 +360,11 @@ object SignerProviderQuery { val signPolicy = permDao.getSignPolicy(requesterId) val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null if (!isRemembered) { + // A rejected encrypt request was never performed, so no + // ciphertext exists — store nothing rather than leaking the + // plaintext input. Decrypt requests carry their ciphertext as + // input, so persist it and decrypt on demand. + val rejectedContent = if (isEncrypt) "" else content scope.launch { historyDatabase.dao().addHistory( listOf( @@ -377,7 +375,9 @@ object SignerProviderQuery { v3Kind, TimeUtils.now(), false, - content = content, + content = rejectedContent, + encryptionPubKey = if (rejectedContent.isNotEmpty() && type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT) pubkey else "", + encryptionScope = if (isV3) v3Scope else "", ), ), account.npub, @@ -420,13 +420,11 @@ object SignerProviderQuery { "Could not decrypt the message" } - // For v3 the wire value is Base64; this auto-accept path has no - // EncryptedDataKind, so decode it once for the readable log. - val historyContent = if (isV3) { - nip44v3Plaintext(if (!isEncrypt) finalResult else content) - } else { - if (!isEncrypt) finalResult else content - } + // Persist the encrypted form (ciphertext), never the plaintext: + // encrypt requests store their ciphertext output (`finalResult`), + // decrypt requests store the ciphertext input (`content`). The + // plaintext is recovered on demand in the activity/history UI. + val historyContent = if (isEncrypt) finalResult else content scope.launch { historyDatabase.dao().addHistory( listOf( @@ -438,6 +436,8 @@ object SignerProviderQuery { TimeUtils.now(), true, content = historyContent, + encryptionPubKey = if (type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT) pubkey else "", + encryptionScope = if (isV3) v3Scope else "", ), ), account.npub, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDao.kt index 8018f021..d5239929 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDao.kt @@ -12,6 +12,7 @@ import androidx.room.Transaction import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.AmberLog import com.greenart7c3.nostrsigner.models.Permission +import com.greenart7c3.nostrsigner.models.encryptDecryptHistoryTypeNames import com.vitorpamplona.quartz.utils.TimeUtils private const val MAX_CONTENT_LENGTH = 500 @@ -110,9 +111,12 @@ interface HistoryDao { try { val localEntities = entities.map { entity -> val permission = Permission(entity.type.toLowerCase(Locale.current), entity.kind) + // Ciphertext stored for encrypt/decrypt rows must be kept whole so it + // can still be decrypted on demand; only truncate plaintext content. + val isEncryptedContent = entity.type in encryptDecryptHistoryTypeNames entity.copy( translatedPermission = permission.toLocalizedString(Amber.instance, true), - content = if (entity.content.length > MAX_CONTENT_LENGTH) entity.content.take(MAX_CONTENT_LENGTH) else entity.content, + content = if (!isEncryptedContent && entity.content.length > MAX_CONTENT_LENGTH) entity.content.take(MAX_CONTENT_LENGTH) else entity.content, ) } innerAddHistory(localEntities) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDatabase.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDatabase.kt index f9909441..eb7a2a5c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDatabase.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryDatabase.kt @@ -21,11 +21,18 @@ val migration_2_3 = object : Migration(2, 3) { } } +val migration_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE history ADD COLUMN encryptionPubKey TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE history ADD COLUMN encryptionScope TEXT NOT NULL DEFAULT ''") + } +} + @Database( entities = [ HistoryEntity::class, ], - version = 3, + version = 4, ) @TypeConverters(Converters::class) abstract class HistoryDatabase : RoomDatabase() { @@ -47,7 +54,7 @@ abstract class HistoryDatabase : RoomDatabase() { ) .setQueryExecutor(executor) .setTransactionExecutor(transactionExecutor) - .addMigrations(migration_1_2, migration_2_3) + .addMigrations(migration_1_2, migration_2_3, migration_3_4) .build() instance diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryEntity.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryEntity.kt index f7ff9050..cb569d1c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryEntity.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/HistoryEntity.kt @@ -34,5 +34,11 @@ data class HistoryEntity( val time: Long, val accepted: Boolean, val translatedPermission: String = "", + // For encrypt/decrypt requests `content` holds the ciphertext (the encrypted + // form that was sent or received), never the plaintext. The plaintext is + // recovered on demand in the activity/history UI via [encryptionPubKey] (the + // counterparty key) and, for NIP-44 v3, [kind] + [encryptionScope]. val content: String = "", + val encryptionPubKey: String = "", + val encryptionScope: String = "", ) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt index b82cf9c5..f1cc65d2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt @@ -48,6 +48,19 @@ val encryptDecryptSignerTypes = setOf( SignerType.DECRYPT_ZAP_EVENT, ) +val encryptSignerTypes = setOf( + SignerType.NIP04_ENCRYPT, + SignerType.NIP44_ENCRYPT, + SignerType.NIP44_V3_ENCRYPT, +) + +/** + * History rows for these request types store the ciphertext (encrypted form) in + * `content` rather than the plaintext, so the value must never be truncated — + * a partial ciphertext can no longer be decrypted on demand. + */ +val encryptDecryptHistoryTypeNames: Set = encryptDecryptSignerTypes.mapTo(mutableSetOf()) { it.name } + /** * Determines the permission type string based on content string and operation direction. * For ENCRYPT: pass the plaintext to classify what kind of data is being encrypted. diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt index a0fe842e..482a82cc 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -16,7 +16,8 @@ import com.greenart7c3.nostrsigner.models.AmberBunkerRequest import com.greenart7c3.nostrsigner.models.EncryptionType import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.SignerType -import com.greenart7c3.nostrsigner.models.nip44v3Plaintext +import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes +import com.greenart7c3.nostrsigner.models.encryptSignerTypes import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.ui.RememberType @@ -455,28 +456,35 @@ object BunkerRequestUtils { // assume that everything worked and try to revert it if it fails EventNotificationConsumer(context).notificationManager().cancelAll() dao.insertApplicationWithPermissions(application) + val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT + // Persist the encrypted form (ciphertext), never the plaintext: encrypt + // requests store their ciphertext output (`response`), decrypt requests + // store the ciphertext input that arrived in the request. The plaintext + // is recovered on demand in the activity/history UI. + val historyContent = when (type) { + SignerType.NIP04_ENCRYPT, + SignerType.NIP44_ENCRYPT, + SignerType.NIP44_V3_ENCRYPT, + SignerType.SIGN_EVENT, + -> response + else -> getDataFromBunker(bunkerRequest.request) + } historyDatabase.dao().addHistory( listOf( HistoryEntity( 0, key, type.toString(), - kind, + if (isV3) getNip44v3Kind(bunkerRequest.request) else kind, TimeUtils.now(), true, - content = when (type) { - SignerType.SIGN_EVENT, - SignerType.NIP04_DECRYPT, - SignerType.NIP44_DECRYPT, - SignerType.DECRYPT_ZAP_EVENT, - -> response - // v3 wire values are Base64; log the readable plaintext - // already decoded into encryptedData. - SignerType.NIP44_V3_ENCRYPT, - SignerType.NIP44_V3_DECRYPT, - -> bunkerRequest.encryptedData.nip44v3Plaintext() - else -> getDataFromBunker(bunkerRequest.request) + content = historyContent, + encryptionPubKey = if (type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT) { + bunkerRequest.request.params.firstOrNull() ?: "" + } else { + "" }, + encryptionScope = if (isV3) getNip44v3Scope(bunkerRequest.request) else "", ), ), account.npub, @@ -630,16 +638,28 @@ object BunkerRequestUtils { if (bunkerRequest.request !is BunkerRequestConnect) { Amber.instance.dao(account.npub).insertApplicationWithPermissions(application) + val isV3 = signerType == SignerType.NIP44_V3_ENCRYPT || signerType == SignerType.NIP44_V3_DECRYPT + // A rejected encrypt request was never performed, so no ciphertext + // exists — store nothing rather than leaking the plaintext input. + // Decrypt requests carry their ciphertext in the request, so it is + // safe to persist and decrypt on demand. + val historyContent = if (signerType in encryptSignerTypes) "" else getDataFromBunker(bunkerRequest.request) Amber.instance.getHistoryDatabase(account.npub).dao().addHistory( listOf( HistoryEntity( 0, key, signerType.toString(), - null, + if (isV3) getNip44v3Kind(bunkerRequest.request) else null, TimeUtils.now(), false, - content = getDataFromBunker(bunkerRequest.request), + content = historyContent, + encryptionPubKey = if (historyContent.isNotEmpty() && signerType in encryptDecryptSignerTypes && signerType != SignerType.DECRYPT_ZAP_EVENT) { + bunkerRequest.request.params.firstOrNull() ?: "" + } else { + "" + }, + encryptionScope = if (isV3) getNip44v3Scope(bunkerRequest.request) else "", ), ), account.npub, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 56d34658..146af8cf 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -35,7 +35,8 @@ import com.greenart7c3.nostrsigner.models.ReturnType import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind import com.greenart7c3.nostrsigner.models.containsNip -import com.greenart7c3.nostrsigner.models.nip44v3Plaintext +import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes +import com.greenart7c3.nostrsigner.models.encryptSignerTypes import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.ui.RememberType import com.greenart7c3.nostrsigner.ui.components.DecryptTypeScope @@ -900,6 +901,19 @@ object IntentUtils { // immediately, not only on a later request. persistNativeAppMetadata(context, account, packageName) val historyDatabase = Amber.instance.getHistoryDatabase(account.npub) + val isV3 = intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT + // Persist the encrypted form (ciphertext), never the plaintext: + // encrypt requests store their ciphertext output (`value`), decrypt + // requests store the ciphertext input (`intentData.data`). The + // plaintext is recovered on demand in the activity/history UI. + val historyContent = when (intentData.type) { + SignerType.SIGN_EVENT -> event + SignerType.NIP04_ENCRYPT, + SignerType.NIP44_ENCRYPT, + SignerType.NIP44_V3_ENCRYPT, + -> value + else -> intentData.data + } Amber.instance.applicationIOScope.launch { historyDatabase.dao().addHistory( listOf( @@ -907,24 +921,16 @@ object IntentUtils { 0, key, intentData.type.toString(), - kind, + if (isV3) intentData.nip44v3Kind else kind, TimeUtils.now(), true, - content = when (intentData.type) { - SignerType.SIGN_EVENT -> event - SignerType.NIP04_DECRYPT, - SignerType.NIP44_DECRYPT, - SignerType.DECRYPT_ZAP_EVENT, - -> value - - // v3 wire values are Base64; log the readable - // plaintext already decoded into encryptedData. - SignerType.NIP44_V3_ENCRYPT, - SignerType.NIP44_V3_DECRYPT, - -> intentData.encryptedData.nip44v3Plaintext() - - else -> intentData.data + content = historyContent, + encryptionPubKey = if (intentData.type in encryptDecryptSignerTypes && intentData.type != SignerType.DECRYPT_ZAP_EVENT) { + intentData.pubKey + } else { + "" }, + encryptionScope = if (isV3) intentData.nip44v3Scope else "", ), ), account.npub, @@ -1058,16 +1064,27 @@ object IntentUtils { } Amber.instance.dao(account.npub).insertApplicationWithPermissions(application) + val isV3 = intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT + // A rejected encrypt request was never performed, so no ciphertext exists — + // store nothing rather than leaking the plaintext input. Decrypt requests + // carry their ciphertext in the request, so persist it and decrypt on demand. + val historyContent = if (intentData.type in encryptSignerTypes) "" else intentData.data Amber.instance.getHistoryDatabase(account.npub).dao().addHistory( listOf( HistoryEntity( 0, key, intentData.type.toString(), - kind, + if (isV3) intentData.nip44v3Kind else kind, TimeUtils.now(), false, - content = intentData.data, + content = historyContent, + encryptionPubKey = if (historyContent.isNotEmpty() && intentData.type in encryptDecryptSignerTypes && intentData.type != SignerType.DECRYPT_ZAP_EVENT) { + intentData.pubKey + } else { + "" + }, + encryptionScope = if (isV3) intentData.nip44v3Scope else "", ), ), account.npub, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ActivitiesScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ActivitiesScreen.kt index d8018b00..c3ee5068 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/ActivitiesScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/ActivitiesScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -51,6 +52,7 @@ import com.greenart7c3.nostrsigner.AmberLog import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.database.HistoryEntity import com.greenart7c3.nostrsigner.models.Account +import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.TimeUtils import com.greenart7c3.nostrsigner.models.supportedKindNumbers import com.greenart7c3.nostrsigner.service.ApplicationNameCache @@ -188,14 +190,52 @@ fun ActivitiesScreen( } } +/** + * Recovers the readable plaintext for a history row on demand. Encrypt/decrypt + * rows persist only the ciphertext, so the plaintext is derived here using the + * stored counterparty key (and, for NIP-44 v3, the kind + scope). Falls back to + * the stored content if the row is not encrypted or decryption fails (e.g. old + * rows created before ciphertext-at-rest, or a missing counterparty key). + */ +suspend fun decryptHistoryContent(activity: HistoryEntity, account: Account): String { + if (activity.content.isBlank()) return "" + return try { + when (activity.type) { + SignerType.NIP04_ENCRYPT.name, SignerType.NIP44_ENCRYPT.name, + SignerType.NIP04_DECRYPT.name, SignerType.NIP44_DECRYPT.name, + -> + if (activity.encryptionPubKey.isBlank()) { + activity.content + } else { + account.decrypt(activity.content, activity.encryptionPubKey) + } + SignerType.NIP44_V3_ENCRYPT.name, SignerType.NIP44_V3_DECRYPT.name -> { + val kind = activity.kind + if (activity.encryptionPubKey.isBlank() || kind == null) { + activity.content + } else { + account.nip44v3Decrypt(activity.content, activity.encryptionPubKey, kind, activity.encryptionScope).decodeToString() + } + } + SignerType.DECRYPT_ZAP_EVENT.name -> account.decryptZapEvent(activity.content) ?: activity.content + else -> activity.content + } + } catch (_: Exception) { + activity.content + } +} + @Composable fun ActivityRow(activity: HistoryEntity, account: Account) { val clipboard = LocalClipboard.current - val parsedEvent = remember(activity.content) { - if (activity.content.isBlank()) { + val displayContent by produceState(initialValue = "", activity.id, activity.content) { + value = withContext(Dispatchers.IO) { decryptHistoryContent(activity, account) } + } + val parsedEvent = remember(displayContent) { + if (displayContent.isBlank()) { null } else { - runCatching { AmberEvent.fromJson(activity.content).toEvent() }.getOrNull() + runCatching { AmberEvent.fromJson(displayContent).toEvent() }.getOrNull() } } @@ -250,11 +290,11 @@ fun ActivityRow(activity: HistoryEntity, account: Account) { }, ) } - } else if (activity.content.isNotBlank()) { + } else if (displayContent.isNotBlank()) { Spacer(Modifier.height(4.dp)) Text( modifier = Modifier.padding(top = 2.dp), - text = activity.content, + text = displayContent, maxLines = 3, overflow = TextOverflow.Ellipsis, fontSize = 14.sp,