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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WABMihCLr9XN51uLWj93oX
This commit is contained in:
Claude
2026-06-19 10:21:23 +00:00
parent 802e87adf5
commit c37f5291e8
9 changed files with 288 additions and 58 deletions
@@ -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')"
]
}
}
@@ -8,6 +8,7 @@ import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.database.LogEntity import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.encryptDecryptSignerTypes
import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.models.permissionTypeFromContent import com.greenart7c3.nostrsigner.models.permissionTypeFromContent
import com.greenart7c3.nostrsigner.service.AmberUtils 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 * Runs a signer operation and returns its result as a [Cursor], mirroring the
* column layout the [SignerProvider] ContentProvider exposes to external apps. * column layout the [SignerProvider] ContentProvider exposes to external apps.
@@ -367,6 +360,11 @@ object SignerProviderQuery {
val signPolicy = permDao.getSignPolicy(requesterId) val signPolicy = permDao.getSignPolicy(requesterId)
val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null val isRemembered = IntentUtils.isRemembered(signPolicy, permission) ?: return null
if (!isRemembered) { 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 { scope.launch {
historyDatabase.dao().addHistory( historyDatabase.dao().addHistory(
listOf( listOf(
@@ -377,7 +375,9 @@ object SignerProviderQuery {
v3Kind, v3Kind,
TimeUtils.now(), TimeUtils.now(),
false, 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, account.npub,
@@ -420,13 +420,11 @@ object SignerProviderQuery {
"Could not decrypt the message" "Could not decrypt the message"
} }
// For v3 the wire value is Base64; this auto-accept path has no // Persist the encrypted form (ciphertext), never the plaintext:
// EncryptedDataKind, so decode it once for the readable log. // encrypt requests store their ciphertext output (`finalResult`),
val historyContent = if (isV3) { // decrypt requests store the ciphertext input (`content`). The
nip44v3Plaintext(if (!isEncrypt) finalResult else content) // plaintext is recovered on demand in the activity/history UI.
} else { val historyContent = if (isEncrypt) finalResult else content
if (!isEncrypt) finalResult else content
}
scope.launch { scope.launch {
historyDatabase.dao().addHistory( historyDatabase.dao().addHistory(
listOf( listOf(
@@ -438,6 +436,8 @@ object SignerProviderQuery {
TimeUtils.now(), TimeUtils.now(),
true, true,
content = historyContent, content = historyContent,
encryptionPubKey = if (type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT) pubkey else "",
encryptionScope = if (isV3) v3Scope else "",
), ),
), ),
account.npub, account.npub,
@@ -12,6 +12,7 @@ import androidx.room.Transaction
import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.AmberLog import com.greenart7c3.nostrsigner.AmberLog
import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.encryptDecryptHistoryTypeNames
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
private const val MAX_CONTENT_LENGTH = 500 private const val MAX_CONTENT_LENGTH = 500
@@ -110,9 +111,12 @@ interface HistoryDao {
try { try {
val localEntities = entities.map { entity -> val localEntities = entities.map { entity ->
val permission = Permission(entity.type.toLowerCase(Locale.current), entity.kind) 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( entity.copy(
translatedPermission = permission.toLocalizedString(Amber.instance, true), 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) innerAddHistory(localEntities)
@@ -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( @Database(
entities = [ entities = [
HistoryEntity::class, HistoryEntity::class,
], ],
version = 3, version = 4,
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class HistoryDatabase : RoomDatabase() { abstract class HistoryDatabase : RoomDatabase() {
@@ -47,7 +54,7 @@ abstract class HistoryDatabase : RoomDatabase() {
) )
.setQueryExecutor(executor) .setQueryExecutor(executor)
.setTransactionExecutor(transactionExecutor) .setTransactionExecutor(transactionExecutor)
.addMigrations(migration_1_2, migration_2_3) .addMigrations(migration_1_2, migration_2_3, migration_3_4)
.build() .build()
instance instance
@@ -34,5 +34,11 @@ data class HistoryEntity(
val time: Long, val time: Long,
val accepted: Boolean, val accepted: Boolean,
val translatedPermission: String = "", 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 content: String = "",
val encryptionPubKey: String = "",
val encryptionScope: String = "",
) )
@@ -48,6 +48,19 @@ val encryptDecryptSignerTypes = setOf(
SignerType.DECRYPT_ZAP_EVENT, 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<String> = encryptDecryptSignerTypes.mapTo(mutableSetOf()) { it.name }
/** /**
* Determines the permission type string based on content string and operation direction. * 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. * For ENCRYPT: pass the plaintext to classify what kind of data is being encrypted.
@@ -16,7 +16,8 @@ import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.EncryptionType import com.greenart7c3.nostrsigner.models.EncryptionType
import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.SignerType 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.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType import com.greenart7c3.nostrsigner.ui.RememberType
@@ -455,28 +456,35 @@ object BunkerRequestUtils {
// assume that everything worked and try to revert it if it fails // assume that everything worked and try to revert it if it fails
EventNotificationConsumer(context).notificationManager().cancelAll() EventNotificationConsumer(context).notificationManager().cancelAll()
dao.insertApplicationWithPermissions(application) 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( historyDatabase.dao().addHistory(
listOf( listOf(
HistoryEntity( HistoryEntity(
0, 0,
key, key,
type.toString(), type.toString(),
kind, if (isV3) getNip44v3Kind(bunkerRequest.request) else kind,
TimeUtils.now(), TimeUtils.now(),
true, true,
content = when (type) { content = historyContent,
SignerType.SIGN_EVENT, encryptionPubKey = if (type in encryptDecryptSignerTypes && type != SignerType.DECRYPT_ZAP_EVENT) {
SignerType.NIP04_DECRYPT, bunkerRequest.request.params.firstOrNull() ?: ""
SignerType.NIP44_DECRYPT, } else {
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)
}, },
encryptionScope = if (isV3) getNip44v3Scope(bunkerRequest.request) else "",
), ),
), ),
account.npub, account.npub,
@@ -630,16 +638,28 @@ object BunkerRequestUtils {
if (bunkerRequest.request !is BunkerRequestConnect) { if (bunkerRequest.request !is BunkerRequestConnect) {
Amber.instance.dao(account.npub).insertApplicationWithPermissions(application) 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( Amber.instance.getHistoryDatabase(account.npub).dao().addHistory(
listOf( listOf(
HistoryEntity( HistoryEntity(
0, 0,
key, key,
signerType.toString(), signerType.toString(),
null, if (isV3) getNip44v3Kind(bunkerRequest.request) else null,
TimeUtils.now(), TimeUtils.now(),
false, 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, account.npub,
@@ -35,7 +35,8 @@ import com.greenart7c3.nostrsigner.models.ReturnType
import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind
import com.greenart7c3.nostrsigner.models.containsNip 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.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.components.DecryptTypeScope import com.greenart7c3.nostrsigner.ui.components.DecryptTypeScope
@@ -900,6 +901,19 @@ object IntentUtils {
// immediately, not only on a later request. // immediately, not only on a later request.
persistNativeAppMetadata(context, account, packageName) persistNativeAppMetadata(context, account, packageName)
val historyDatabase = Amber.instance.getHistoryDatabase(account.npub) 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 { Amber.instance.applicationIOScope.launch {
historyDatabase.dao().addHistory( historyDatabase.dao().addHistory(
listOf( listOf(
@@ -907,24 +921,16 @@ object IntentUtils {
0, 0,
key, key,
intentData.type.toString(), intentData.type.toString(),
kind, if (isV3) intentData.nip44v3Kind else kind,
TimeUtils.now(), TimeUtils.now(),
true, true,
content = when (intentData.type) { content = historyContent,
SignerType.SIGN_EVENT -> event encryptionPubKey = if (intentData.type in encryptDecryptSignerTypes && intentData.type != SignerType.DECRYPT_ZAP_EVENT) {
SignerType.NIP04_DECRYPT, intentData.pubKey
SignerType.NIP44_DECRYPT, } else {
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
}, },
encryptionScope = if (isV3) intentData.nip44v3Scope else "",
), ),
), ),
account.npub, account.npub,
@@ -1058,16 +1064,27 @@ object IntentUtils {
} }
Amber.instance.dao(account.npub).insertApplicationWithPermissions(application) 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( Amber.instance.getHistoryDatabase(account.npub).dao().addHistory(
listOf( listOf(
HistoryEntity( HistoryEntity(
0, 0,
key, key,
intentData.type.toString(), intentData.type.toString(),
kind, if (isV3) intentData.nip44v3Kind else kind,
TimeUtils.now(), TimeUtils.now(),
false, 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, account.npub,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -51,6 +52,7 @@ import com.greenart7c3.nostrsigner.AmberLog
import com.greenart7c3.nostrsigner.R import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.database.HistoryEntity import com.greenart7c3.nostrsigner.database.HistoryEntity
import com.greenart7c3.nostrsigner.models.Account import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.TimeUtils import com.greenart7c3.nostrsigner.models.TimeUtils
import com.greenart7c3.nostrsigner.models.supportedKindNumbers import com.greenart7c3.nostrsigner.models.supportedKindNumbers
import com.greenart7c3.nostrsigner.service.ApplicationNameCache 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 @Composable
fun ActivityRow(activity: HistoryEntity, account: Account) { fun ActivityRow(activity: HistoryEntity, account: Account) {
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val parsedEvent = remember(activity.content) { val displayContent by produceState(initialValue = "", activity.id, activity.content) {
if (activity.content.isBlank()) { value = withContext(Dispatchers.IO) { decryptHistoryContent(activity, account) }
}
val parsedEvent = remember(displayContent) {
if (displayContent.isBlank()) {
null null
} else { } 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)) Spacer(Modifier.height(4.dp))
Text( Text(
modifier = Modifier.padding(top = 2.dp), modifier = Modifier.padding(top = 2.dp),
text = activity.content, text = displayContent,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontSize = 14.sp, fontSize = 14.sp,