From a7d46a80343e6f92d408f74c6c45194bc714b133 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:55:03 +0000 Subject: [PATCH] Handle NIP-44 v3 plaintext like v2 (no base64 layer) Following the v2 pattern: encrypt/decrypt operate on plaintext strings end to end, so the decrypted content flows into history naturally. This removes the Base64 wrapping introduced earlier along with the per-type decode special-casing in the history-saving switches, the encrypt payload base64 validation, and the screen's base64 decode. The bunker approval now reuses the precomputed encryptedData.result (as v2 does) instead of recomputing on accept. --- .../greenart7c3/nostrsigner/SignerProvider.kt | 24 +-------- .../nostrsigner/service/AmberUtils.kt | 31 +++-------- .../nostrsigner/service/BunkerRequestUtils.kt | 4 +- .../service/EventNotificationConsumer.kt | 44 +++++++-------- .../nostrsigner/service/IntentUtils.kt | 5 +- .../components/BunkerSingleEventHomeScreen.kt | 54 ++++++++----------- .../ui/components/Nip44v3ApprovalData.kt | 54 +++---------------- app/src/main/res/values/strings.xml | 1 - 8 files changed, 58 insertions(+), 159 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index 74c8cf30..efbe0472 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -28,14 +28,6 @@ class SignerProvider : ContentProvider() { private fun rejectedCursor(): Cursor = MatrixCursor(arrayOf("rejected")).also { it.addRow(arrayOf("true")) } - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) - private fun isValidBase64(value: String): Boolean = try { - kotlin.io.encoding.Base64.decode(value) - true - } catch (_: IllegalArgumentException) { - false - } - override fun delete( uri: Uri, selection: String?, @@ -279,14 +271,6 @@ class SignerProvider : ContentProvider() { null to "" } - // For V3 encrypt the wire payload is base64-encoded plaintext. - // Validate it up-front (no secret material involved) so a - // malformed request is rejected without bothering the user. - if (isV3 && isEncrypt && !isValidBase64(content)) { - Log.d(Amber.TAG, "NIP-44 v3 encrypt payload is not valid base64") - return rejectedCursor() - } - // For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result val result = if (isEncrypt) { @@ -411,12 +395,6 @@ class SignerProvider : ContentProvider() { "Could not decrypt the message" } - // For v3 the wire payload is Base64; store the readable plaintext. - val historyContent = if (isV3) { - AmberUtils.decodeNip44v3LogContent(if (!isEncrypt) finalResult else content) - } else { - if (!isEncrypt) finalResult else content - } scope.launch { historyDatabase.dao().addHistory( listOf( @@ -427,7 +405,7 @@ class SignerProvider : ContentProvider() { v3Kind, TimeUtils.now(), true, - content = historyContent, + content = if (!isEncrypt) finalResult else content, ), ), account.npub, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt index 29b16842..097b1f99 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt @@ -25,16 +25,11 @@ import com.vitorpamplona.quartz.utils.TimeUtils object AmberUtils { /** - * For NIP-44 v3 callers, [data] follows the NIP-46 wire format: - * - V3 encrypt: [data] is base64-encoded plaintext bytes; the result is the - * v3 ciphertext string. - * - V3 decrypt: [data] is the v3 ciphertext string; the result is the - * base64-encoded plaintext bytes. - * - * [nip44v3Kind] and [nip44v3Scope] are required for the V3 SignerType variants - * (kind comes from the request; scope defaults to empty per spec). + * 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). */ - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) suspend fun encryptOrDecryptData( data: String, type: SignerType, @@ -57,31 +52,17 @@ object AmberUtils { } SignerType.NIP44_V3_ENCRYPT -> { requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_ENCRYPT" } - val plaintextBytes = kotlin.io.encoding.Base64.decode(data) - account.nip44v3Encrypt(plaintextBytes, pubKey, nip44v3Kind, nip44v3Scope) + account.nip44v3Encrypt(data.toByteArray(Charsets.UTF_8), pubKey, nip44v3Kind, nip44v3Scope) } SignerType.NIP44_V3_DECRYPT -> { requireNotNull(nip44v3Kind) { "kind is required for NIP44_V3_DECRYPT" } - val plaintextBytes = account.nip44v3Decrypt(data, pubKey, nip44v3Kind, nip44v3Scope) - kotlin.io.encoding.Base64.encode(plaintextBytes) + account.nip44v3Decrypt(data, pubKey, nip44v3Kind, nip44v3Scope).toString(Charsets.UTF_8) } else -> { account.nip44Decrypt(data, pubKey) } } - /** - * NIP-44 v3 wire payloads are Base64-encoded plaintext bytes. History logs - * should record the readable plaintext, so decode it here; if the bytes - * aren't valid base64/UTF-8 (e.g. binary data) keep the original string. - */ - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) - fun decodeNip44v3LogContent(content: String): String = try { - kotlin.io.encoding.Base64.decode(content).decodeToString(throwOnInvalidSequence = true) - } catch (_: Exception) { - content - } - suspend fun sendBunkerError( account: Account, bunkerRequest: AmberBunkerRequest, 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 d4f544d2..31de2e7d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -430,11 +430,9 @@ object BunkerRequestUtils { SignerType.SIGN_EVENT, SignerType.NIP04_DECRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, -> response - // v3 payloads are Base64; log the readable plaintext. - SignerType.NIP44_V3_DECRYPT -> AmberUtils.decodeNip44v3LogContent(response) - SignerType.NIP44_V3_ENCRYPT -> AmberUtils.decodeNip44v3LogContent(getDataFromBunker(bunkerRequest.request)) else -> getDataFromBunker(bunkerRequest.request) }, ), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt index 069dbac8..bb0b8c69 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -608,8 +608,8 @@ class EventNotificationConsumer(private val applicationContext: Context) { acc: Account, url: String, ): EncryptedDataKind? = if (bunkerRequest != null && (bunkerRequest.method == "nip44v3_encrypt" || bunkerRequest.method == "nip44v3_decrypt")) { - // V3 arrives as a generic BunkerRequest; build a preview the dedicated - // approval screen can render (it Base64-decodes the stored payload). + // V3 arrives as a generic BunkerRequest; build the preview the dedicated + // approval screen renders (plaintext, like the v2 path). nip44v3EncryptedDataKind(bunkerRequest, acc) } else if (bunkerRequest is BunkerRequestNip44Decrypt) { val result = acc.nip44Decrypt(bunkerRequest.ciphertext, bunkerRequest.pubKey) @@ -782,19 +782,12 @@ class EventNotificationConsumer(private val applicationContext: Context) { } /** - * Validates a NIP-44 v3 bunker request. Returns null when the request can - * proceed, or an error message when it is malformed and must be rejected - * without prompting the user (missing/invalid kind, context mismatch on - * decrypt, bad MAC/padding, or a non-base64 encrypt payload). + * 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). */ - /** - * Builds the preview [EncryptedDataKind] for a NIP-44 v3 bunker request. - * For encrypt, the wire payload is Base64 plaintext (shown decoded). For - * decrypt, the payload is decrypted and the plaintext stored Base64-encoded - * — matching what the approval screen expects. 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, @@ -805,10 +798,11 @@ class EventNotificationConsumer(private val applicationContext: Context) { val pubKey = bunkerRequest.params.firstOrNull() ?: return null return try { if (bunkerRequest.method == "nip44v3_encrypt") { - ClearTextEncryptedDataKind(data, "") + val ciphertext = acc.nip44v3Encrypt(data.toByteArray(Charsets.UTF_8), pubKey, kind, scope) + ClearTextEncryptedDataKind(data, ciphertext) } else { - val plaintext = acc.nip44v3Decrypt(data, pubKey, kind, scope) - ClearTextEncryptedDataKind("", kotlin.io.encoding.Base64.encode(plaintext)) + val plaintext = acc.nip44v3Decrypt(data, pubKey, kind, scope).toString(Charsets.UTF_8) + ClearTextEncryptedDataKind(data, plaintext) } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -816,7 +810,12 @@ class EventNotificationConsumer(private val applicationContext: Context) { } } - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + /** + * Validates a NIP-44 v3 bunker request. Returns null when it can proceed, or + * a generic error when it is malformed and must be rejected without + * prompting the user (missing kind, or a decrypt whose context/MAC/padding + * doesn't check out). + */ private fun validateNip44v3Request( type: SignerType, kind: Int?, @@ -826,18 +825,15 @@ class EventNotificationConsumer(private val applicationContext: Context) { acc: Account, ): String? { if (kind == null) return "kind is required for nip44v3" + if (type != SignerType.NIP44_V3_DECRYPT) return null return try { - if (type == SignerType.NIP44_V3_DECRYPT) { - acc.nip44v3Decrypt(data, pubKey, kind, scope) - } else { - kotlin.io.encoding.Base64.decode(data) - } + acc.nip44v3Decrypt(data, pubKey, kind, scope) null } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e // Keep the response generic so we don't leak the ciphertext's // embedded context back to the requester. - if (type == SignerType.NIP44_V3_DECRYPT) "could not decrypt the message" else "invalid encrypted payload" + "could not decrypt the message" } } 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 9c392ea9..8e7964e3 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -854,13 +854,10 @@ object IntentUtils { SignerType.SIGN_EVENT -> event SignerType.NIP04_DECRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, -> value - // v3 payloads are Base64; log the readable plaintext. - SignerType.NIP44_V3_DECRYPT -> AmberUtils.decodeNip44v3LogContent(value) - SignerType.NIP44_V3_ENCRYPT -> AmberUtils.decodeNip44v3LogContent(intentData.data) - else -> intentData.data }, ), diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt index 92843cee..b6442d4c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt @@ -36,7 +36,6 @@ import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.toPermissionType -import com.greenart7c3.nostrsigner.service.AmberUtils import com.greenart7c3.nostrsigner.service.BunkerRequestUtils import com.greenart7c3.nostrsigner.service.IntentUtils import com.greenart7c3.nostrsigner.service.PsbtDecoder @@ -840,38 +839,27 @@ fun BunkerSingleEventHomeScreen( encryptedData = bunkerRequest.encryptedData, shouldRunOnAccept = acceptOrReject, onAccept = { rememberType, scope -> - Amber.instance.applicationIOScope.launch(Dispatchers.IO) { - val result = try { - AmberUtils.encryptOrDecryptData( - BunkerRequestUtils.getDataFromBunker(bunkerRequest.request), - type, - account, - bunkerRequest.request.params.first(), - v3Kind, - v3Scope, - ) ?: "" - } catch (e: Exception) { - "" - } - - // SPECIFIC ⇒ kind-scoped grant; ALL ⇒ broad grant (kind=null). - val grantedKind = if (scope == DecryptTypeScope.SPECIFIC) v3Kind else null - BunkerRequestUtils.sendResult( - context = context, - account = account, - key = key, - response = result, - bunkerRequest = bunkerRequest, - kind = grantedKind, - onLoading = onLoading, - permissions = null, - appName = appName, - signPolicy = null, - shouldCloseApplication = bunkerRequest.closeApplication, - rememberType = rememberType, - decryptTypeScope = scope, - ) - } + // encryptedData.result holds the ciphertext (encrypt) or + // decrypted plaintext (decrypt), computed when the request + // arrived — same 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 + BunkerRequestUtils.sendResult( + context = context, + account = account, + key = key, + response = result, + bunkerRequest = bunkerRequest, + kind = grantedKind, + onLoading = onLoading, + permissions = null, + appName = appName, + signPolicy = null, + shouldCloseApplication = bunkerRequest.closeApplication, + rememberType = rememberType, + decryptTypeScope = scope, + ) }, onReject = { rememberType, scope -> val grantedKind = if (scope == DecryptTypeScope.SPECIFIC) v3Kind else null diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt index 8fb9f577..82c07a08 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt @@ -32,17 +32,13 @@ import com.greenart7c3.nostrsigner.models.EncryptedDataKind import com.greenart7c3.nostrsigner.models.Permission import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.ui.RememberType -import java.nio.charset.CharacterCodingException -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi /** * Dedicated approval screen for NIP-44 v3 encrypt/decrypt requests. * * Unlike the v2/v4 [EncryptDecryptData] screen, v3 authenticates an event - * `kind` and a `scope`, and its wire payloads are Base64-encoded bytes — so - * this screen surfaces that context explicitly and decodes the payload for - * display. The scope toggle grants either a single kind or all kinds. + * `kind` and a `scope`, so this screen surfaces that context explicitly. The + * scope toggle grants either a single kind or all kinds. */ @Composable fun Nip44v3ApprovalData( @@ -187,45 +183,11 @@ fun Nip44v3ContextBox( } /** - * The v3 payload travels Base64-encoded. Decode it for display, falling back to - * a "binary data" placeholder when the bytes aren't valid UTF-8 text. + * The plaintext to show: encrypt stores it as `text`, decrypt as `result` + * (populated when the request was parsed) — same shape as the v2 screen. */ -@Composable -private fun nip44v3DisplayContent(encryptedData: EncryptedDataKind?, isEncrypt: Boolean): String { - val raw = if (isEncrypt) { - (encryptedData as? ClearTextEncryptedDataKind)?.text ?: encryptedData?.result ?: "" - } else { - encryptedData?.result ?: "" - } - if (raw.isEmpty()) return "" - val binaryLabelSize = decodeBinarySizeOrNull(raw) - return binaryLabelSize?.let { stringResource(R.string.nip44_v3_binary_data, it.toString()) } - ?: decodeTextOrRaw(raw) -} - -@OptIn(ExperimentalEncodingApi::class) -private fun decodeBytesOrNull(value: String): ByteArray? = try { - Base64.decode(value) -} catch (_: IllegalArgumentException) { - null -} - -/** @return the byte count when [value] is Base64 of non-UTF-8 bytes, else null. */ -private fun decodeBinarySizeOrNull(value: String): Int? { - val bytes = decodeBytesOrNull(value) ?: return null - return try { - bytes.decodeToString(throwOnInvalidSequence = true) - null - } catch (_: CharacterCodingException) { - bytes.size - } -} - -private fun decodeTextOrRaw(value: String): String { - val bytes = decodeBytesOrNull(value) ?: return value - return try { - bytes.decodeToString(throwOnInvalidSequence = true) - } catch (_: CharacterCodingException) { - value - } +private fun nip44v3DisplayContent(encryptedData: EncryptedDataKind?, isEncrypt: Boolean): String = if (isEncrypt) { + (encryptedData as? ClearTextEncryptedDataKind)?.text ?: encryptedData?.result ?: "" +} else { + encryptedData?.result ?: "" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c4e9f7c..5a2d627f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -655,7 +655,6 @@ All kinds wants to encrypt with NIP-44 v3 wants to read encrypted content with NIP-44 v3 - Binary data (%1$s bytes) Indexer relays Proxy relays Broadcast relays