Use base64 v3 wire but log the decoded plaintext

Per the nip44v3 NIP-46 draft, v3 plaintext travels Base64-encoded on the
wire: AmberUtils now base64-decodes on encrypt and encodes on decrypt.
The EncryptedDataKind stores the readable plaintext in `text` and the
Base64 wire value in `result`, so history/display read the decoded
plaintext (via nip44v3Plaintext) while responses keep the wire value.
The ContentProvider auto-accept path has no EncryptedDataKind, so it
decodes the wire value once for its history entry.
This commit is contained in:
Claude
2026-06-01 12:48:12 +00:00
parent a7d46a8034
commit 96c786816a
8 changed files with 79 additions and 30 deletions
@@ -28,6 +28,14 @@ class SignerProvider : ContentProvider() {
private fun rejectedCursor(): Cursor = MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) }
// 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
}
override fun delete(
uri: Uri,
selection: String?,
@@ -395,6 +403,13 @@ class SignerProvider : ContentProvider() {
"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
}
scope.launch {
historyDatabase.dao().addHistory(
listOf(
@@ -405,7 +420,7 @@ class SignerProvider : ContentProvider() {
v3Kind,
TimeUtils.now(),
true,
content = if (!isEncrypt) finalResult else content,
content = historyContent,
),
),
account.npub,
@@ -31,6 +31,13 @@ fun SignerType.toPermissionTypeString(encryptedData: EncryptedDataKind?): String
else -> this.toString()
}
/**
* The readable NIP-44 v3 plaintext. For v3 the data kind stores the real
* plaintext in `text` and the Base64 wire value in `result`, so history/display
* read `text` and never touch the wire encoding.
*/
fun EncryptedDataKind?.nip44v3Plaintext(): String = (this as? ClearTextEncryptedDataKind)?.text ?: this?.result ?: ""
val encryptDecryptSignerTypes = setOf(
SignerType.NIP04_ENCRYPT,
SignerType.NIP44_ENCRYPT,
@@ -25,11 +25,13 @@ import com.vitorpamplona.quartz.utils.TimeUtils
object AmberUtils {
/**
* Like v2, NIP-44 v3 plaintext is handled as a UTF-8 string here: encrypt
* takes the plaintext and returns the ciphertext; decrypt returns the
* plaintext. [nip44v3Kind] and [nip44v3Scope] are required for the V3
* SignerType variants (kind comes from the request; scope defaults to empty).
* Produces the wire value for a request. Per the nip44v3 NIP-46 draft, v3
* plaintext travels Base64-encoded: encrypt receives base64(plaintext) and
* returns the ciphertext; decrypt returns base64(plaintext). The readable
* plaintext for logs/display comes from the EncryptedDataKind instead (see
* [nip44v3Plaintext]). [nip44v3Kind]/[nip44v3Scope] are required for V3.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun encryptOrDecryptData(
data: String,
type: SignerType,
@@ -52,11 +54,11 @@ object AmberUtils {
}
SignerType.NIP44_V3_ENCRYPT -> {
requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_ENCRYPT" }
account.nip44v3Encrypt(data.toByteArray(Charsets.UTF_8), pubKey, nip44v3Kind, nip44v3Scope)
account.nip44v3Encrypt(kotlin.io.encoding.Base64.decode(data), pubKey, nip44v3Kind, nip44v3Scope)
}
SignerType.NIP44_V3_DECRYPT -> {
requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_DECRYPT" }
account.nip44v3Decrypt(data, pubKey, nip44v3Kind, nip44v3Scope).toString(Charsets.UTF_8)
kotlin.io.encoding.Base64.encode(account.nip44v3Decrypt(data, pubKey, nip44v3Kind, nip44v3Scope))
}
else -> {
account.nip44Decrypt(data, pubKey)
@@ -16,6 +16,7 @@ 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.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
@@ -430,9 +431,13 @@ object BunkerRequestUtils {
SignerType.SIGN_EVENT,
SignerType.NIP04_DECRYPT,
SignerType.NIP44_DECRYPT,
SignerType.NIP44_V3_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)
},
),
@@ -785,9 +785,11 @@ class EventNotificationConsumer(private val applicationContext: Context) {
* Builds the preview [EncryptedDataKind] for a NIP-44 v3 bunker request,
* mirroring v2: encrypt stores the plaintext as `text` and the ciphertext
* as `result`; decrypt stores the ciphertext as `text` and the decrypted
* plaintext as `result`. Returns null if it can't be produced (the request
* is auto-rejected elsewhere in that case).
* plaintext as `result`. The Base64 wire payload is decoded here once so
* history/display can use the readable plaintext. Returns null if it can't
* be produced (the request is auto-rejected elsewhere in that case).
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
private fun nip44v3EncryptedDataKind(
bunkerRequest: BunkerRequest,
acc: Account,
@@ -797,12 +799,14 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val data = BunkerRequestUtils.getDataFromBunker(bunkerRequest)
val pubKey = bunkerRequest.params.firstOrNull() ?: return null
return try {
// text = readable plaintext (for display/history); result = wire value.
if (bunkerRequest.method == "nip44v3_encrypt") {
val ciphertext = acc.nip44v3Encrypt(data.toByteArray(Charsets.UTF_8), pubKey, kind, scope)
ClearTextEncryptedDataKind(data, ciphertext)
val plainBytes = kotlin.io.encoding.Base64.decode(data)
val ciphertext = acc.nip44v3Encrypt(plainBytes, pubKey, kind, scope)
ClearTextEncryptedDataKind(plainBytes.toString(Charsets.UTF_8), ciphertext)
} else {
val plaintext = acc.nip44v3Decrypt(data, pubKey, kind, scope).toString(Charsets.UTF_8)
ClearTextEncryptedDataKind(data, plaintext)
val plainBytes = acc.nip44v3Decrypt(data, pubKey, kind, scope)
ClearTextEncryptedDataKind(plainBytes.toString(Charsets.UTF_8), kotlin.io.encoding.Base64.encode(plainBytes))
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
@@ -35,6 +35,7 @@ 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.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.components.DecryptTypeScope
@@ -545,6 +546,7 @@ object IntentUtils {
}
}
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
private suspend fun getEncryptedDataKind(
type: SignerType,
result: String,
@@ -555,6 +557,25 @@ object IntentUtils {
PrivateZapEncryptedDataKind(result)
}
// v3 wire values are Base64; decode once so display/history can read the
// plaintext from `text`, while `result` keeps the wire value.
SignerType.NIP44_V3_ENCRYPT -> {
val plaintext = try {
kotlin.io.encoding.Base64.decode(data).toString(Charsets.UTF_8)
} catch (_: Exception) {
data
}
ClearTextEncryptedDataKind(plaintext, result)
}
SignerType.NIP44_V3_DECRYPT -> {
val plaintext = try {
kotlin.io.encoding.Base64.decode(result).toString(Charsets.UTF_8)
} catch (_: Exception) {
result
}
ClearTextEncryptedDataKind(plaintext, result)
}
else -> {
if (type.name.contains("ENCRYPT")) {
if (data.startsWith("{")) {
@@ -854,10 +875,15 @@ object IntentUtils {
SignerType.SIGN_EVENT -> event
SignerType.NIP04_DECRYPT,
SignerType.NIP44_DECRYPT,
SignerType.NIP44_V3_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
},
),
@@ -839,9 +839,9 @@ fun BunkerSingleEventHomeScreen(
encryptedData = bunkerRequest.encryptedData,
shouldRunOnAccept = acceptOrReject,
onAccept = { rememberType, scope ->
// encryptedData.result holds the ciphertext (encrypt) or
// decrypted plaintext (decrypt), computed when the request
// arrived — same as the v2 path.
// encryptedData.result holds the Base64 wire value (ciphertext
// for encrypt, base64 plaintext for decrypt), computed when the
// request arrived — same shape as the v2 path.
val result = bunkerRequest.encryptedData?.result ?: ""
// SPECIFIC ⇒ kind-scoped grant; ALL ⇒ broad grant (kind=null).
val grantedKind = if (scope == DecryptTypeScope.SPECIFIC) v3Kind else null
@@ -27,10 +27,10 @@ import androidx.compose.ui.unit.sp
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.Account
import com.greenart7c3.nostrsigner.models.ClearTextEncryptedDataKind
import com.greenart7c3.nostrsigner.models.EncryptedDataKind
import com.greenart7c3.nostrsigner.models.Permission
import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.models.nip44v3Plaintext
import com.greenart7c3.nostrsigner.ui.RememberType
/**
@@ -61,7 +61,7 @@ fun Nip44v3ApprovalData(
val isEncrypt = type == SignerType.NIP44_V3_ENCRYPT
val messageRes = if (isEncrypt) R.string.nip44_v3_wants_to_encrypt else R.string.nip44_v3_wants_to_decrypt
val displayContent = nip44v3DisplayContent(encryptedData, isEncrypt)
val displayContent = encryptedData.nip44v3Plaintext()
Column(modifier) {
if (isBunker) {
@@ -181,13 +181,3 @@ fun Nip44v3ContextBox(
}
}
}
/**
* The plaintext to show: encrypt stores it as `text`, decrypt as `result`
* (populated when the request was parsed) — same shape as the v2 screen.
*/
private fun nip44v3DisplayContent(encryptedData: EncryptedDataKind?, isEncrypt: Boolean): String = if (isEncrypt) {
(encryptedData as? ClearTextEncryptedDataKind)?.text ?: encryptedData?.result ?: ""
} else {
encryptedData?.result ?: ""
}