mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user