From a115a0161184c6b5f52df31d5141dc1accdaa3c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 15:27:48 +0000 Subject: [PATCH 01/11] Add NIP-44 v3 encryption Implements the draft NIP-44 v3 cipher (nostr-land/nip44v3) alongside the existing v2 path, with kind+scope context binding for cross-context replay protection. The cipher is a self-contained class that reuses Quartz's existing primitives (secp256k1 ECDH, HKDF-SHA256, ChaCha20, HMAC-SHA256), so no new runtime dependency is needed. Wires v3 through all three ingestion paths (NIP-46 bunker via `nip44v3_encrypt` / `nip44v3_decrypt` methods, `nostrsigner://` intents, and the ContentProvider IPC at `content://*.NIP44_V3_{EN,DE}CRYPT`), threading kind and scope from the request through the approval UI to the cipher. Permissions for v3 are stored per (app, type, kind) so users can grant a peer access to a single event kind rather than the whole NIP. V3 grants are kept distinct from v2 grants. Validated against the official spec test vectors (encrypt/decrypt round-trips, key derivation, long messages, padding boundaries, context-rebinding rejection, MAC tamper detection, invalid-version handling). The JVM secp256k1 native library is added as a test-only dependency so the vectors run in `./gradlew test`. --- app/build.gradle.kts | 2 + .../greenart7c3/nostrsigner/SignerProvider.kt | 55 ++- .../greenart7c3/nostrsigner/models/Account.kt | 6 + .../nostrsigner/models/EncryptedDataKind.kt | 3 + .../nostrsigner/models/IntentData.kt | 4 + .../nostrsigner/models/SignerType.kt | 2 + .../nostrsigner/service/AmberUtils.kt | 23 + .../nostrsigner/service/BunkerRequestUtils.kt | 12 + .../service/EventNotificationConsumer.kt | 25 +- .../nostrsigner/service/IntentUtils.kt | 26 +- .../nostrsigner/service/nip44v3/Nip44v3.kt | 261 ++++++++++ .../components/BunkerSingleEventHomeScreen.kt | 74 ++- .../components/IntentSingleEventHomeScreen.kt | 35 +- .../service/BunkerRequestUtilsTest.kt | 40 ++ .../service/nip44v3/Nip44v3Test.kt | 236 +++++++++ app/src/test/resources/nip44v3-vectors.json | 461 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 17 files changed, 1239 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt create mode 100644 app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt create mode 100644 app/src/test/resources/nip44v3-vectors.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc166ad0..ebb7f675 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -206,6 +206,8 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + // JVM-side native secp256k1 so unit tests can exercise ECDH (NIP-44 v3). + testImplementation(libs.secp256k1.jni.jvm) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.ui.test.junit4) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index 5d280896..b781c9a5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -224,6 +224,8 @@ class SignerProvider : ContentProvider() { "content://$appId.NIP44_DECRYPT", "content://$appId.NIP04_ENCRYPT", "content://$appId.NIP44_ENCRYPT", + "content://$appId.NIP44_V3_DECRYPT", + "content://$appId.NIP44_V3_ENCRYPT", "content://$appId.DECRYPT_ZAP_EVENT", -> { val content = projection?.first() ?: return null @@ -240,11 +242,30 @@ class SignerProvider : ContentProvider() { "NIP44_DECRYPT" -> SignerType.NIP44_DECRYPT "NIP04_ENCRYPT" -> SignerType.NIP04_ENCRYPT "NIP44_ENCRYPT" -> SignerType.NIP44_ENCRYPT + "NIP44_V3_DECRYPT" -> SignerType.NIP44_V3_DECRYPT + "NIP44_V3_ENCRYPT" -> SignerType.NIP44_V3_ENCRYPT "DECRYPT_ZAP_EVENT" -> SignerType.DECRYPT_ZAP_EVENT else -> null } ?: return null - val isEncrypt = type == SignerType.NIP04_ENCRYPT || type == SignerType.NIP44_ENCRYPT + val isEncrypt = type == SignerType.NIP04_ENCRYPT || + type == SignerType.NIP44_ENCRYPT || + type == SignerType.NIP44_V3_ENCRYPT + val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT + + // V3 carries kind and scope as projection[3] and projection[4]. + val (v3Kind, v3Scope) = if (isV3) { + val kindStr = projection.getOrNull(3) + val scopeStr = projection.getOrNull(4) ?: "" + val parsedKind = kindStr?.toIntOrNull() + if (parsedKind == null) { + Log.d(Amber.TAG, "NIP-44 v3 request missing/invalid kind") + return null + } + parsedKind to scopeStr + } else { + null to "" + } // For ENCRYPT: classify plaintext input; for DECRYPT: perform operation first then classify result val result = @@ -258,6 +279,8 @@ class SignerProvider : ContentProvider() { type, account, pubkey, + v3Kind, + v3Scope, ) ?: "Could not decrypt the message" } } catch (e: Exception) { @@ -276,18 +299,20 @@ class SignerProvider : ContentProvider() { } } - // Classify the content to determine EncryptedDataKind-based permission type - val classifyContent = if (isEncrypt) content else (result ?: content) - val permType = permissionTypeFromContent(classifyContent, isEncrypt, type) - - var permission = permDao.getPermission(packageName, permType) - if (permission == null) { - permission = permDao.getPermission( - packageName, - type.toString(), - ) + // Permission lookup. V3 grants are scoped by (packageName, + // SignerType, kind); fall back to a kind=null "all kinds" + // grant. V3 grants do NOT satisfy V2 requests and vice versa. + var permission = if (isV3) { + permDao.getPermission(packageName, type.toString(), v3Kind!!) + ?: permDao.getPermission(packageName, type.toString()) + } else { + // Classify the content to determine EncryptedDataKind-based permission type + val classifyContent = if (isEncrypt) content else (result ?: content) + val permType = permissionTypeFromContent(classifyContent, isEncrypt, type) + permDao.getPermission(packageName, permType) + ?: permDao.getPermission(packageName, type.toString()) } - if (permission == null) { + if (permission == null && !isV3) { val nip = when (stringType) { "NIP04_DECRYPT" -> 4 "NIP44_DECRYPT" -> 44 @@ -316,7 +341,7 @@ class SignerProvider : ContentProvider() { 0, packageName, uriString.replace("content://$appId.", ""), - null, + v3Kind, TimeUtils.now(), false, content = content, @@ -343,6 +368,8 @@ class SignerProvider : ContentProvider() { type, account, pubkey, + v3Kind, + v3Scope, ) ?: "Could not decrypt the message" } } catch (e: Exception) { @@ -367,7 +394,7 @@ class SignerProvider : ContentProvider() { 0, packageName, uriString.replace("content://$appId.", ""), - null, + v3Kind, TimeUtils.now(), true, content = if (!isEncrypt) finalResult else content, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt index 037a7d24..60ef217a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/Account.kt @@ -9,8 +9,10 @@ import androidx.compose.ui.platform.Clipboard import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.DataStoreAccess import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.service.nip44v3.Nip44v3 import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -84,6 +86,10 @@ class Account( suspend fun nip04Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip04Decrypt(cipherText, fromPublicKey) + fun nip44v3Encrypt(plainText: ByteArray, toPublicKey: String, kind: Int, scope: String): String = Nip44v3.encrypt(plainText, signer.keyPair.privKey!!, toPublicKey.hexToByteArray(), kind, scope) + + fun nip44v3Decrypt(cipherText: String, fromPublicKey: String, kind: Int, scope: String): ByteArray = Nip44v3.decrypt(cipherText, signer.keyPair.privKey!!, fromPublicKey.hexToByteArray(), kind, scope) + suspend fun decrypt(encryptedContent: String, fromPublicKey: String): String = signer.decrypt(encryptedContent, fromPublicKey) suspend fun signPsbt(psbtHex: String): String = signer.signPsbt(psbtHex) 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 91962683..8763a198 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt @@ -26,6 +26,7 @@ fun EncryptedDataKind?.toPermissionType(isEncrypt: Boolean): String = when (this fun SignerType.toPermissionTypeString(encryptedData: EncryptedDataKind?): String = when (this) { SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT -> encryptedData.toPermissionType(isEncrypt = true) SignerType.NIP04_DECRYPT, SignerType.NIP44_DECRYPT -> encryptedData.toPermissionType(isEncrypt = false) + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT -> this.toString() SignerType.DECRYPT_ZAP_EVENT -> "DECRYPT_ZAP_EVENT" else -> this.toString() } @@ -35,6 +36,8 @@ val encryptDecryptSignerTypes = setOf( SignerType.NIP44_ENCRYPT, SignerType.NIP04_DECRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_ENCRYPT, + SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, ) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt index fde5aaf9..f14cc9fa 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/IntentData.kt @@ -21,4 +21,8 @@ data class IntentData( val encryptedData: EncryptedDataKind?, val isNostrConnectURI: Boolean = false, val unsignedEventKey: HexKey = "", + // NIP-44 v3 context fields. Required when [type] is one of the + // NIP44_V3_* variants; ignored otherwise. + val nip44v3Kind: Int? = null, + val nip44v3Scope: String = "", ) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt b/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt index 9b85aca7..85fde7ea 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/SignerType.kt @@ -7,6 +7,8 @@ enum class SignerType { NIP04_DECRYPT, NIP44_ENCRYPT, NIP44_DECRYPT, + NIP44_V3_ENCRYPT, + NIP44_V3_DECRYPT, GET_PUBLIC_KEY, DECRYPT_ZAP_EVENT, PING, 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 c6d245cf..0b8c2a10 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt @@ -24,11 +24,24 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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). + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) suspend fun encryptOrDecryptData( data: String, type: SignerType, account: Account, pubKey: HexKey, + nip44v3Kind: Int? = null, + nip44v3Scope: String = "", ): String? = when (type) { SignerType.DECRYPT_ZAP_EVENT -> { account.decryptZapEvent(data) @@ -42,6 +55,16 @@ object AmberUtils { SignerType.NIP44_ENCRYPT -> { account.nip44Encrypt(data, pubKey) } + 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) + } + 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) + } else -> { account.nip44Decrypt(data, pubKey) } 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 93502bea..0ce24c9c 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -228,6 +228,8 @@ object BunkerRequestUtils { "nip04_decrypt" -> SignerType.NIP04_DECRYPT "nip44_encrypt" -> SignerType.NIP44_ENCRYPT "nip44_decrypt" -> SignerType.NIP44_DECRYPT + "nip44v3_encrypt" -> SignerType.NIP44_V3_ENCRYPT + "nip44v3_decrypt" -> SignerType.NIP44_V3_DECRYPT "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT "ping" -> SignerType.PING "switch_relays" -> SignerType.SWITCH_RELAYS @@ -242,11 +244,21 @@ object BunkerRequestUtils { amberEvent.toEvent().toJson() } "nip04_encrypt", "nip04_decrypt", "nip44_encrypt", "nip44_decrypt", "decrypt_zap_event" -> bunkerRequest.params.getOrElse(1) { "" } + // NIP-44 v3 NIP-46 layout: [pubkey, kind, scope, payload]. For + // `nip44v3_encrypt` the payload is base64-encoded plaintext; for + // `nip44v3_decrypt` it is the v3 ciphertext. + "nip44v3_encrypt", "nip44v3_decrypt" -> bunkerRequest.params.getOrElse(3) { "" } "ping" -> "pong" "sign_psbt" -> bunkerRequest.params.first() else -> "" } + /** Extract `kind` from a NIP-44 v3 bunker request; null if missing/invalid. */ + fun getNip44v3Kind(bunkerRequest: BunkerRequest): Int? = bunkerRequest.params.getOrNull(1)?.toIntOrNull() + + /** Extract `scope` from a NIP-44 v3 bunker request; defaults to empty per spec. */ + fun getNip44v3Scope(bunkerRequest: BunkerRequest): String = bunkerRequest.params.getOrElse(2) { "" } + fun sendResult( context: Context, account: Account, 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 226575fb..637df52e 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -455,10 +455,27 @@ class EventNotificationConsumer(private val applicationContext: Context) { } val data = BunkerRequestUtils.getDataFromBunker(bunkerRequest) - val projection = if (type == SignerType.PING) { - arrayOf(acc.npub) - } else { - arrayOf(data, pubKey, acc.npub) + val projection = when { + type == SignerType.PING -> arrayOf(acc.npub) + type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> { + val kind = BunkerRequestUtils.getNip44v3Kind(bunkerRequest) + val scope = BunkerRequestUtils.getNip44v3Scope(bunkerRequest) + if (kind == null) { + saveLog("NIP-44 v3 request missing/invalid kind", relay.url, acc.npub) + BunkerRequestUtils.sendBunkerResponse( + context = applicationContext, + account = acc, + bunkerRequest = request, + bunkerResponse = BunkerResponse(bunkerRequest.id, "", "kind is required for nip44v3"), + relays = relays, + onLoading = { }, + onDone = { }, + ) + return + } + arrayOf(data, pubKey, acc.npub, kind.toString(), scope) + } + else -> arrayOf(data, pubKey, acc.npub) } val cursor = applicationContext.contentResolver.query( 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 8a045e5e..cffc31c6 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -138,6 +138,10 @@ object IntentUtils { SignerType.NIP44_ENCRYPT "nip44_decrypt" -> SignerType.NIP44_DECRYPT + "nip44v3_encrypt" -> + SignerType.NIP44_V3_ENCRYPT + "nip44v3_decrypt" -> + SignerType.NIP44_V3_DECRYPT "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT "sign_psbt" -> @@ -187,6 +191,8 @@ object IntentUtils { var callbackUrl: String? = null var returnType = ReturnType.SIGNATURE var appName = "" + var nip44v3Kind: Int? = null + var nip44v3Scope = "" // flatMap avoids building an intermediate joined string before splitting on "&" parameters.flatMap { it.split("&") }.forEach { val params = it.split("=").toMutableList() @@ -200,6 +206,8 @@ object IntentUtils { parameter == "callbackUrl" -> callbackUrl = parameterData parameter == "returnType" -> if (parameterData == "event") returnType = ReturnType.EVENT parameter == "appName" -> appName = parameterData + parameter == "kind" -> nip44v3Kind = parameterData.toIntOrNull() + parameter == "scope" -> nip44v3Scope = parameterData } } @@ -242,7 +250,9 @@ object IntentUtils { unsignedEventKey = unsignedEventKey, ) } - SignerType.NIP04_ENCRYPT, SignerType.NIP04_DECRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT -> { + SignerType.NIP04_ENCRYPT, SignerType.NIP04_DECRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT, + -> { val result = try { AmberUtils.encryptOrDecryptData( @@ -250,6 +260,8 @@ object IntentUtils { type, account, pubKey, + nip44v3Kind, + nip44v3Scope, ) ?: "Could not decrypt the message" } catch (e: Exception) { Amber.instance.applicationIOScope.launch { @@ -283,6 +295,8 @@ object IntentUtils { route = route, event = null, encryptedData = encryptedDataKind, + nip44v3Kind = nip44v3Kind, + nip44v3Scope = nip44v3Scope, ) } SignerType.GET_PUBLIC_KEY -> { @@ -410,7 +424,11 @@ object IntentUtils { unsignedEventKey = unsignedEventKey, ) } - SignerType.NIP04_ENCRYPT, SignerType.NIP04_DECRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, SignerType.DECRYPT_ZAP_EVENT -> { + SignerType.NIP04_ENCRYPT, SignerType.NIP04_DECRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, + -> { + val nip44v3Kind = intent.extras?.getString("kind")?.toIntOrNull() + val nip44v3Scope = intent.extras?.getString("scope") ?: "" val result = try { AmberUtils.encryptOrDecryptData( @@ -418,6 +436,8 @@ object IntentUtils { type, account, pubKey, + nip44v3Kind, + nip44v3Scope, ) ?: "Could not decrypt the message" } catch (e: Exception) { if (e is FailedMigrationException) throw e @@ -457,6 +477,8 @@ object IntentUtils { route = route, event = null, encryptedData = encryptedDataKind, + nip44v3Kind = nip44v3Kind, + nip44v3Scope = nip44v3Scope, ) } SignerType.GET_PUBLIC_KEY -> { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt new file mode 100644 index 00000000..fe153435 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt @@ -0,0 +1,261 @@ +package com.greenart7c3.nostrsigner.service.nip44v3 + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.mac.MacInstance +import kotlin.io.encoding.Base64 + +/** + * NIP-44 v3 cipher. + * + * Implements the asymmetric encryption scheme defined in the + * `nostr-land/nip44v3` draft: ECDH(secp256k1) → HKDF-SHA256 keyed with + * `"nip44-v3\x00" || nonce`, ChaCha20 with an all-zeroes 96-bit nonce, + * HMAC-SHA256 over `nonce || kind || scope_len || scope || ciphertext`, + * and a context (`kind` + `scope`) authenticated alongside the + * ciphertext to prevent cross-context replay. + */ +object Nip44v3 { + const val VERSION: Byte = 0x03 + private const val NONCE_SIZE = 32 + private const val MAC_SIZE = 32 + private const val MIN_DECODED_SIZE = 77 // 1 + 32 + 32 + 4 + 4 + 0 + 4 + private const val MIN_PADDING = 32 + private const val PAD_CHUNK_THRESHOLD = 32768 + private const val PAD_SUBDIVS_SMALL = 4 + private const val PAD_SUBDIVS_LARGE = 8 + + private val saltPrefix = "nip44-v3\u0000".encodeToByteArray() + private val infoEncryptionKey = "encryption_key".encodeToByteArray() + private val infoMacKey = "mac_key".encodeToByteArray() + private val zeroChaChaNonce = ByteArray(12) + + private val hkdf = Hkdf() + private val chaCha = ChaCha20() + + class Nip44v3Exception(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + + fun encrypt( + plaintext: ByteArray, + privKey: ByteArray, + pubKey: ByteArray, + kind: Int, + scope: String, + ): String = encryptWithNonce(plaintext, privKey, pubKey, kind, scope, RandomInstance.bytes(NONCE_SIZE)) + + /** + * Test/library-internal overload that accepts a caller-supplied nonce. The + * NIP-44 v3 spec is explicit that production code must not let callers + * choose the nonce — use [encrypt] for that. + */ + fun encryptWithNonce( + plaintext: ByteArray, + privKey: ByteArray, + pubKey: ByteArray, + kind: Int, + scope: String, + nonce: ByteArray, + ): String { + require(nonce.size == NONCE_SIZE) { "nonce must be $NONCE_SIZE bytes, got ${nonce.size}" } + require(kind >= 0) { "kind must be non-negative, got $kind" } + + val scopeBytes = scope.encodeToByteArray() + val (encryptionKey, macKey) = deriveKeys(privKey, pubKey, nonce) + + val padded = pad(plaintext) + val cipherBytes = chaCha.encrypt(padded, zeroChaChaNonce, encryptionKey) + + val mac = computeMac(macKey, nonce, kind, scopeBytes, cipherBytes) + + val payload = ByteArray(1 + NONCE_SIZE + MAC_SIZE + 4 + 4 + scopeBytes.size + cipherBytes.size) + var off = 0 + payload[off++] = VERSION + nonce.copyInto(payload, off) + off += NONCE_SIZE + mac.copyInto(payload, off) + off += MAC_SIZE + writeU32BE(payload, off, kind) + off += 4 + writeU32BE(payload, off, scopeBytes.size) + off += 4 + scopeBytes.copyInto(payload, off) + off += scopeBytes.size + cipherBytes.copyInto(payload, off) + + return Base64.encode(payload) + } + + fun decrypt( + payload: String, + privKey: ByteArray, + pubKey: ByteArray, + expectedKind: Int, + expectedScope: String, + ): ByteArray { + if (payload.isEmpty()) throw Nip44v3Exception("empty payload") + if (payload[0] == '#') throw Nip44v3Exception("unsupported future version") + + val decoded = try { + Base64.decode(payload) + } catch (e: IllegalArgumentException) { + throw Nip44v3Exception("invalid base64", e) + } + + if (decoded.size < MIN_DECODED_SIZE) { + throw Nip44v3Exception("ciphertext too short: ${decoded.size} < $MIN_DECODED_SIZE") + } + if (decoded[0] != VERSION) { + throw Nip44v3Exception("unsupported version: ${decoded[0].toInt() and 0xff}") + } + + val nonce = decoded.copyOfRange(1, 1 + NONCE_SIZE) + val mac = decoded.copyOfRange(1 + NONCE_SIZE, 1 + NONCE_SIZE + MAC_SIZE) + val kind = readU32BE(decoded, 1 + NONCE_SIZE + MAC_SIZE) + val scopeLen = readU32BE(decoded, 1 + NONCE_SIZE + MAC_SIZE + 4) + + val scopeOff = 1 + NONCE_SIZE + MAC_SIZE + 4 + 4 + if (scopeLen < 0 || scopeLen > decoded.size - scopeOff) { + throw Nip44v3Exception("scope length out-of-bounds: $scopeLen") + } + val scope = decoded.copyOfRange(scopeOff, scopeOff + scopeLen) + val cipherBytes = decoded.copyOfRange(scopeOff + scopeLen, decoded.size) + if (cipherBytes.size < 4) { + throw Nip44v3Exception("ciphertext too short") + } + + if (kind != expectedKind) { + throw Nip44v3Exception("context mismatch (kind): got $kind expected $expectedKind") + } + val expectedScopeBytes = expectedScope.encodeToByteArray() + if (!scope.contentEquals(expectedScopeBytes)) { + throw Nip44v3Exception("context mismatch (scope)") + } + + val (encryptionKey, macKey) = deriveKeys(privKey, pubKey, nonce) + + val expectedMac = computeMac(macKey, nonce, kind, scope, cipherBytes) + if (!constantTimeEq(mac, expectedMac)) { + throw Nip44v3Exception("invalid MAC") + } + + val padded = chaCha.decrypt(cipherBytes, zeroChaChaNonce, encryptionKey) + return unpad(padded) + } + + fun deriveKeys(privKey: ByteArray, pubKey: ByteArray, nonce: ByteArray): Pair { + val sharedSecret = Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privKey) + return deriveKeysFromSharedSecret(sharedSecret, nonce) + } + + fun deriveKeysFromSharedSecret(sharedSecret: ByteArray, nonce: ByteArray): Pair { + val prk = extract(sharedSecret, nonce) + val encryptionKey = hkdf.expand(prk, infoEncryptionKey, 32) + val macKey = hkdf.expand(prk, infoMacKey, 32) + return encryptionKey to macKey + } + + /** Exposed for test vectors that check the intermediate `prk`. */ + fun extract(sharedSecret: ByteArray, nonce: ByteArray): ByteArray { + val salt = ByteArray(saltPrefix.size + nonce.size) + saltPrefix.copyInto(salt, 0) + nonce.copyInto(salt, saltPrefix.size) + return hkdf.extract(sharedSecret, salt) + } + + fun pad(plaintext: ByteArray): ByteArray { + val prefixedLen = 4 + plaintext.size + val targetSize = targetSize(prefixedLen) + val out = ByteArray(targetSize) + writeU32BE(out, 0, plaintext.size) + plaintext.copyInto(out, 4) + return out + } + + fun unpad(padded: ByteArray): ByteArray { + if (padded.size < 4) throw Nip44v3Exception("padded buffer too short") + val plaintextLen = readU32BE(padded, 0) + if (plaintextLen < 0) throw Nip44v3Exception("invalid plaintext length: $plaintextLen") + if (plaintextLen.toLong() + 4L > padded.size.toLong()) { + throw Nip44v3Exception("invalid padding: declared $plaintextLen, available ${padded.size - 4}") + } + // The padded buffer length is fully determined by the plaintext length; + // reject anything that does not match the canonical size so attackers + // can't smuggle data in the padding. + if (padded.size.toLong() != targetSizeLong(4L + plaintextLen)) { + throw Nip44v3Exception("invalid padding: wrong target size") + } + // Constant-time zero check over the padding region. + var diff = 0 + for (i in 4 + plaintextLen until padded.size) { + diff = diff or padded[i].toInt() + } + if (diff != 0) throw Nip44v3Exception("invalid padding: non-zero trailing bytes") + return padded.copyOfRange(4, 4 + plaintextLen) + } + + fun targetSize(len: Int): Int { + val t = targetSizeLong(len.toLong()) + if (t > Int.MAX_VALUE) throw Nip44v3Exception("padded length exceeds Int.MAX_VALUE: $t") + return t.toInt() + } + + private fun targetSizeLong(len: Long): Long { + require(len >= 0) { "negative length: $len" } + if (len == 0L) return MIN_PADDING.toLong() + // next_power = 2 ** ceil(log2(len)) + val nextPower = if (len == 1L) 1L else 1L shl ceilLog2Long(len) + val subdivs = if (nextPower >= PAD_CHUNK_THRESHOLD) PAD_SUBDIVS_LARGE.toLong() else PAD_SUBDIVS_SMALL.toLong() + val chunk = maxOf(MIN_PADDING.toLong(), nextPower / subdivs) + return chunk * ((len + chunk - 1) / chunk) + } + + private fun ceilLog2Long(n: Long): Int { + // For n >= 2; matches ceil(log2(n)). + if (n <= 1) return 0 + var v = n - 1 + var bits = 0 + while (v > 0) { + v = v ushr 1 + bits++ + } + return bits + } + + private fun computeMac(macKey: ByteArray, nonce: ByteArray, kind: Int, scope: ByteArray, ciphertext: ByteArray): ByteArray { + val mac = MacInstance("HmacSHA256", macKey) + mac.update(nonce) + val u32 = ByteArray(4) + writeU32BE(u32, 0, kind) + mac.update(u32) + writeU32BE(u32, 0, scope.size) + mac.update(u32) + if (scope.isNotEmpty()) mac.update(scope) + if (ciphertext.isNotEmpty()) mac.update(ciphertext) + return mac.doFinal() + } + + private fun writeU32BE(out: ByteArray, offset: Int, value: Int) { + out[offset] = (value ushr 24).toByte() + out[offset + 1] = (value ushr 16).toByte() + out[offset + 2] = (value ushr 8).toByte() + out[offset + 3] = value.toByte() + } + + private fun readU32BE(src: ByteArray, offset: Int): Int { + val v = ((src[offset].toLong() and 0xff) shl 24) or + ((src[offset + 1].toLong() and 0xff) shl 16) or + ((src[offset + 2].toLong() and 0xff) shl 8) or + (src[offset + 3].toLong() and 0xff) + if (v > Int.MAX_VALUE) throw Nip44v3Exception("u32 value exceeds Int.MAX_VALUE: $v") + return v.toInt() + } + + private fun constantTimeEq(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} 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 6fe29bfa..4b381835 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,9 @@ 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 import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.isPrivateEvent @@ -813,7 +815,77 @@ fun BunkerSingleEventHomeScreen( } else -> { - if (type == SignerType.DECRYPT_ZAP_EVENT) { + if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) { + // NIP-44 v3 requests arrive as a generic BunkerRequest (Quartz + // doesn't know the method name); kind/scope are at params[1..2]. + val v3Kind = BunkerRequestUtils.getNip44v3Kind(bunkerRequest.request) + val v3Scope = BunkerRequestUtils.getNip44v3Scope(bunkerRequest.request) + val permission = applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == type.toString() && it.kind == v3Kind + } ?: applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == type.toString() && it.kind == null + } + + val acceptOrReject = IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, permission) + + BunkerEncryptDecryptData( + modifier = modifier, + encryptedData = bunkerRequest.encryptedData, + shouldRunOnAccept = acceptOrReject, + appName = "$appName (kind $v3Kind${if (v3Scope.isNotEmpty()) " · scope \"$v3Scope\"" else ""})", + type = type, + account = account, + defaultScope = DecryptTypeScope.SPECIFIC, + 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, + ) + } + }, + onReject = { rememberType, scope -> + val grantedKind = if (scope == DecryptTypeScope.SPECIFIC) v3Kind else null + BunkerRequestUtils.sendRejection( + key = key, + account = account, + bunkerRequest = bunkerRequest, + appName = appName, + rememberType = rememberType, + signerType = type, + kind = grantedKind, + onLoading = onLoading, + decryptTypeScope = scope, + ) + }, + ) + } else if (type == SignerType.DECRYPT_ZAP_EVENT) { val permission = applicationEntity?.permissions?.firstOrNull { it.pkKey == key && it.type == type.toString() diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt index 19560315..8f9f8142 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt @@ -170,24 +170,37 @@ fun IntentSingleEventHomeScreen( ) } - SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, SignerType.DECRYPT_ZAP_EVENT -> { + SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, + -> { + val isV3 = intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT val nip = when (intentData.type) { SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT -> 4 SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT -> 44 SignerType.DECRYPT_ZAP_EVENT -> null + // V3 has its own per-kind permission model; don't fall back to a NIP-level grant. + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT -> null else -> null } - val isEncrypt = intentData.type == SignerType.NIP04_ENCRYPT || intentData.type == SignerType.NIP44_ENCRYPT - val permType = intentData.encryptedData.toPermissionType(isEncrypt = isEncrypt) + val isEncrypt = intentData.type == SignerType.NIP04_ENCRYPT || + intentData.type == SignerType.NIP44_ENCRYPT || + intentData.type == SignerType.NIP44_V3_ENCRYPT + val permType = if (isV3) intentData.type.toString() else intentData.encryptedData.toPermissionType(isEncrypt = isEncrypt) var permission = applicationEntity?.permissions?.firstOrNull { - it.pkKey == key && it.type == permType + it.pkKey == key && it.type == permType && (!isV3 || it.kind == intentData.nip44v3Kind) } - if (permission == null) { + if (permission == null && !isV3) { permission = applicationEntity?.permissions?.firstOrNull { it.pkKey == key && it.type == intentData.type.toString() } + } else if (permission == null && isV3) { + // Fall back to a (V3, kind=null) "all kinds" grant. + permission = + applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == permType && it.kind == null + } } if (permission == null && nip != null) { permission = @@ -205,6 +218,9 @@ fun IntentSingleEventHomeScreen( packageName = packageName, type = intentData.type, account = account, + // For V3, scope toggle's "SPECIFIC vs ALL" instead controls + // whether the grant is kind-scoped — handled via [kind] below. + defaultScope = if (isV3) DecryptTypeScope.SPECIFIC else DecryptTypeScope.ALL, onAccept = { rememberType, scope -> val result = if (intentData.encryptedData?.result == "Could not decrypt the message" && (intentData.type == SignerType.DECRYPT_ZAP_EVENT)) { @@ -213,6 +229,10 @@ fun IntentSingleEventHomeScreen( intentData.encryptedData?.result ?: "" } + // V3: write the permission with the requested kind when + // the user picked SPECIFIC, or kind=null (all kinds) for ALL. + val v3Kind = if (isV3 && scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null + IntentUtils.sendResult( context, packageName, @@ -222,7 +242,7 @@ fun IntentSingleEventHomeScreen( result, result, intentData, - null, + v3Kind, onRemoveIntentData = onRemoveIntentData, onLoading = onLoading, rememberType = rememberType, @@ -230,6 +250,7 @@ fun IntentSingleEventHomeScreen( ) }, onReject = { rememberType, scope -> + val v3Kind = if (isV3 && scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null IntentUtils.sendRejection( key = key, account = account, @@ -238,7 +259,7 @@ fun IntentSingleEventHomeScreen( rememberType = rememberType, onLoading = onLoading, onRemoveIntentData = onRemoveIntentData, - kind = null, + kind = v3Kind, decryptTypeScope = scope, ) }, diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt index a963427a..19fba699 100644 --- a/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtilsTest.kt @@ -95,6 +95,16 @@ class BunkerRequestUtilsTest { assertEquals(SignerType.NIP44_DECRYPT, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("nip44_decrypt"))) } + @Test + fun `getTypeFromBunker returns NIP44_V3_ENCRYPT for nip44v3_encrypt`() { + assertEquals(SignerType.NIP44_V3_ENCRYPT, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("nip44v3_encrypt"))) + } + + @Test + fun `getTypeFromBunker returns NIP44_V3_DECRYPT for nip44v3_decrypt`() { + assertEquals(SignerType.NIP44_V3_DECRYPT, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("nip44v3_decrypt"))) + } + @Test fun `getTypeFromBunker returns DECRYPT_ZAP_EVENT for decrypt_zap_event`() { assertEquals(SignerType.DECRYPT_ZAP_EVENT, BunkerRequestUtils.getTypeFromBunker(mockBunkerRequest("decrypt_zap_event"))) @@ -167,6 +177,36 @@ class BunkerRequestUtilsTest { assertEquals("ciphertext456", BunkerRequestUtils.getDataFromBunker(request)) } + @Test + fun `getDataFromBunker returns fourth param for nip44v3_encrypt`() { + val request = mockBunkerRequest("nip44v3_encrypt", arrayOf("pubkey123", "4", "dm", "cGxhaW50ZXh0Lw==")) + assertEquals("cGxhaW50ZXh0Lw==", BunkerRequestUtils.getDataFromBunker(request)) + } + + @Test + fun `getDataFromBunker returns fourth param for nip44v3_decrypt`() { + val request = mockBunkerRequest("nip44v3_decrypt", arrayOf("pubkey123", "4", "dm", "A2NpcGhlcnRleHQ=")) + assertEquals("A2NpcGhlcnRleHQ=", BunkerRequestUtils.getDataFromBunker(request)) + } + + @Test + fun `getNip44v3Kind parses second param`() { + val request = mockBunkerRequest("nip44v3_encrypt", arrayOf("pk", "42", "scope", "data")) + assertEquals(42, BunkerRequestUtils.getNip44v3Kind(request)) + } + + @Test + fun `getNip44v3Kind returns null when missing or invalid`() { + assertEquals(null, BunkerRequestUtils.getNip44v3Kind(mockBunkerRequest("nip44v3_encrypt", arrayOf("pk")))) + assertEquals(null, BunkerRequestUtils.getNip44v3Kind(mockBunkerRequest("nip44v3_encrypt", arrayOf("pk", "not-a-number", "s", "d")))) + } + + @Test + fun `getNip44v3Scope returns third param or empty`() { + assertEquals("dm", BunkerRequestUtils.getNip44v3Scope(mockBunkerRequest("nip44v3_encrypt", arrayOf("pk", "4", "dm", "data")))) + assertEquals("", BunkerRequestUtils.getNip44v3Scope(mockBunkerRequest("nip44v3_encrypt", arrayOf("pk", "4")))) + } + @Test fun `getDataFromBunker returns second param for decrypt_zap_event`() { val request = mockBunkerRequest("decrypt_zap_event", arrayOf("pubkey123", "event_json_string")) diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt new file mode 100644 index 00000000..9b32b4f4 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt @@ -0,0 +1,236 @@ +package com.greenart7c3.nostrsigner.service.nip44v3 + +import com.fasterxml.jackson.databind.JsonNode +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import java.security.MessageDigest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class Nip44v3Test { + private fun loadVectors(): JsonNode { + val stream = Nip44v3Test::class.java.classLoader!!.getResourceAsStream("nip44v3-vectors.json") + assertNotNull("nip44v3-vectors.json not found on test classpath", stream) + return JacksonMapper.mapper.readTree(stream) + } + + private fun hex(s: String): ByteArray { + val out = ByteArray(s.length / 2) + for (i in out.indices) { + out[i] = ((Character.digit(s[i * 2], 16) shl 4) or Character.digit(s[i * 2 + 1], 16)).toByte() + } + return out + } + + private fun toHex(b: ByteArray): String { + val sb = StringBuilder(b.size * 2) + for (x in b) sb.append(String.format("%02x", x.toInt() and 0xff)) + return sb.toString() + } + + private fun pubKeyFor(privKeyHex: String): ByteArray = KeyPair(privKey = hex(privKeyHex)).pubKey + + @Test + fun `padded length vectors match`() { + val arr = loadVectors().get("padded_length") + for (entry in arr) { + val len = entry.get(0).intValue() + val expected = entry.get(1).intValue() + // The spec's target_size is over length(prefixed_plaintext); the + // table is parameterized on that prefixed length directly. + assertEquals("targetSize($len)", expected, Nip44v3.targetSize(len)) + } + } + + @Test + fun `encrypt and decrypt vectors round-trip in both directions`() { + val vectors = loadVectors().get("encrypt_decrypt") + for ((idx, v) in vectors.withIndex()) { + val priv1 = hex(v.get("secret1").textValue()) + val priv2 = hex(v.get("secret2").textValue()) + val pub1 = pubKeyFor(v.get("secret1").textValue()) + val pub2 = pubKeyFor(v.get("secret2").textValue()) + val nonce = hex(v.get("nonce").textValue()) + val kind = v.get("kind").intValue() + val scope = String(hex(v.get("scope_hex").textValue()), Charsets.UTF_8) + val plaintext = hex(v.get("plaintext_hex").textValue()) + val expectedCt = v.get("ciphertext").textValue() + val expectedPrk = v.get("prk").textValue() + val expectedEnc = v.get("encryption_key").textValue() + val expectedMac = v.get("mac_key").textValue() + + // Key derivation matches PRK / encryption_key / mac_key from spec + val prk1 = Nip44v3.extract( + com.vitorpamplona.quartz.utils.Secp256k1Instance.pubKeyTweakMulCompact(pub2, priv1), + nonce, + ) + assertEquals("vector $idx: prk", expectedPrk, toHex(prk1)) + val (enc, mac) = Nip44v3.deriveKeys(priv1, pub2, nonce) + assertEquals("vector $idx: encryption_key", expectedEnc, toHex(enc)) + assertEquals("vector $idx: mac_key", expectedMac, toHex(mac)) + + // Encrypt from secret1 → must match the spec ciphertext byte for byte. + val ct1 = Nip44v3.encryptWithNonce(plaintext, priv1, pub2, kind, scope, nonce) + assertEquals("vector $idx: ciphertext from secret1", expectedCt, ct1) + + // Encrypt from secret2 with the same nonce — same ECDH product, same ciphertext. + val ct2 = Nip44v3.encryptWithNonce(plaintext, priv2, pub1, kind, scope, nonce) + assertEquals("vector $idx: ciphertext from secret2", expectedCt, ct2) + + // Decryption recovers plaintext from each side + val dec1 = Nip44v3.decrypt(expectedCt, priv1, pub2, kind, scope) + assertArrayEquals("vector $idx: decrypt from secret1", plaintext, dec1) + val dec2 = Nip44v3.decrypt(expectedCt, priv2, pub1, kind, scope) + assertArrayEquals("vector $idx: decrypt from secret2", plaintext, dec2) + } + } + + @Test + fun `long encrypt and decrypt vectors match sha256 of ciphertext`() { + val vectors = loadVectors().get("long_encrypt_decrypt") + val sha = MessageDigest.getInstance("SHA-256") + for ((idx, v) in vectors.withIndex()) { + val priv1 = hex(v.get("secret1").textValue()) + val priv2 = hex(v.get("secret2").textValue()) + val pub1 = pubKeyFor(v.get("secret1").textValue()) + val pub2 = pubKeyFor(v.get("secret2").textValue()) + val nonce = hex(v.get("nonce").textValue()) + val kind = v.get("kind").intValue() + val scope = String(hex(v.get("scope_hex").textValue()), Charsets.UTF_8) + val pattern = hex(v.get("pattern_hex").textValue()) + val repeat = v.get("repeat").intValue() + val expectedHash = v.get("ciphertext_sha256").textValue() + + val plaintext = ByteArray(pattern.size * repeat) + for (i in 0 until repeat) pattern.copyInto(plaintext, i * pattern.size) + + val ct = Nip44v3.encryptWithNonce(plaintext, priv1, pub2, kind, scope, nonce) + sha.reset() + val hash = toHex(sha.digest(ct.toByteArray(Charsets.US_ASCII))) + assertEquals("vector $idx: ciphertext sha256", expectedHash, hash) + + // Round-trip with the other side, just to exercise the symmetric path + val recovered = Nip44v3.decrypt(ct, priv2, pub1, kind, scope) + assertArrayEquals("vector $idx: long decrypt", plaintext, recovered) + } + } + + @Test + fun `invalid decryption vectors fail`() { + val vectors = loadVectors().get("invalid_decryption") + for ((idx, v) in vectors.withIndex()) { + val priv = hex(v.get("secret").textValue()) + val pub = hex(v.get("public").textValue()) + val kind = v.get("kind").intValue() + val scope = String(hex(v.get("scope_hex").textValue()), Charsets.UTF_8) + val ct = v.get("ciphertext").textValue() + val why = v.get("why").textValue() + + assertThrows("vector $idx ($why) should fail", Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt(ct, priv, pub, kind, scope) + } + } + } + + @Test + fun `round-trip on various plaintext sizes`() { + val priv = hex("0000000000000000000000000000000000000000000000000000000000000001") + val peer = hex("0000000000000000000000000000000000000000000000000000000000000002") + val peerPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000002") + val sizes = listOf(0, 1, 31, 32, 33, 100, 1024, 65535, 65536, 100_000) + for (size in sizes) { + val plaintext = ByteArray(size) { (it and 0xff).toByte() } + val ct = Nip44v3.encrypt(plaintext, priv, peerPub, kind = 4, scope = "dm") + val ownPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000001") + val recovered = Nip44v3.decrypt(ct, peer, ownPub, expectedKind = 4, expectedScope = "dm") + assertArrayEquals("size=$size", plaintext, recovered) + } + } + + @Test + fun `context rebinding is rejected`() { + val priv = hex("0000000000000000000000000000000000000000000000000000000000000001") + val peer = hex("0000000000000000000000000000000000000000000000000000000000000002") + val peerPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000002") + val ownPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000001") + + val ct = Nip44v3.encrypt("hello".toByteArray(), priv, peerPub, kind = 4, scope = "a") + assertThrows(Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt(ct, peer, ownPub, expectedKind = 4, expectedScope = "b") + } + assertThrows(Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt(ct, peer, ownPub, expectedKind = 5, expectedScope = "a") + } + } + + @Test + fun `version byte rejection`() { + val priv = hex("0000000000000000000000000000000000000000000000000000000000000001") + val peerPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000002") + + val e1 = assertThrows(Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt("#anything", priv, peerPub, 0, "") + } + assertTrue(e1.message!!.contains("unsupported future version")) + + val e2 = assertThrows(Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt("", priv, peerPub, 0, "") + } + assertTrue(e2.message!!.contains("empty payload")) + } + + @Test + fun `pad and unpad are inverse for short and long inputs`() { + for (size in listOf(0, 1, 32, 33, 1024, 65535)) { + val plain = ByteArray(size) { (it and 0xff).toByte() } + val padded = Nip44v3.pad(plain) + assertEquals(Nip44v3.targetSize(4 + size), padded.size) + val recovered = Nip44v3.unpad(padded) + assertArrayEquals(plain, recovered) + } + } + + @Test + fun `unpad rejects non-zero padding`() { + val plain = byteArrayOf(1, 2, 3) + val padded = Nip44v3.pad(plain) + padded[padded.size - 1] = 0x42 // tamper trailing pad byte + assertThrows(Nip44v3.Nip44v3Exception::class.java) { Nip44v3.unpad(padded) } + } + + @Test + fun `constant-time MAC tamper detection`() { + val priv = hex("0000000000000000000000000000000000000000000000000000000000000001") + val peer = hex("0000000000000000000000000000000000000000000000000000000000000002") + val peerPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000002") + val ownPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000001") + + val ct = Nip44v3.encrypt("hello".toByteArray(), priv, peerPub, kind = 1, scope = "") + val decoded = kotlin.io.encoding.Base64.decode(ct) + // Flip a bit in the MAC region (bytes 33..65) + decoded[40] = (decoded[40].toInt() xor 0x01).toByte() + val tampered = kotlin.io.encoding.Base64.encode(decoded) + val ex = assertThrows(Nip44v3.Nip44v3Exception::class.java) { + Nip44v3.decrypt(tampered, peer, ownPub, 1, "") + } + assertTrue(ex.message!!.contains("invalid MAC")) + } + + @Test + fun `kind above 65535 is supported`() { + // The signer libs MUST support kinds above 65535 per implementing.md. + val priv = hex("0000000000000000000000000000000000000000000000000000000000000001") + val peer = hex("0000000000000000000000000000000000000000000000000000000000000002") + val peerPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000002") + val ownPub = pubKeyFor("0000000000000000000000000000000000000000000000000000000000000001") + val ct = Nip44v3.encrypt("ok".toByteArray(), priv, peerPub, kind = 1_000_000, scope = "") + val plain = Nip44v3.decrypt(ct, peer, ownPub, expectedKind = 1_000_000, expectedScope = "") + assertArrayEquals("ok".toByteArray(), plain) + assertFalse(plain.isEmpty()) + } +} diff --git a/app/src/test/resources/nip44v3-vectors.json b/app/src/test/resources/nip44v3-vectors.json new file mode 100644 index 00000000..1f21901a --- /dev/null +++ b/app/src/test/resources/nip44v3-vectors.json @@ -0,0 +1,461 @@ +{ + "encrypt_decrypt": [ + { + "secret1": "1b7023bb70248d8edab44658c5e2677dd7e5d7093ec062eb204975df4255fddc", + "secret2": "827844538be12d1cfa0f7fa096668cc4f2c4a25c2c8f7e92ca6cb05c3c445d17", + "nonce": "b5451a6d90ec575b4cdcedf4987429eeab1bbaa192ea3db89eafa058826885a6", + "kind": 1, + "scope_hex": "", + "prk": "3520160171dc39d75e64768d4fb667647480d458fc4d5c26d000a7cb3c8805b1", + "encryption_key": "de94e4663af538351a9b75b8af31e968ed8b88241ddbce43ad1d4ae2b984327d", + "mac_key": "70e65d5ff8769e92fbdf163b00b1b317bd4d30fe82de6b00d05cd74fb576febd", + "plaintext_hex": "efbbbf48656c6c6f20776f726c6421", + "ciphertext": "A7VFGm2Q7FdbTNzt9Jh0Ke6rG7qhkuo9uJ6voFiCaIWmMJrEDBNRRCorotVxmP7ge14Y+UtDn1/Pn3uzAaNNzHUAAAABAAAAAPJgoFXpn6mjFE0hUZrnZljeaYwSdqBKbVDXcyLgVGC8" + }, + { + "secret1": "f9869a8237c9fffd3bc175d21cc144051de4889da28b462ca1e4557adc2d2275", + "secret2": "c4c53829b9ad83682873761b71d667457935eaa84159a206dea58f18be09d05d", + "nonce": "f99a4a4a84a4906d839b62861dcd54883cccabb3616d003f27250ac00e672c50", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "prk": "7eeee2eac804eae839f64c4f2204ba6c205a65ae895bea006a45afd2ff9afee0", + "encryption_key": "56c727b1f69ff6ecb29c6cfd6469c1908da5556b0c13123b3303d5068edf03b6", + "mac_key": "f6be43893bffe64c43d56ee2692014d3e5275a78a3cd8268e2e1e0cb707a6bad", + "plaintext_hex": "6e6f7374722e6c616e64206e697034347633", + "ciphertext": "A/maSkqEpJBtg5tihh3NVIg8zKuzYW0APyclCsAOZyxQfHiK7t6u8D4JR3dRUKMpBRQzoOYtunePezG3p65AXPEAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzvgOo5isSBI06S531Yb9j9l+LpL9dA0D9/LLtorb866Y=" + }, + { + "secret1": "2f69dcb9891cf749ab0b4e07a718a9e364c44e7603d851c7c09e080b631534fb", + "secret2": "110ffc1f2ea8b15ffd5d24c59dc1b72c4b1f8180dd5ccb6a68097ff328f49e54", + "nonce": "ffffd9144f5fe48077ac672e1366d303dfebdf60b1abd07fce1ff762bb25a4aa", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "prk": "dd23c1dad51c025ed632be8b8da198517eef83a86729ccf524382f6011c9500b", + "encryption_key": "44f5de03559045aeb509d670299e7eab7f12682a7d5cc6c9a1441fd35f9484a9", + "mac_key": "ab08e3aa28d40c9376a56056dea33b3e935402da5585607b081ad166cecd8432", + "plaintext_hex": "f09f9088f09fa694", + "ciphertext": "A///2RRPX+SAd6xnLhNm0wPf699gsavQf84f92K7JaSqrm0b+bxgKBqNS04QURAmEXZlYBY9Ed4neDw2uOAqkGcAAAABAAAACeOBr+S4lueVjIEUNKR4ekMqHUoWb/ks495G0c1lD6oPQ3ZFsa4LHvRE" + }, + { + "secret1": "e945941c87478b88c8af150219ed8055692f3f01543a3dec3cb40854fdf8545b", + "secret2": "11eefd6b9a1a4d4e4b71840aa77eb47d3821d825ca8d4e45065ff563bdc342d9", + "nonce": "726cab7f363afe8c0783dc1d2d6e4700ace52a26996a53ba3928ef3c865cc235", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "prk": "7fafc5865086ba6ef1d48c93fa5e8c84dc0fd73924f23a4560d8d0f31f9ab2db", + "encryption_key": "2b6ed20127afba197082b52159120042d0bdfec6df2e657944f79a62ec90a1d0", + "mac_key": "2e72490f486fd6f0ed2ca5e508dff16b6db6936df59b77406717e6aed645d2a0", + "plaintext_hex": "e69a97e58fb7e58c96e381aee3819fe38281e381aee382a2e382afe382bbe382b9e588b6e5bea1e6a99fe883bde3818ce799bbe5a0b4e38197e381bee38197e3819fefbc81", + "ciphertext": "A3Jsq382Ov6MB4PcHS1uRwCs5SommWpTujko7zyGXMI1d8FRsRgcGnjOo+Ifry8x/QC+vDDkPCHv7WDaem7tQ10AAAABAAAABu+7v++/vm+/pDQcUXHli2Do1EEoqYFmF/67UUcl31Ks9TRy9vCwc2IUY6Ev9T+oBanqVWGbPgAWysjisi5dIPAEcndMK2Ur4m2UqTo3WVTIqKmy30ad5VOwl4v1AHweiZvJU/w+lQ==" + }, + { + "secret1": "98c7c39a4abf5f923db71a3e2c0951fa020bc5ba1555c158ebc8663e1582bb01", + "secret2": "b775d4f4ef14b1a93cc34a534a64a1ec2cd1a64a5a7b45f837af5ea4595b37dc", + "nonce": "ec64f769d99bc3c6f5231145b546334275d910e11fe9a11351ee487e4dbfd4ec", + "kind": 1, + "scope_hex": "ef8080", + "prk": "98fca3e635b9478407385a8989fb78ddb115d992a92019852953a4cd139aeb69", + "encryption_key": "9e6bdba691401f02de1403f75ebcb3516f5ff3b77c8a3918d8a3393e73eb3188", + "mac_key": "b05399edc9e6eae42cc21ec5941a73e8b7387bf10f49dfcfd740131309d81c35", + "plaintext_hex": "9b8de973ddf42a02103de24d9b7a4f0c4f551abaf7cd88f08e7a9c4d41ec5f777b45c890c112968fee50dccd3287583e9a3a33f962d78054f36dcb6f1ea9a8aa3fcb80953e04f6a2b3c3c4e26909ef7c5e84da6df3fd423215015640b249c91b28b38b18499b615bf1e92635e1df15aeeba2063692ce7cc8296582ceed25ceda", + "ciphertext": "A+xk92nZm8PG9SMRRbVGM0J12RDhH+mhE1HuSH5Nv9Tsg6J943ljpXnIaVIuHaXrWfa99RkqZOW6NGy6oqm2HocAAAABAAAAA++AgNCkiGZgN5Uzx1HVpcoLQQisIwWD32PqBoQ4T598/KmHsxUAGEARiXh9ikGXtwKuH8a8EzTcobkr4OEXfPs0h5u0A1HUJ3M/Hc/orcqZgeA0RhfZe3IASVmQfU9/pge+nTPjJVK5ZHOlEBnt7tmYcT8vqv9bpxbyhCBGMO6nEFhUtrr2IKCW3Z6vljg7T3FDr7aVIY/cxniq4E+e5ec9pZ+wn3j9PAibWgEANCDK5nyiH6B348lnqxfmu8bvzzyPhA==" + }, + { + "secret1": "b0e73a57d65972a4276879cb8604f683dfd9197cc236f299ea55acb66bfa8ff0", + "secret2": "ebf87d9858227055ac9f789911edad1b55777edc99dd4b8634f52bb8c0922edc", + "nonce": "c027624d50656a34add75cec7e476e6287bc919cacf0ebbda6d3277c02b0a239", + "kind": 1, + "scope_hex": "", + "prk": "a76ecc57266a24238761cf79c9909e27af6adfa523fb914a1a54e17d15e26287", + "encryption_key": "3c5be4141db8d4e4bfd998b6a4f995922070b9dc4af41c5d50c89c7ccd437f0e", + "mac_key": "92704765cd32cbe3beb21f347541184fc0cff839c8d2077d198d1f91103bdd22", + "plaintext_hex": "6120646563656e7472616c697a65642070726f746f636f6c20666f7220616e797468696e67", + "ciphertext": "A8AnYk1QZWo0rddc7H5HbmKHvJGcrPDrvabTJ3wCsKI5ZMf+aMW7P7Iz5qDghY+87TL5pZjNiykm0xpMKlkwITgAAAABAAAAAE2F94qgXOR+co8R41Vu04wLtkrI3Y5QJbVmutA5v1MkCgrLCmZAwNXhQsUnzUOuAPloXVQQdgQL4gmVgIz0rqQ=" + }, + { + "secret1": "0bac57d63af3e6650152577f7d5515062270b68cd2cda1250604ab70b7cdf091", + "secret2": "ddb09a891ef13bbf1b9ed8fb403afce4eea2197428da805dc85d90eee76e20f2", + "nonce": "0da18d3ebcc5f269f6415e3e3fcb5e1a8d76318fe439ec83cfdf99ef8eaacee9", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "prk": "d084d04cb7e61bb0c8fc7fbfaf48b58863fc01c1a4cebc7d48cad3b3853ac7a7", + "encryption_key": "be6eb2e4aa213dc2260abc2414e763057ab7df785e33338f1a3167ac280ee7fb", + "mac_key": "59f55d61316dee4f7f71c7b3d9704ef822d5d31bcfda30d02a267f29cb20d92e", + "plaintext_hex": "efbbbf48656c6c6f20776f726c6421", + "ciphertext": "Aw2hjT68xfJp9kFePj/LXhqNdjGP5Dnsg8/fme+Oqs7p+xyexXUdk8ZJ2rtLWT1xQ9lXxWSiagEVpRg35PndmKQAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzuKZ6xxWsljlgBA/i6yz7+dmE6dyszU9qkR7f2xDUQdg=" + }, + { + "secret1": "b69f38d981ad22b1fd25473756b2dd9c69d1554c6d31ae2a64c0fc82aafd86ac", + "secret2": "c916f18fd08a90c1d20bdfe27f31c53d33ebefdbe28e3da8797632b4b474b9df", + "nonce": "8b3c3f3aaf575328259ac5e3c08191dde308c573e3f4e7cda7042f82133143fb", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "prk": "93c97bd637c3fc60d9dbdb410df34c4a614c52db57ed0f2a218e8a973e125265", + "encryption_key": "e0a45a9306cc404aea91687e2b3c26abe23b0e12945799279e332c1880cacd78", + "mac_key": "b21748a15b40d53bbdaeb81c419c160994780d141324fcb1ac65bcd8be6bb6f4", + "plaintext_hex": "6e6f7374722e6c616e64206e697034347633", + "ciphertext": "A4s8PzqvV1MoJZrF48CBkd3jCMVz4/TnzacEL4ITMUP7b2QxXAKNEKp93ebvTrmrJ4aeJtLvqRokEeGXPBLE9UsAAAABAAAACeOBr+S4lueVjO04T51hx+sZw9n3gheEAyVOP0w/pWFvFtCuolpBkHvk" + }, + { + "secret1": "20d7e7e95a8e6376438182425c33c9445055fa4a8bd2c57e5c7902015433e18d", + "secret2": "d38139efe4118dd5862c2556600ef7914d1659cabbd1a3d5fd9f2a0abe9dcbb3", + "nonce": "20c635f2f795178ea0bbf9856dd99da02138ba79337d2511d887f2a065b917c9", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "prk": "4f9c75fe7c850a79f83000901ef8f020301c06e413a84de01784971ec249bb7b", + "encryption_key": "4345c818ddb2793427d8f5bb056e663cd941f910165601ff6806866cb7fb0fc3", + "mac_key": "4eb9ee0ea464574336446a8aae961f05b6a65cd5feb2087417eabf5344c554da", + "plaintext_hex": "f09f9088f09fa694", + "ciphertext": "AyDGNfL3lReOoLv5hW3ZnaAhOLp5M30lEdiH8qBluRfJTmsWPfIzALsx5OokjdKYAWkgDkES88FoC4k6wtgxUK8AAAABAAAABu+7v++/vmSE/qHW8+XDY97+8EQCRVPzORPYKrnLM6mNRp+zl2C6" + }, + { + "secret1": "1a2c6e81b5f1038fdda1f555d0431d1bd3efb22d57f608708fa46d7d7b96f1f5", + "secret2": "c18596eac499c94e04334021c1b6952757d83aeda2aa84f90ab47357cdd29fdb", + "nonce": "a05a11dcd50aa1e855b7e11a816158a1a4827d21a00b60105ed3c8e802770d77", + "kind": 1, + "scope_hex": "ef8080", + "prk": "c043b08590fcc2ef03e299633af842deffd1b5dbd2bf598606bf02abb898303d", + "encryption_key": "ba927e27a656a34369920ce7be028b6f6cb5878890123d1d3ba6b9f7ef4ab9c4", + "mac_key": "71163bde93ea8fa3b5574e81869416bcb8f6954a3b746e1b2ed24546949e208c", + "plaintext_hex": "e69a97e58fb7e58c96e381aee3819fe38281e381aee382a2e382afe382bbe382b9e588b6e5bea1e6a99fe883bde3818ce799bbe5a0b4e38197e381bee38197e3819fefbc81", + "ciphertext": "A6BaEdzVCqHoVbfhGoFhWKGkgn0hoAtgEF7TyOgCdw13O273WC9FSDyMtfOYNFvOlZQcaSrLdo6WBQ7ZI2UWn5MAAAABAAAAA++AgPPJWHFZya+M6arLz4wrWMHfL4Wyv4gYZBkicAvVBX0dMsr5tBcTP5xaM4lJZZnokEvMZRzYbjrfNTjT2gCWBapNdr/QrHxlTDa54nRmVR/2GBLkmQ5QeIiDm6OhfjXyYA==" + } + ], + "long_encrypt_decrypt": [ + { + "secret1": "e35f016acdf0bec26f9f0e97fd813aa042727cb1e5ac2adf1c7b8d18d393f455", + "secret2": "e3c47278057365a1007414224f54ee99e6198ac6b6a82917e635375a1f9afa8e", + "nonce": "0598a9aa024df86e1e532e8cd3ed412e5b8bc914ff0340aa8868f9fd2fe2871f", + "kind": 1, + "scope_hex": "ef8080", + "pattern_hex": "9b8de973ddf42a02103de24d9b7a4f0c4f551abaf7cd88f08e7a9c4d41ec5f777b45c890c112968fee50dccd3287583e9a3a33f962d78054f36dcb6f1ea9a8aa3fcb80953e04f6a2b3c3c4e26909ef7c5e84da6df3fd423215015640b249c91b28b38b18499b615bf1e92635e1df15aeeba2063692ce7cc8296582ceed25ceda", + "repeat": 511, + "ciphertext_sha256": "cf2c183f974c2601c0ec5d4fad0f0f98f18b0c83bc4e988c53ec1e496528deb1" + }, + { + "secret1": "69efe21ce6ffe00d4126a019542e61324ff59fef06c81798cf1ec9810bfa5566", + "secret2": "91749344a212c2168587ad46197ef2eb026e3fa6839cb4286599c4f24820431c", + "nonce": "8c9f02398c9c5c11260b9ec27292bd32f0127c3e5366b255e0878ecb82e81eeb", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "pattern_hex": "9b8de973ddf42a02103de24d9b7a4f0c4f551abaf7cd88f08e7a9c4d41ec5f777b45c890c112968fee50dccd3287583e9a3a33f962d78054f36dcb6f1ea9a8aa3fcb80953e04f6a2b3c3c4e26909ef7c5e84da6df3fd423215015640b249c91b28b38b18499b615bf1e92635e1df15aeeba2063692ce7cc8296582ceed25ceda", + "repeat": 1023, + "ciphertext_sha256": "8538f11d334dee64c561961ab7b371a90cb5ded3d1bf6c544d93aa4c72e5b1c0" + }, + { + "secret1": "9ed97778c4bcaf3b5c66c41d3b97ec62e89e2bb9ead5d27a980a1c268a24a2c9", + "secret2": "58cba97d2985b001a7367a088a2e868a85158bd218f2a5858eb23a43de4ea382", + "nonce": "d8212d54ca0a36a7a5ed9f33656aebcd995f64dc6c4551a54b0dad5b897e254c", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "pattern_hex": "9b8de973ddf42a02103de24d9b7a4f0c4f551abaf7cd88f08e7a9c4d41ec5f777b45c890c112968fee50dccd3287583e9a3a33f962d78054f36dcb6f1ea9a8aa3fcb80953e04f6a2b3c3c4e26909ef7c5e84da6df3fd423215015640b249c91b28b38b18499b615bf1e92635e1df15aeeba2063692ce7cc8296582ceed25ceda", + "repeat": 2047, + "ciphertext_sha256": "66c5b453cc7123d9826115d437902db5226dd25f41aea9519f986938709c2901" + }, + { + "secret1": "77930acaf9d28607482f0d329d65eea04fd218a957f25004d6606414dcdea848", + "secret2": "c31571de3b9b8053d5477a8dd090d7ed7ffbed98f8e9c904b8034ba77f74e232", + "nonce": "b40a2f2ae51c8355ed8bce7f810628c5fd3a4c5d4fe9170c159b9c7e9d1d5f87", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "pattern_hex": "f09fa694", + "repeat": 16383, + "ciphertext_sha256": "ccd2942a398aaab22845d6d65599a82fa9ee5fc1caecb5a8cc358771b1b7ba7b" + }, + { + "secret1": "dc0ded78a0d133195b49429aadc6d424fde9b98a0e9ee12b4382bc0f08125a1c", + "secret2": "fac11ede5498f415f3a48cdf052a4e0d2f77fc4012baaf77de70a3f2cb4bc195", + "nonce": "7f2506e82ad6d97fa2cbbc2cf9f3a02bb61ce65bbe72a891c07c7bde23ade06b", + "kind": 1, + "scope_hex": "", + "pattern_hex": "f09fa694", + "repeat": 32767, + "ciphertext_sha256": "d84d643bda0d6f11dfc165eff69ab7c12c31f7f651603fe9e3397fb2ebb44a24" + }, + { + "secret1": "b585991901f19ca1353b4122a591c3ade793338174f70326ee351d54b2b4c9be", + "secret2": "cdbc4210a142109928087965a6e47922ed65763165cebdb54498770da448d5b0", + "nonce": "aef913a704ce90355a134dd5f4ea253115d9d426269f371f45a33de3c79a90d7", + "kind": 1, + "scope_hex": "ef8080", + "pattern_hex": "f09fa694", + "repeat": 65535, + "ciphertext_sha256": "75d4386e1e5bf39c2775486c066274fbceb9e0aff2880b513ec1593ce8a68f74" + }, + { + "secret1": "57420cd8c43b789a506e7dd1eb433b010e5323eb219de7c6a6a6f6976aa80693", + "secret2": "370091dce8dce4c3cf6b1a67ec8f41f9ae78a69ea902c67bdfe8930d90b2b7d6", + "nonce": "15ecf921c0e227f5199523de99193626087d506d998b8abd3c086e66fb25af0e", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "pattern_hex": "e38193e38293e381abe381a1e381afe4b896e7958c", + "repeat": 3120, + "ciphertext_sha256": "0577e05453f458e700740be3d849096f3d9d46924333ce3aa27c342a786a6cc4" + }, + { + "secret1": "aeda4666950455c2e038d6b7d9e000be92be6aaecbb57b7f0f980ba29da52453", + "secret2": "9d819437f4eb60290bfb9aa547d426516a3ea07a2149e46f3310acc85dd45a18", + "nonce": "56146d9c3caf5c118288754d2caabd142eb45e1f2d80f3caf5183888ca2ee416", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "pattern_hex": "e38193e38293e381abe381a1e381afe4b896e7958c", + "repeat": 6241, + "ciphertext_sha256": "6a0c112e126aa897f80ac73227fbb062ac7ded96f641a05fa4634a9f4a8c702b" + }, + { + "secret1": "f153b2eadf9aa51bedf90ac8804dbe2bc4fed1ddd3861a857ae80a20d5c55f27", + "secret2": "22c42e7079ae57313d99c2f025c1be2f5dd999a3898d9aecbed7a207a1018510", + "nonce": "a7db4437442ca0ffbd836822c622115bc001de197c9f1a1d3d67a1e63c044d30", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "pattern_hex": "e38193e38293e381abe381a1e381afe4b896e7958c", + "repeat": 12482, + "ciphertext_sha256": "c066419c9ee88e7c8a77dbe25fac1dd8e9720fa4f481924058189062b12e3fe1" + }, + { + "secret1": "217d14ae21140584417aa9bbcab4bdf4adccb5e74b191d65d2b357794f7f7143", + "secret2": "cccdef797a083bea633fcff31f255b57b5d5c99b682eda8ff132066dc3cd9127", + "nonce": "143cb408e61cf7b4281b0ccf300284a68d7df282f71667df5740b5f282424880", + "kind": 1, + "scope_hex": "", + "pattern_hex": "21", + "repeat": 65532, + "ciphertext_sha256": "e5360cffeef8c31c88a3455b920d9d9a98c8669b77d38ac232e0f67420467e5d" + }, + { + "secret1": "625bbf8de97b71e8d70092bb6c576ee95895b7e6d2acf924790c80dd69785e8f", + "secret2": "13e7e6925ef02a644ea1c7bf8b68ff7de8c676ecf7f22d49a78a1802a6933189", + "nonce": "1bbbfd8803eac5c844e96ade2fdacf18fe3bda62312bdda102a29b52dd0c97bf", + "kind": 1, + "scope_hex": "ef8080", + "pattern_hex": "21", + "repeat": 131068, + "ciphertext_sha256": "8dd9a3157906d3a4e3de2b4d552cddae489a0996812bacca7728996a2605cae9" + }, + { + "secret1": "266503c3818aa70800280c53be6f7f8156273c6c606cfbb0803d8eb63dca65f9", + "secret2": "9846126fad398ea9b6fe7ebddd7e28c021691eaff5355847cca42a2caacacb68", + "nonce": "9815cf89d4ab86023b1a427166fd8db49681a077d5724d1b743a57cd6fb96b81", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "pattern_hex": "21", + "repeat": 262140, + "ciphertext_sha256": "0bd33e75b6bb9be42a1fce897801d064943945a930977551437031a413e07443" + }, + { + "secret1": "39506d419c1dede09cba910247457d66be7b213adbd981e3af0ef69b46c790f5", + "secret2": "5294e3ad19298a304753a32a42be2d44f3438f553677f30aab591f8a19ba4fd4", + "nonce": "8ccdf24876123afdbce42e59d4e03c53589150b4f23b087d3e3bfe89d96a54a6", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "pattern_hex": "00", + "repeat": 65532, + "ciphertext_sha256": "c4c030de28d8b5fa9aafb1961aab296f078aced2988897c7907fa67cae073de1" + }, + { + "secret1": "86bbd84e3e9e2db2e7ec37fb16900723aac499f1074b66586b44a3ed63022a3e", + "secret2": "17d3ff292c4a65e6181ecc201b9c5ed0c7e8408f36183659181414ddbb17c3d5", + "nonce": "f7ce68c00fb546c23efb88f778819c131797705abfc222886406b8547a9c244c", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "pattern_hex": "00", + "repeat": 131068, + "ciphertext_sha256": "3992c52ad7811ba181f572d37a0e1c9a4dbc1d9b127caa171896eb861d1e818c" + }, + { + "secret1": "d38c5d7c0ba3b3e103318eb12952a481a204ab8dfd1d440b8882e9f0649ac0ee", + "secret2": "484475e3653226fd962fb9ca9ea8c8d4929473308eb53fd73a8ccd95e5eb8b98", + "nonce": "47fd0420348c7c4dead0c52874f2efe9ba9ecc9c4f9ee82319e1f33338d3ab86", + "kind": 1, + "scope_hex": "", + "pattern_hex": "00", + "repeat": 262140, + "ciphertext_sha256": "e03e2f16a5ee7e874d3cdc52e36ae8e73791d664540cdb7ac36f65687c7fc2a5" + }, + { + "secret1": "fd20231a23032bde7acc638eb7086784670dcf8bc90e57a65c1d03d68594d3f0", + "secret2": "b47f3ba965fbea51c927b6846543480aeb25275c0e01c5c4d286ea087e70ee92", + "nonce": "6c9820a69d021d86a695eb3d5cc11ec638a799aca10ecfb9819b1efa3b9731ff", + "kind": 1, + "scope_hex": "ef8080", + "pattern_hex": "ff", + "repeat": 65532, + "ciphertext_sha256": "6621fc89a79464d19cbef605d962c9f43af90568c8c352007829445d1886c9bf" + }, + { + "secret1": "38c961d0c8289789cec189e8db140f740abf34c8251eb16ad93cc5f9021abeb3", + "secret2": "e985cda5e08979c17a2c148532434c6f830bc2243e7a5592704972328f24d62d", + "nonce": "3957e9cc3be20433aff61993558572099e186312e714050cd3a589ff675f0e07", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "pattern_hex": "ff", + "repeat": 131068, + "ciphertext_sha256": "e41d7c737462f18b653683796d739c3b93f284c9bc15182bd94d278d13b84dea" + }, + { + "secret1": "0666935a88d5196746c799ce0593dede7a8e8044930e62e07793950b9dab9b4d", + "secret2": "e3505f4e3b8fd37da2a9b4f3cb67864819e6272cdcdcf198f422598fa1114d21", + "nonce": "56fef50b564913b040a9ee83c9c1eb36ac6553a9e5ad699ac036fb4338a38e35", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "pattern_hex": "ff", + "repeat": 262140, + "ciphertext_sha256": "086aaa494e3a4ca5bc573d91fbed292d9abe05fb730c54660bf5813250744aab" + } + ], + "padded_length": [[0,32],[1,32],[32,32],[33,64],[34,64],[64,64],[65,96],[66,96],[96,96],[97,128],[98,128],[128,128],[129,192],[130,192],[192,192],[193,256],[194,256],[256,256],[257,384],[258,384],[384,384],[385,512],[386,512],[512,512],[513,768],[514,768],[768,768],[769,1024],[770,1024],[1024,1024],[1025,1536],[1026,1536],[1536,1536],[1537,2048],[1538,2048],[2048,2048],[2049,3072],[2050,3072],[3072,3072],[3073,4096],[3074,4096],[4096,4096],[4097,6144],[4098,6144],[6144,6144],[6145,8192],[6146,8192],[8192,8192],[8193,12288],[8194,12288],[12288,12288],[12289,16384],[12290,16384],[16384,16384],[16385,20480],[16386,20480],[20480,20480],[20481,24576],[20482,24576],[24576,24576],[24577,28672],[24578,28672],[28672,28672],[28673,32768],[28674,32768],[32768,32768],[32769,40960],[32770,40960],[40960,40960],[40961,49152],[40962,49152],[49152,49152],[49153,57344],[49154,57344],[57344,57344],[57345,65536],[57346,65536],[65536,65536],[65537,81920],[65538,81920],[81920,81920],[81921,98304],[81922,98304],[98304,98304],[98305,114688],[98306,114688],[114688,114688],[114689,131072],[114690,131072],[131072,131072],[131073,163840],[131074,163840],[163840,163840],[163841,196608],[163842,196608],[196608,196608],[196609,229376],[196610,229376],[229376,229376],[229377,262144],[229378,262144],[262144,262144],[262145,327680],[262146,327680],[327680,327680],[327681,393216],[327682,393216],[393216,393216],[393217,458752],[393218,458752],[458752,458752],[458753,524288],[458754,524288],[524288,524288],[524289,655360],[524290,655360],[655360,655360],[655361,786432],[655362,786432],[786432,786432],[786433,917504],[786434,917504],[917504,917504],[917505,1048576],[917506,1048576],[1048576,1048576],[1048577,1310720],[1048578,1310720],[1310720,1310720],[1310721,1572864],[1310722,1572864],[1572864,1572864],[1572865,1835008],[1572866,1835008],[1835008,1835008],[1835009,2097152],[1835010,2097152],[2097152,2097152],[2097153,2621440],[2097154,2621440],[2621440,2621440],[2621441,3145728],[2621442,3145728],[3145728,3145728],[3145729,3670016],[3145730,3670016],[3670016,3670016],[3670017,4194304],[3670018,4194304],[4194304,4194304],[4194305,5242880],[4194306,5242880],[5242880,5242880],[5242881,6291456],[5242882,6291456],[6291456,6291456],[6291457,7340032],[6291458,7340032],[7340032,7340032],[7340033,8388608],[7340034,8388608],[8388608,8388608],[8388609,10485760],[8388610,10485760],[10485760,10485760],[10485761,12582912],[10485762,12582912],[12582912,12582912],[12582913,14680064],[12582914,14680064],[14680064,14680064],[14680065,16777216],[14680066,16777216],[16777216,16777216],[16777217,20971520],[16777218,20971520]], + "invalid_decryption": [ + { + "secret": "b2a4cca9347992d235fe115382098e313f6eaa3680248443b90c64e4e2ab039e", + "public": "dc62907f84a35acecfc55b6d82961399f019981be0cd7d5e6a5a0620f9158870", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "Awx1nilOH4b0PT+ZszAS4TqOfADUQxWfAHAUVyJmy7c8EvrgFmKouWAVFZyjYN2XuuGSWHlKeuo9bF9t7MwMGfwAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzVeOxtyTClFO2/OPL6lpuSi3WFTdQgbhX6g/f1Iv2K6o=", + "why": "invalid MAC" + }, + { + "secret": "83ed5a7ae0494831e938a0a8226472954be9daffb4bf5d7641473b35e959cf90", + "public": "90ecf0dd8a793c74809735cc37cc3b9de20ffc9aae0eae7a1a0c740ecf09e395", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "A90ZzLN2HaQRTrzzLoobOtW+c9GyPxVp64fhIygpEpLaYYbCN6Pq1rjptBbN5S2vCFPCsE3wmU5u3Wx6L8oZxJoAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYz8Sxek35q19YWqmhyQNRHVZ+sNTtdgXO3MCnvjw0nanb233a39sc969Lm5DaUPN+yTKV0NbtYYN5hIWDOMOXj63XtzT7S7i/LAhv/l8y1zLTc0aUUoIjEg0EHi/FvlakK", + "why": "invalid MAC" + }, + { + "secret": "efd2ac18f500ac0fa1b9639149432ff2d309d1b49c7f683c9ca4613d14449dce", + "public": "84194beab56b44c426b866261772bc0a447ea34f94f2317ce1350cb714021a25", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "A5vkMZfJPQuWeOQsEiuZX1M4VJLY/k2G1mL8EKHK/yq7gifeC4V4zQ4L1iiQ1oVgmTmhwb/vd21Fm3YZrpYGEvYAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzQlapufh4trECMJjdb8m/TNFFcFJJSmiM/XQpnKXM/wc=", + "why": "invalid padding" + }, + { + "secret": "2926c352495ce986639ccbb263ad2221df731bae6ee8ec329cbb5d00c5b9ca87", + "public": "3c4b835fc7de0dd3a02971b559ccd5d3bcd2eb3cce7c1023b93892918effb71d", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "AzW5Wy/bvTYdcVOLIL8W26mfgTFG19S0H2BC9kyqgqK+TZ8oI00WJTNqwJIg8JE7DqDnOY+Q40Yd3G8Hi4GobQ8AAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYz3cI/rKzcQ22idVIBuFgLEVJaK1W5uxh6M6AdMoKKtdxO+lESYYWCEh/zzj35MFMwSJTn8z+XFvv9f2jYgQXHvPmY13wLOpIrpoS2W8luUbOVf9fip5mZXHw3neYvc7jA", + "why": "invalid padding" + }, + { + "secret": "47c04c4c6d385ddaaef691bd58dab94f81f4ba9eb1ab832339c3d0042f998dc5", + "public": "cb6a3aa6d94a58c9f03354a2a8723c3449e06b19a0ccfe19353195b28ddf7694", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "ciphertext": "#A2A/tbfDDqn4qx267aPFZDwyH78j9zZV8g8ekZKonH8bDR7vYhp7zzh3oJAlJWem/Z5OVrRvUAJQrx8q289PqsEAAAABAAAACeOBr+S4lueVjASdOe8pTxevoZoYq1Y8rRarB6+yzRlquT4RZlmHH3jLEQmAbBjQGrOXi1uWPbaKC8j/VpjW5S9BAtyMSMpUcHg=", + "why": "unsupported future version" + }, + { + "secret": "751321afdaaf76aeb87851ca8f35eab87d0f50e1ec595c67e7e37ced7d6b7b9a", + "public": "a340bd34205bb0f3dc2f9aa3fcb70fc52c326cc4566c457bb9cfb9ae17c239af", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "ciphertext": "AP9SHg4CFoD4fy22vSMNZV+efP7Ld7GCOpIKeZANqL+Kspe380sGxRQGKyy/liCuMi8DbcfQJypivkS+Y/bz3sIAAAABAAAACeOBr+S4lueVjMM0jKIQIfjKuEBBmjWPFmQsB20qe5pcJiLpnnXmp12z", + "why": "unsupported version 0" + }, + { + "secret": "c4ca1bf68b1f768bbc3670d554036b5c892303319ed6f7228e8dc2f6c99dd0b3", + "public": "537986db4ffe564eb7d565643e78dbc24e86650957e6e7c2c8d0124fd04fd68f", + "kind": 1, + "scope_hex": "", + "ciphertext": "Ap2Mv1HTQrVArE2UVevKq7rQ+a0FMw8OBuiAMnA81jJit7c0QzkEMr/o+5++t0/FbXFABfQaTRpF+dBuISyw3rEAAAABAAAAAC5e+Y9lfvgD1trmXL2Jv5H3Khi8ayWJJQMrVOEMd9tlJNj1b7k/ZzIG71f2GBzzoeBImA2fk+q6Iix4v5jUulo=", + "why": "unsupported version 2" + }, + { + "secret": "9460c5781801b35f01c1093434266cab5ba997014c52d9a16c3772f8580ac61b", + "public": "375cf44101e7d77699da6b8d7e633f507eaa82720d4d3cea1c0db85447975fa3", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "BAahtRKP0WL8luCz9m6TydiQtWUfoIWvkRlg2tatPVOCAwYrO8Dw3DZMgeTGjaehohfAmVyZ6SneuTQF3Ho+EUIAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzpHJCeyhgMaqIsPgWO635BaIwmRU4cfe9aA6gGqGI7SY=", + "why": "unsupported version 4" + }, + { + "secret": "b46bd96a998b00b55e48ad76c1fa0c68bc64b94174a189983b689fc05a0cec52", + "public": "797b06ba5dd8ca23a8c8cae4e9ee8963b25d9bf92618fa0b62db1ddc7b611453", + "kind": 1, + "scope_hex": "ef8080", + "ciphertext": "", + "why": "empty payload" + }, + { + "secret": "42602869f3d35e0bb04f72e3053d8de9cccf1e78dd6926c9311134784a5e70d6", + "public": "94a4ac3a4bf998dc64ab07cbb573ecb045e028f9b19c88f02af8f73d334ba0bd", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "ciphertext": "Aw==", + "why": "payload only has 1 byte" + }, + { + "secret": "d2dccc683567cd0cc6d9ec907703ddb362ecb61ef8e6c8ebb7411a293230935a", + "public": "294da03d650400e766953c9fb78600b788598a35e47b9755a891802de76f7a09", + "kind": 1, + "scope_hex": "ef8080", + "ciphertext": "A6oBqSSHPckKYt8Doymo2s1ku7LJxSNSfSuQdBoXaRe8q+5FQmvwbejIzJEaVKdUhbykNt5VFxno+sZtreNhrHgAAAABAAAVCO+AgAnTQMJrGJX7muna+wwIyM82vR398H+fhL6XxOam03jQErUC9W+klrNflk6oJ4mmyO88x6FJcf6n6LfDpGjMogNuMxoR1crWfknPMJuHggEUfOU6AjN7CgGiFPdnfcgbUPP487vxX9iw7U8WhQCfnh46vQTdwCDIm0C3aUp2/fJ0xNTVIZkFVKV1CyvyFpVicY9Zc7fmeEIDAsJvM1beK9sWqp9CI/sMU9OXmTfjdugSuisDRevchLkr6h5kB/rXDw==", + "why": "scope length out-of-bounds" + }, + { + "secret": "e1ef663aeb335452c9670d8a9f1f75cae5077f216314e9b49761610b4eb98538", + "public": "b729446e5cdbd2e4ac7967b056c17d5e4735035f2d2cf829722a0a85ca1fb6cb", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "ciphertext": "A/uzxqp0UwE6j7p8PIRKJDa0ah39GGyMbM0fOivlqESqbfFnp5OD2FSHR9TOTeJwiAfLXcXkoZPwiNKjB4ZgXV4AAAABAAAABu+7v++/vm7l8A==", + "why": "ciphertext too short" + }, + { + "secret": "327ffef01143a4dae30e201add671183424b39b1f33f64d78c19c22d3a7e790c", + "public": "039f6ce0144af03f0f3caf8a070a6a36519e2942286c2295a23785b0668ea621", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "ciphertext": "A5jnLthci6SRC9V9Ak/AKGyB7xAGPLGZx+fW9wjfvOKQuug5cUUGX4R0mmxFHl5/TtcQ4syIiTtgXL3uVveIP3MAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYzS3rEqFHqpL5Yqh/xY+a9i0XAyY960LDSfzQjSZh4UHzbuxmMEk92jAczsFkI9cWqd+xzahX59yD9l+UnCw3o9yXmzZIaA6UYPFI20f2VnH+G6F0Zt917fgt0bJwR3QUwblT63eOKLJGYhXqC11dweuKORW6oGJagRFo7P8r3UIFTHPkL5xMhUtZS7TS7GDKTB7kmEp5trwfzboiWxzp12LSlkUU9Nctyf6KE4iEQbk2jDndbC9npCn1rFpsFiHsd!", + "why": "invalid base64 (trailing)" + }, + { + "secret": "53d492157f17e6804bacdef3942fbb220a7239aab0e6bd6355eb3ff989ea0076", + "public": "ad2e1f00a4d5985810072cea336b0670f8e924368bca5b15c84ba8f3d7cae478", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "ciphertext": "A3oG29gbEA%8QXjVLA5JeYOl1Hj5bJVaNcl2tAnfEHm5pzS0V+3eF8Tns8+A+TkfxrSc3DuAbkxc9SgWC+214cBIAAAABAAAACeOBr+S4lueVjGfB4CLk22vLao5NE6OH5KlgzSy++iyD7FZEmAkCVOfQkrnbj9kzyLF7HRygI5E2FJdeQkX6WDiHtwzP/UWwd+cnMaXTYS7vL0Zh6Lvz/PKicCecxB0NvkAdYM3hOpodhXEYd2nano+37mU1Cahp2uwyygJQTb427cHBucQiVpIadVoKqMeIA7EGvO9HTgTxgE93vyT26NqZFO9aniV1bFc7y9nq1OYHFfNQgzdxVMTQ88SwMbq2TSpU5uJc/cphNA==", + "why": "invalid base64 (middle)" + }, + { + "secret": "d4db7f6dcf6a45843739a806876a9da849f75f7a36702e7b4f7a10a986bf76cd", + "public": "0377961328c3bb0db459cd22033d7a7ca1e29d7f42fd3f1b038530a2402879c8", + "kind": 1, + "scope_hex": "", + "ciphertext": "Ay1pBSibePLV49S4vfkgB4GCHR0Xywd7acm1WoC1ZaX6Jg38sM0PQAshZtniNKpUjQWZGw4e7kSKEgFIGhT6SxQAAAADAAAAABuabdmj5F6vlFssb3CHu/ndTMpdcPSWXklapkGwxJRS", + "why": "context mismatch (kind)" + }, + { + "secret": "b00e9f068deb4b69c474109502839c981bd429075eeab9e5f41db9022c0cd869", + "public": "6de2d6a91eb75f5e6038d633da8695f35fad807d5bad6b9e25ae8f526e10c05f", + "kind": 1, + "scope_hex": "68656c6c6f20776f726c6421", + "ciphertext": "A49dkIXX4dVAn6A9ql4cQ3MQfoU7rPrsg9/8V8d5NL9+A7Ntb9VIbBQ4V2ORFXS5rzOkHhZkKmtnn3dpMUiQWwsAACcQAAAADGhlbGxvIHdvcmxkIRb3rRdfIPuJvtEnjn6dj7RgKg1OUvGmCQoXmp+32EN6XL+vEHnbIbvLWLqIV7eCAmOqrPVGngCJEppHzzFMuhM=", + "why": "context mismatch (kind)" + }, + { + "secret": "e150f3f5fd2eb47c49e8c1ee0ca51d3502d370108a98519d212731746fc513c2", + "public": "901f54e63d0d1df5bf120e12faf00d6e4ec3c6d9fe81e0dc0f35cf1d5a489ac2", + "kind": 30078, + "scope_hex": "6170706c69636174696f6e2d64617461", + "ciphertext": "A/JaJhlnuKYJhr6LdZ46lkK7cw0nxGvA4rp112e70q0O9jMnXxaWqqNE7nZnllwl7yuGWRtcLU2q6uWAUZ6wthIAAHV+AAAAD2luY29ycmVjdC1zY29wZZ++z1BEvaKntWN9G+9EIT0da0luVAV/hAvOoUg90pXE4C6nsAKmpZ3Q2YfAvtUDlkMpxPv4u10Rlz0VGlPo9GtcGJp4X2aq/5gdbTZJF8mxnkhZ4zQqwRibV0JJ0ZNZxg==", + "why": "context mismatch (scope)" + }, + { + "secret": "1c9c9ff5df8a9ff99e50b3dac27d987422567e9122075097edea2915e12580ca", + "public": "32178882654a70441f1c507902ae3a89888ecd2266930a45f8d6b2f578643307", + "kind": 4, + "scope_hex": "efbbbf", + "ciphertext": "A3c7VODzMr9mQXQNRtw3MQsntesGokh3g8nQwdAK4iWqL/nV6HPOhnz5xFf1UjyvTrHJC8BrGjRefguMrJLrfy8AAAAEAAAAD2luY29ycmVjdC1zY29wZfvq9zBo8LQrHyulrPg0swuPZs4W3Xy1TSQlmczZ643aOllm+9DveIcqoXoQaGQTs1RDLeekOMNquv9a5XMWZ431ysJ44L3ET4Ztw5Eevnxx3pgaob8bCikyuGT+5CyR2A==", + "why": "context mismatch (scope)" + }, + { + "secret": "18a8c52a7d94c36bf08ec04336247ab2c67014b964a3e33f3d6677e697f2008c", + "public": "d1069a731110484f212fa9fe2fa4dcd2e2c0857b5e0dda1bd5667901ce303d81", + "kind": 4, + "scope_hex": "ff", + "ciphertext": "A3PSjybYp19qos4LlMKEdlPQShJIBOJBGjjOI6etumpU4VaN0LYMB8qECxbwF7ebuv2Lxu5h0yXtNpdJtX4Z5fgAAAAEAAAAAf+AeTttkffcDMHUCDRxKYA3p7nnmK2hNvB04X0VP2H27Q==", + "why": "invalid scope (not valid utf8)" + } + ] +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 329e36fa..23ce4f5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ mockk = "1.14.9" coroutinesTest = "1.10.2" kmpTor = "2.6.0" kmpTorResource = "409.5.0" +secp256k1Jni = "0.23.0" [libraries] datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } @@ -81,6 +82,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesTest" } kmptor-runtime = { module = "io.matthewnelson.kmp-tor:runtime", version.ref = "kmpTor" } kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor", version.ref = "kmpTorResource" } +secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" } [plugins] androidLibrary = { id = "com.android.library", version.ref = "agp" } From 4bc7c969dc5052543bb702b7be092278ac253bc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:19:18 +0000 Subject: [PATCH 02/11] Auto-reject invalid NIP-44 v3 requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A v3 request that can never succeed — missing/invalid kind, a decrypt whose context (kind/scope) does not match the ciphertext, a bad MAC, corrupt padding, or a non-base64 encrypt payload — is now rejected outright instead of being surfaced to the user on the approval screen. Covers all three ingestion paths: the ContentProvider/bunker path returns a rejected cursor (so the bunker replies without notifying), and the intent path drops the request via the invalid-intent channel. --- .../greenart7c3/nostrsigner/SignerProvider.kt | 26 ++++++++++++++++++- .../nostrsigner/service/IntentUtils.kt | 16 ++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index b781c9a5..feabed23 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -26,6 +26,16 @@ import kotlinx.coroutines.runBlocking class SignerProvider : ContentProvider() { private val scope get() = Amber.instance.applicationIOScope + 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?, @@ -254,19 +264,29 @@ class SignerProvider : ContentProvider() { val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT // V3 carries kind and scope as projection[3] and projection[4]. + // A missing/invalid kind is a malformed request: auto-reject + // instead of prompting the user. val (v3Kind, v3Scope) = if (isV3) { val kindStr = projection.getOrNull(3) val scopeStr = projection.getOrNull(4) ?: "" val parsedKind = kindStr?.toIntOrNull() if (parsedKind == null) { Log.d(Amber.TAG, "NIP-44 v3 request missing/invalid kind") - return null + return rejectedCursor() } parsedKind to scopeStr } else { 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) { @@ -295,6 +315,10 @@ class SignerProvider : ContentProvider() { ), ) } + // A V3 decrypt that throws cannot succeed (wrong + // version/context, bad MAC, corrupt padding, ...): + // reject it rather than prompting the user. + if (isV3) return rejectedCursor() "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 cffc31c6..3c44c779 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -264,6 +264,14 @@ object IntentUtils { nip44v3Scope, ) ?: "Could not decrypt the message" } catch (e: Exception) { + // A NIP-44 v3 failure (missing/invalid kind, context + // mismatch, bad MAC, corrupt payload) means the request + // can never succeed: reject it without showing the user + // an approval screen. + if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) { + emitInvalid(intent, packageName, "Invalid NIP-44 v3 request: ${e.message}", e) + return null + } Amber.instance.applicationIOScope.launch { val database = Amber.instance.getLogDatabase(account.npub) database.dao().insertLog( @@ -441,6 +449,14 @@ object IntentUtils { ) ?: "Could not decrypt the message" } catch (e: Exception) { if (e is FailedMigrationException) throw e + // A NIP-44 v3 failure (missing/invalid kind, context + // mismatch, bad MAC, corrupt payload) means the request + // can never succeed: reject it without showing the user + // an approval screen. + if (type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT) { + emitInvalid(intent, packageName, "Invalid NIP-44 v3 request: ${e.message}", e) + return null + } Amber.instance.applicationIOScope.launch { val database = Amber.instance.getLogDatabase(account.npub) database.dao().insertLog( From 8b93340a28c956013c630321d52f8403c2e3b309 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:33:30 +0000 Subject: [PATCH 03/11] Register NIP-44 v3 ContentProvider authorities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SignerProvider's authorities are enumerated explicitly in the manifest, and the new NIP44_V3_ENCRYPT / NIP44_V3_DECRYPT authorities were missing. As a result contentResolver.query() for a v3 URI resolved to no provider and returned null, which the bunker path treats as "needs approval" — so invalid v3 requests (e.g. a decrypt whose kind does not match the ciphertext) were surfaced to the user instead of being rejected, and valid v3 requests could never auto-accept from a remembered permission. Also validate v3 bunker requests at the relay entry point and reject malformed ones (missing kind, context mismatch, bad MAC/padding, non-base64 payload) with a generic error response before any approval screen is shown. --- app/src/main/AndroidManifest.xml | 2 +- .../service/EventNotificationConsumer.kt | 42 +++++++++++++++++-- app/src/offline/AndroidManifest.xml | 2 +- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 77608678..a33d401e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,7 +58,7 @@ 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 637df52e..20664251 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -460,13 +460,18 @@ class EventNotificationConsumer(private val applicationContext: Context) { type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT -> { val kind = BunkerRequestUtils.getNip44v3Kind(bunkerRequest) val scope = BunkerRequestUtils.getNip44v3Scope(bunkerRequest) - if (kind == null) { - saveLog("NIP-44 v3 request missing/invalid kind", relay.url, acc.npub) + // A v3 request that can never succeed — missing/invalid kind, a + // decrypt whose context (kind/scope) does not match the + // ciphertext, a bad MAC/padding, or a non-base64 encrypt payload + // — is rejected here, before any approval screen is shown. + val validationError = validateNip44v3Request(type, kind, scope, data, pubKey, acc) + if (validationError != null) { + saveLog("Rejecting invalid NIP-44 v3 request: $validationError", relay.url, acc.npub) BunkerRequestUtils.sendBunkerResponse( context = applicationContext, account = acc, bunkerRequest = request, - bunkerResponse = BunkerResponse(bunkerRequest.id, "", "kind is required for nip44v3"), + bunkerResponse = BunkerResponse(bunkerRequest.id, "", validationError), relays = relays, onLoading = { }, onDone = { }, @@ -772,5 +777,36 @@ class EventNotificationConsumer(private val applicationContext: Context) { null } + /** + * 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). + */ + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + private fun validateNip44v3Request( + type: SignerType, + kind: Int?, + scope: String, + data: String, + pubKey: String, + acc: Account, + ): String? { + if (kind == null) return "kind is required for nip44v3" + return try { + if (type == SignerType.NIP44_V3_DECRYPT) { + acc.nip44v3Decrypt(data, pubKey, kind, scope) + } else { + kotlin.io.encoding.Base64.decode(data) + } + 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" + } + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager } diff --git a/app/src/offline/AndroidManifest.xml b/app/src/offline/AndroidManifest.xml index 2846cc37..ba3a49c5 100644 --- a/app/src/offline/AndroidManifest.xml +++ b/app/src/offline/AndroidManifest.xml @@ -25,7 +25,7 @@ From 567ec022a2c571d8337e3cdaae50937e7d5426fd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:01:36 +0000 Subject: [PATCH 04/11] Show NIP-44 v3 context on the intent approval screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypt/decrypt approval screen reached via a nostrsigner:// intent looked identical for v3 and v2 — it never surfaced the v3 context. It now labels the algorithm "NIP-44 v3", shows the requested event kind and scope, and relabels the scope toggle as "this kind only" / "all kinds" to match v3's per-kind permission model. --- .../ui/components/EncryptDecryptData.kt | 54 ++++++++++++++++--- .../components/IntentSingleEventHomeScreen.kt | 2 + app/src/main/res/values/strings.xml | 6 +++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt index 87df8cf8..af10219b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt @@ -46,6 +46,8 @@ fun EncryptDecryptData( type: SignerType, account: Account, defaultScope: DecryptTypeScope = DecryptTypeScope.ALL, + nip44v3Kind: Int? = null, + nip44v3Scope: String = "", onAccept: (RememberType, DecryptTypeScope) -> Unit, onReject: (RememberType, DecryptTypeScope) -> Unit, ) { @@ -54,6 +56,9 @@ fun EncryptDecryptData( } var scope by remember { mutableStateOf(defaultScope) } val showScopeToggle = type != SignerType.DECRYPT_ZAP_EVENT + val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT + // V3 surfaces "NIP-44 v3"; v2/v4 derive the label from the type name ("NIP44"/"NIP04"). + val algoLabel = if (isV3) "NIP-44 v3" else type.name.split("_").first() Column( modifier, @@ -68,14 +73,14 @@ fun EncryptDecryptData( val unknownKindString = stringResource(R.string.event_kind, encryptedData.event.kind.toString()) val altTag = encryptedData.event.tags.firstOrNull { it.size > 1 && it[0] == "alt" }?.getOrNull(1) val displayTranslation = if (kindTranslation == unknownKindString && altTag != null) altTag else kindTranslation - stringResource(R.string.wants_to_encrypt_with, displayTranslation, type.name.split("_").first()) + stringResource(R.string.wants_to_encrypt_with, displayTranslation, algoLabel) } is TagArrayEncryptedDataKind -> { - stringResource(R.string.wants_to_encrypt_this_list_of_tags_with, type.name.split("_").first()) + stringResource(R.string.wants_to_encrypt_this_list_of_tags_with, algoLabel) } - else -> stringResource(R.string.wants_to_encrypt_this_text_with, type.name.split("_").first()) + else -> stringResource(R.string.wants_to_encrypt_this_text_with, algoLabel) } } else { when (encryptedData) { @@ -85,18 +90,18 @@ fun EncryptDecryptData( val unknownKindString = stringResource(R.string.event_kind, encryptedData.event.kind.toString()) val altTag = encryptedData.event.tags.firstOrNull { it.size > 1 && it[0] == "alt" }?.getOrNull(1) val displayTranslation = if (kindTranslation == unknownKindString && altTag != null) altTag else kindTranslation - stringResource(R.string.wants_to_read_from_encrypted_content, displayTranslation, type.name.split("_").first()) + stringResource(R.string.wants_to_read_from_encrypted_content, displayTranslation, algoLabel) } is TagArrayEncryptedDataKind -> { - stringResource(R.string.wants_to_read_this_list_of_tags_from_encrypted_content, type.name.split("_").first()) + stringResource(R.string.wants_to_read_this_list_of_tags_from_encrypted_content, algoLabel) } is PrivateZapEncryptedDataKind -> { stringResource(R.string.wants_you_to, stringResource(R.string.decrypt_zap_event)) } - else -> stringResource(R.string.wants_to_read_this_text_from_encrypted_content, type.name.split("_").first()) + else -> stringResource(R.string.wants_to_read_this_text_from_encrypted_content, algoLabel) } } @@ -105,6 +110,11 @@ fun EncryptDecryptData( fontSize = 18.sp, ) + if (isV3) { + Spacer(Modifier.size(8.dp)) + Nip44v3ContextBox(kind = nip44v3Kind, scope = nip44v3Scope) + } + Card( modifier = Modifier .fillMaxWidth() @@ -200,11 +210,12 @@ fun EncryptDecryptData( selected = scope, options = listOf(DecryptTypeScope.SPECIFIC, DecryptTypeScope.ALL), onSelected = { scope = it }, + // For V3 the toggle scopes the grant by event kind, not by content type. label = { stringResource( when (it) { - DecryptTypeScope.SPECIFIC -> R.string.for_this_method_only - DecryptTypeScope.ALL -> R.string.for_all_methods + DecryptTypeScope.SPECIFIC -> if (isV3) R.string.for_this_kind_only else R.string.for_this_method_only + DecryptTypeScope.ALL -> if (isV3) R.string.for_all_kinds else R.string.for_all_methods }, ) }, @@ -234,6 +245,33 @@ fun EncryptDecryptData( } } +/** + * Shows the NIP-44 v3 context (event kind + scope) being authenticated by the + * request, so the user can see what they are granting access to. + */ +@Composable +fun Nip44v3ContextBox( + kind: Int?, + scope: String, +) { + LabeledBorderBox( + label = stringResource(R.string.nip44_v3_context), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column { + Text(stringResource(R.string.nip44_v3_kind, kind?.toString() ?: "?")) + Text( + stringResource( + R.string.nip44_v3_scope, + scope.ifEmpty { stringResource(R.string.nip44_v3_no_scope) }, + ), + ) + } + } +} + /** * Scope for an encrypt/decrypt permission. * [SPECIFIC] means the permission applies only to this specific content type (e.g., clear text, tag array, event). diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt index 8f9f8142..4f27a2b5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt @@ -221,6 +221,8 @@ fun IntentSingleEventHomeScreen( // For V3, scope toggle's "SPECIFIC vs ALL" instead controls // whether the grant is kind-scoped — handled via [kind] below. defaultScope = if (isV3) DecryptTypeScope.SPECIFIC else DecryptTypeScope.ALL, + nip44v3Kind = intentData.nip44v3Kind, + nip44v3Scope = intentData.nip44v3Scope, onAccept = { rememberType, scope -> val result = if (intentData.encryptedData?.result == "Could not decrypt the message" && (intentData.type == SignerType.DECRYPT_ZAP_EVENT)) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e8b0645..a07bc153 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -647,6 +647,12 @@ Encryption scope This method only All methods + NIP-44 v3 context + Kind: %1$s + Scope: %1$s + (none) + This kind only + All kinds Indexer relays Proxy relays Broadcast relays From 6cb6085724047d41c7721119618a5e7aac9569f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:15:07 +0000 Subject: [PATCH 05/11] Add a dedicated NIP-44 v3 approval screen Instead of overloading the v2/v4 EncryptDecryptData screen with v3 conditionals, v3 encrypt/decrypt requests now render their own Nip44v3ApprovalData screen (used by both the intent and bunker paths). It surfaces the authenticated context (event kind + scope), decodes the Base64 wire payload for display (with a binary-data fallback), and its scope toggle grants a single kind or all kinds. The shared screen is reverted to its v2/v4-only form. --- .../components/BunkerSingleEventHomeScreen.kt | 13 +- .../ui/components/EncryptDecryptData.kt | 54 +---- .../components/IntentSingleEventHomeScreen.kt | 98 +++++--- .../ui/components/Nip44v3ApprovalData.kt | 222 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 5 files changed, 309 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt 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 4b381835..92843cee 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 @@ -828,14 +828,17 @@ fun BunkerSingleEventHomeScreen( val acceptOrReject = IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, permission) - BunkerEncryptDecryptData( + Nip44v3ApprovalData( modifier = modifier, - encryptedData = bunkerRequest.encryptedData, - shouldRunOnAccept = acceptOrReject, - appName = "$appName (kind $v3Kind${if (v3Scope.isNotEmpty()) " · scope \"$v3Scope\"" else ""})", + isBunker = true, + appName = appName, + packageName = null, type = type, account = account, - defaultScope = DecryptTypeScope.SPECIFIC, + kind = v3Kind, + scope = v3Scope, + encryptedData = bunkerRequest.encryptedData, + shouldRunOnAccept = acceptOrReject, onAccept = { rememberType, scope -> Amber.instance.applicationIOScope.launch(Dispatchers.IO) { val result = try { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt index af10219b..87df8cf8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/EncryptDecryptData.kt @@ -46,8 +46,6 @@ fun EncryptDecryptData( type: SignerType, account: Account, defaultScope: DecryptTypeScope = DecryptTypeScope.ALL, - nip44v3Kind: Int? = null, - nip44v3Scope: String = "", onAccept: (RememberType, DecryptTypeScope) -> Unit, onReject: (RememberType, DecryptTypeScope) -> Unit, ) { @@ -56,9 +54,6 @@ fun EncryptDecryptData( } var scope by remember { mutableStateOf(defaultScope) } val showScopeToggle = type != SignerType.DECRYPT_ZAP_EVENT - val isV3 = type == SignerType.NIP44_V3_ENCRYPT || type == SignerType.NIP44_V3_DECRYPT - // V3 surfaces "NIP-44 v3"; v2/v4 derive the label from the type name ("NIP44"/"NIP04"). - val algoLabel = if (isV3) "NIP-44 v3" else type.name.split("_").first() Column( modifier, @@ -73,14 +68,14 @@ fun EncryptDecryptData( val unknownKindString = stringResource(R.string.event_kind, encryptedData.event.kind.toString()) val altTag = encryptedData.event.tags.firstOrNull { it.size > 1 && it[0] == "alt" }?.getOrNull(1) val displayTranslation = if (kindTranslation == unknownKindString && altTag != null) altTag else kindTranslation - stringResource(R.string.wants_to_encrypt_with, displayTranslation, algoLabel) + stringResource(R.string.wants_to_encrypt_with, displayTranslation, type.name.split("_").first()) } is TagArrayEncryptedDataKind -> { - stringResource(R.string.wants_to_encrypt_this_list_of_tags_with, algoLabel) + stringResource(R.string.wants_to_encrypt_this_list_of_tags_with, type.name.split("_").first()) } - else -> stringResource(R.string.wants_to_encrypt_this_text_with, algoLabel) + else -> stringResource(R.string.wants_to_encrypt_this_text_with, type.name.split("_").first()) } } else { when (encryptedData) { @@ -90,18 +85,18 @@ fun EncryptDecryptData( val unknownKindString = stringResource(R.string.event_kind, encryptedData.event.kind.toString()) val altTag = encryptedData.event.tags.firstOrNull { it.size > 1 && it[0] == "alt" }?.getOrNull(1) val displayTranslation = if (kindTranslation == unknownKindString && altTag != null) altTag else kindTranslation - stringResource(R.string.wants_to_read_from_encrypted_content, displayTranslation, algoLabel) + stringResource(R.string.wants_to_read_from_encrypted_content, displayTranslation, type.name.split("_").first()) } is TagArrayEncryptedDataKind -> { - stringResource(R.string.wants_to_read_this_list_of_tags_from_encrypted_content, algoLabel) + stringResource(R.string.wants_to_read_this_list_of_tags_from_encrypted_content, type.name.split("_").first()) } is PrivateZapEncryptedDataKind -> { stringResource(R.string.wants_you_to, stringResource(R.string.decrypt_zap_event)) } - else -> stringResource(R.string.wants_to_read_this_text_from_encrypted_content, algoLabel) + else -> stringResource(R.string.wants_to_read_this_text_from_encrypted_content, type.name.split("_").first()) } } @@ -110,11 +105,6 @@ fun EncryptDecryptData( fontSize = 18.sp, ) - if (isV3) { - Spacer(Modifier.size(8.dp)) - Nip44v3ContextBox(kind = nip44v3Kind, scope = nip44v3Scope) - } - Card( modifier = Modifier .fillMaxWidth() @@ -210,12 +200,11 @@ fun EncryptDecryptData( selected = scope, options = listOf(DecryptTypeScope.SPECIFIC, DecryptTypeScope.ALL), onSelected = { scope = it }, - // For V3 the toggle scopes the grant by event kind, not by content type. label = { stringResource( when (it) { - DecryptTypeScope.SPECIFIC -> if (isV3) R.string.for_this_kind_only else R.string.for_this_method_only - DecryptTypeScope.ALL -> if (isV3) R.string.for_all_kinds else R.string.for_all_methods + DecryptTypeScope.SPECIFIC -> R.string.for_this_method_only + DecryptTypeScope.ALL -> R.string.for_all_methods }, ) }, @@ -245,33 +234,6 @@ fun EncryptDecryptData( } } -/** - * Shows the NIP-44 v3 context (event kind + scope) being authenticated by the - * request, so the user can see what they are granting access to. - */ -@Composable -fun Nip44v3ContextBox( - kind: Int?, - scope: String, -) { - LabeledBorderBox( - label = stringResource(R.string.nip44_v3_context), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Column { - Text(stringResource(R.string.nip44_v3_kind, kind?.toString() ?: "?")) - Text( - stringResource( - R.string.nip44_v3_scope, - scope.ifEmpty { stringResource(R.string.nip44_v3_no_scope) }, - ), - ) - } - } -} - /** * Scope for an encrypt/decrypt permission. * [SPECIFIC] means the permission applies only to this specific content type (e.g., clear text, tag array, event). diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt index 4f27a2b5..53a214db 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt @@ -170,37 +170,85 @@ fun IntentSingleEventHomeScreen( ) } - SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, - SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT, SignerType.DECRYPT_ZAP_EVENT, - -> { - val isV3 = intentData.type == SignerType.NIP44_V3_ENCRYPT || intentData.type == SignerType.NIP44_V3_DECRYPT + SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT -> { + // V3 grants are scoped by (app, type, kind); fall back to a + // kind=null "all kinds" grant. They never satisfy v2/v4 requests. + val permType = intentData.type.toString() + val permission = + applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == permType && it.kind == intentData.nip44v3Kind + } ?: applicationEntity?.permissions?.firstOrNull { + it.pkKey == key && it.type == permType && it.kind == null + } + + val acceptOrReject = IntentUtils.isRemembered(applicationEntity?.application?.signPolicy, permission) + + Nip44v3ApprovalData( + modifier = modifier, + isBunker = false, + appName = appName, + packageName = packageName, + type = intentData.type, + account = account, + kind = intentData.nip44v3Kind, + scope = intentData.nip44v3Scope, + encryptedData = intentData.encryptedData, + shouldRunOnAccept = acceptOrReject, + onAccept = { rememberType, scope -> + val result = intentData.encryptedData?.result ?: "" + // SPECIFIC ⇒ kind-scoped grant; ALL ⇒ all-kinds grant (kind=null). + val v3Kind = if (scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null + IntentUtils.sendResult( + context, + packageName, + account, + key, + clipboardManager, + result, + result, + intentData, + v3Kind, + onRemoveIntentData = onRemoveIntentData, + onLoading = onLoading, + rememberType = rememberType, + decryptTypeScope = scope, + ) + }, + onReject = { rememberType, scope -> + val v3Kind = if (scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null + IntentUtils.sendRejection( + key = key, + account = account, + intentData = intentData, + appName = appName, + rememberType = rememberType, + onLoading = onLoading, + onRemoveIntentData = onRemoveIntentData, + kind = v3Kind, + decryptTypeScope = scope, + ) + }, + ) + } + + SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT, SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT, SignerType.DECRYPT_ZAP_EVENT -> { val nip = when (intentData.type) { SignerType.NIP04_DECRYPT, SignerType.NIP04_ENCRYPT -> 4 SignerType.NIP44_ENCRYPT, SignerType.NIP44_DECRYPT -> 44 SignerType.DECRYPT_ZAP_EVENT -> null - // V3 has its own per-kind permission model; don't fall back to a NIP-level grant. - SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT -> null else -> null } - val isEncrypt = intentData.type == SignerType.NIP04_ENCRYPT || - intentData.type == SignerType.NIP44_ENCRYPT || - intentData.type == SignerType.NIP44_V3_ENCRYPT - val permType = if (isV3) intentData.type.toString() else intentData.encryptedData.toPermissionType(isEncrypt = isEncrypt) + val isEncrypt = intentData.type == SignerType.NIP04_ENCRYPT || intentData.type == SignerType.NIP44_ENCRYPT + val permType = intentData.encryptedData.toPermissionType(isEncrypt = isEncrypt) var permission = applicationEntity?.permissions?.firstOrNull { - it.pkKey == key && it.type == permType && (!isV3 || it.kind == intentData.nip44v3Kind) + it.pkKey == key && it.type == permType } - if (permission == null && !isV3) { + if (permission == null) { permission = applicationEntity?.permissions?.firstOrNull { it.pkKey == key && it.type == intentData.type.toString() } - } else if (permission == null && isV3) { - // Fall back to a (V3, kind=null) "all kinds" grant. - permission = - applicationEntity?.permissions?.firstOrNull { - it.pkKey == key && it.type == permType && it.kind == null - } } if (permission == null && nip != null) { permission = @@ -218,11 +266,6 @@ fun IntentSingleEventHomeScreen( packageName = packageName, type = intentData.type, account = account, - // For V3, scope toggle's "SPECIFIC vs ALL" instead controls - // whether the grant is kind-scoped — handled via [kind] below. - defaultScope = if (isV3) DecryptTypeScope.SPECIFIC else DecryptTypeScope.ALL, - nip44v3Kind = intentData.nip44v3Kind, - nip44v3Scope = intentData.nip44v3Scope, onAccept = { rememberType, scope -> val result = if (intentData.encryptedData?.result == "Could not decrypt the message" && (intentData.type == SignerType.DECRYPT_ZAP_EVENT)) { @@ -231,10 +274,6 @@ fun IntentSingleEventHomeScreen( intentData.encryptedData?.result ?: "" } - // V3: write the permission with the requested kind when - // the user picked SPECIFIC, or kind=null (all kinds) for ALL. - val v3Kind = if (isV3 && scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null - IntentUtils.sendResult( context, packageName, @@ -244,7 +283,7 @@ fun IntentSingleEventHomeScreen( result, result, intentData, - v3Kind, + null, onRemoveIntentData = onRemoveIntentData, onLoading = onLoading, rememberType = rememberType, @@ -252,7 +291,6 @@ fun IntentSingleEventHomeScreen( ) }, onReject = { rememberType, scope -> - val v3Kind = if (isV3 && scope == DecryptTypeScope.SPECIFIC) intentData.nip44v3Kind else null IntentUtils.sendRejection( key = key, account = account, @@ -261,7 +299,7 @@ fun IntentSingleEventHomeScreen( rememberType = rememberType, onLoading = onLoading, onRemoveIntentData = onRemoveIntentData, - kind = v3Kind, + kind = null, decryptTypeScope = scope, ) }, 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 new file mode 100644 index 00000000..3f0ee1a5 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/Nip44v3ApprovalData.kt @@ -0,0 +1,222 @@ +package com.greenart7c3.nostrsigner.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.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. + */ +@Composable +fun Nip44v3ApprovalData( + modifier: Modifier, + isBunker: Boolean, + appName: String, + packageName: String?, + type: SignerType, + account: Account, + kind: Int?, + scope: String, + encryptedData: EncryptedDataKind?, + shouldRunOnAccept: Boolean?, + defaultScope: DecryptTypeScope = DecryptTypeScope.SPECIFIC, + onAccept: (RememberType, DecryptTypeScope) -> Unit, + onReject: (RememberType, DecryptTypeScope) -> Unit, +) { + var rememberType by remember { mutableStateOf(RememberType.NEVER) } + var grantScope by remember { mutableStateOf(defaultScope) } + + 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) + + Column(modifier) { + if (isBunker) { + Text( + buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(appName) + } + append(" ") + append(stringResource(messageRes)) + }, + fontSize = 18.sp, + ) + } else { + LocalAppIcon(packageName) + Text( + stringResource(messageRes), + fontSize = 18.sp, + ) + } + + Spacer(Modifier.size(8.dp)) + Nip44v3ContextBox(kind = kind, scope = scope) + + Card( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + colors = CardDefaults.cardColors().copy( + containerColor = MaterialTheme.colorScheme.background, + ), + ) { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + displayContent, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + } + + Spacer(Modifier.size(16.dp)) + SigningAs(account) + Spacer(modifier = Modifier.weight(1f)) + + Spacer(Modifier.size(8.dp)) + LabeledBorderBox( + label = stringResource(R.string.encryption_scope), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + AmberToggles( + selected = grantScope, + options = listOf(DecryptTypeScope.SPECIFIC, DecryptTypeScope.ALL), + onSelected = { grantScope = it }, + label = { + stringResource( + when (it) { + DecryptTypeScope.SPECIFIC -> R.string.for_this_kind_only + DecryptTypeScope.ALL -> R.string.for_all_kinds + }, + ) + }, + ) + } + Spacer(Modifier.size(8.dp)) + + RememberMyChoice( + shouldRunOnAccept, + if (isBunker) null else packageName, + isBunker, + onAccept = { onAccept(it, grantScope) }, + onReject = { onReject(it, grantScope) }, + ) { + rememberType = it + } + + AcceptRejectButtons( + onAccept = { onAccept(rememberType, grantScope) }, + onReject = { onReject(rememberType, grantScope) }, + ) + } +} + +/** + * Shows the NIP-44 v3 context (event kind + scope) being authenticated by the + * request, so the user can see what they are granting access to. + */ +@Composable +fun Nip44v3ContextBox( + kind: Int?, + scope: String, +) { + LabeledBorderBox( + label = stringResource(R.string.nip44_v3_context), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Column { + Text(stringResource(R.string.nip44_v3_kind, kind?.toString() ?: "?")) + Text( + stringResource( + R.string.nip44_v3_scope, + scope.ifEmpty { stringResource(R.string.nip44_v3_no_scope) }, + ), + ) + } + } +} + +/** + * 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. + */ +@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 + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a07bc153..4c4e9f7c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -653,6 +653,9 @@ (none) This kind only 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 From a7017d3f75c62cad442c57a3ec00647ce2514c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:22:09 +0000 Subject: [PATCH 06/11] Refine NIP-44 v3 context box Drop the horizontal padding so the box aligns with the rest of the screen, and show the event kind's human-readable name alongside the number when a translation exists (e.g. "4 (Encrypted direct messages)"). --- .../ui/components/Nip44v3ApprovalData.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 3f0ee1a5..8fb9f577 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 @@ -24,10 +24,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp 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.ui.RememberType import java.nio.charset.CharacterCodingException @@ -161,12 +163,19 @@ fun Nip44v3ContextBox( ) { LabeledBorderBox( label = stringResource(R.string.nip44_v3_context), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth(), ) { Column { - Text(stringResource(R.string.nip44_v3_kind, kind?.toString() ?: "?")) + val kindLabel = if (kind != null) { + val translation = Permission("sign_event", kind).toLocalizedString(Amber.instance) + val unknown = stringResource(R.string.event_kind, kind.toString()) + // toLocalizedString returns the "Event kind N" fallback when the + // kind has no translation; only append the name when one exists. + if (translation != unknown) "$kind ($translation)" else kind.toString() + } else { + "?" + } + Text(stringResource(R.string.nip44_v3_kind, kindLabel)) Text( stringResource( R.string.nip44_v3_scope, From 93794607a8c8a24e1358dc9eef8284568534e5c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:32:04 +0000 Subject: [PATCH 07/11] Populate NIP-44 v3 preview for bunker requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated v3 approval screen is already used for NIP-46 bunker requests, but its content area was empty: getEncryptedDataKind only handled the typed v2/v4 BunkerRequest subclasses and returned null for v3 (which arrives as a generic BunkerRequest). Build the preview for v3 too — decrypting on decrypt, passing through the Base64 plaintext on encrypt — so the screen shows what is being encrypted/decrypted. --- .../service/EventNotificationConsumer.kt | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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 20664251..069dbac8 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -607,7 +607,11 @@ class EventNotificationConsumer(private val applicationContext: Context) { bunkerRequest: BunkerRequest?, acc: Account, url: String, - ): EncryptedDataKind? = if (bunkerRequest is BunkerRequestNip44Decrypt) { + ): 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). + nip44v3EncryptedDataKind(bunkerRequest, acc) + } else if (bunkerRequest is BunkerRequestNip44Decrypt) { val result = acc.nip44Decrypt(bunkerRequest.ciphertext, bunkerRequest.pubKey) if (result.startsWith("{")) { @@ -783,6 +787,35 @@ class EventNotificationConsumer(private val applicationContext: Context) { * 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. + * 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, + ): EncryptedDataKind? { + val kind = BunkerRequestUtils.getNip44v3Kind(bunkerRequest) ?: return null + val scope = BunkerRequestUtils.getNip44v3Scope(bunkerRequest) + val data = BunkerRequestUtils.getDataFromBunker(bunkerRequest) + val pubKey = bunkerRequest.params.firstOrNull() ?: return null + return try { + if (bunkerRequest.method == "nip44v3_encrypt") { + ClearTextEncryptedDataKind(data, "") + } else { + val plaintext = acc.nip44v3Decrypt(data, pubKey, kind, scope) + ClearTextEncryptedDataKind("", kotlin.io.encoding.Base64.encode(plaintext)) + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } + } + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) private fun validateNip44v3Request( type: SignerType, From ca5d82f06ec865d954004b8578afc1df8d477b00 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:43:09 +0000 Subject: [PATCH 08/11] Log decrypted plaintext for NIP-44 v3 history V3 wire payloads are Base64-encoded bytes, so history entries recorded the opaque Base64 string instead of the readable content. Decode it when storing history across all three paths (ContentProvider, bunker approval, intent), falling back to the original string when the bytes aren't valid UTF-8 (e.g. binary data). --- .../com/greenart7c3/nostrsigner/SignerProvider.kt | 8 +++++++- .../greenart7c3/nostrsigner/service/AmberUtils.kt | 12 ++++++++++++ .../nostrsigner/service/BunkerRequestUtils.kt | 3 +++ .../greenart7c3/nostrsigner/service/IntentUtils.kt | 4 ++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index feabed23..74c8cf30 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -411,6 +411,12 @@ 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( @@ -421,7 +427,7 @@ class SignerProvider : ContentProvider() { v3Kind, TimeUtils.now(), true, - content = if (!isEncrypt) finalResult else content, + content = historyContent, ), ), 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 0b8c2a10..29b16842 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt @@ -70,6 +70,18 @@ object AmberUtils { } } + /** + * 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 0ce24c9c..d4f544d2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -432,6 +432,9 @@ object BunkerRequestUtils { SignerType.NIP44_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/IntentUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt index 3c44c779..9c392ea9 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -857,6 +857,10 @@ object IntentUtils { 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 }, ), From a7d46a80343e6f92d408f74c6c45194bc714b133 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 12:55:03 +0000 Subject: [PATCH 09/11] 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 From 96c786816a25360da33c4aa6faada1f018d4b6ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 13:25:57 +0000 Subject: [PATCH 10/11] 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. --- .../greenart7c3/nostrsigner/SignerProvider.kt | 17 ++++++++++- .../nostrsigner/models/EncryptedDataKind.kt | 7 +++++ .../nostrsigner/service/AmberUtils.kt | 14 ++++++---- .../nostrsigner/service/BunkerRequestUtils.kt | 7 ++++- .../service/EventNotificationConsumer.kt | 16 +++++++---- .../nostrsigner/service/IntentUtils.kt | 28 ++++++++++++++++++- .../components/BunkerSingleEventHomeScreen.kt | 6 ++-- .../ui/components/Nip44v3ApprovalData.kt | 14 ++-------- 8 files changed, 79 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index efbe0472..1da97b10 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -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, 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 8763a198..b82cf9c5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/models/EncryptedDataKind.kt @@ -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, 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 097b1f99..92bcf476 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/AmberUtils.kt @@ -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) 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 31de2e7d..ba2bb3fa 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/BunkerRequestUtils.kt @@ -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) }, ), 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 bb0b8c69..46489a54 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/EventNotificationConsumer.kt @@ -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 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 8e7964e3..14c29baf 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/IntentUtils.kt @@ -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 }, ), 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 b6442d4c..421aee03 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 @@ -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 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 82c07a08..12812e7f 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 @@ -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 ?: "" -} From 5b3522c702ca9f7ffa44fab25068ad865473c966 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 13:06:10 +0000 Subject: [PATCH 11/11] Honor kind on auto-handled NIP-44 v3 rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ContentProvider auto-handle path looked the v3 permission up by (key, type, kind) and, on miss, fell back to (key, type) — whose SQL matches any kind. A v3 reject saved with kind=A therefore leaked to a request with kind=B, auto-rejecting it. Add a dedicated DAO query for the explicit "all kinds" (kind IS NULL) grant and use that as the fallback instead, mirroring how the manual bunker and intent screens already resolve v3 permissions. --- .../com/greenart7c3/nostrsigner/SignerProvider.kt | 6 +++++- .../nostrsigner/database/ApplicationDao.kt | 12 ++++++++++++ .../nostrsigner/database/CachingApplicationDao.kt | 10 +++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index 1da97b10..52bacfa2 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -319,8 +319,12 @@ class SignerProvider : ContentProvider() { // SignerType, kind); fall back to a kind=null "all kinds" // grant. V3 grants do NOT satisfy V2 requests and vice versa. var permission = if (isV3) { + // V3 grants are kind-scoped; fall back to the explicit + // "all kinds" (kind IS NULL) grant only, never to any + // other kind — otherwise e.g. a kind-A reject would + // leak to a kind-B request. permDao.getPermission(packageName, type.toString(), v3Kind!!) - ?: permDao.getPermission(packageName, type.toString()) + ?: permDao.getPermissionAllKinds(packageName, type.toString()) } else { // Classify the content to determine EncryptedDataKind-based permission type val classifyContent = if (isEncrypt) content else (result ?: content) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt index 8de614b2..922dc21a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/ApplicationDao.kt @@ -78,6 +78,18 @@ interface ApplicationDao { type: String, ): ApplicationPermissionsEntity? + /** + * Match an "all kinds" grant — the row whose `kind` column is `NULL`. + * Distinct from [getPermission] (no kind), which matches any row of the + * given type regardless of kind: V3's kind-scoped permission model needs + * the explicit `IS NULL` to avoid kind-A rejects leaking to kind-B requests. + */ + @Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind IS NULL AND relay = '' LIMIT 1") + fun getPermissionAllKinds( + key: String, + type: String, + ): ApplicationPermissionsEntity? + @Query("SELECT * FROM applicationPermission WHERE pkKey = :key AND type = :type AND kind = :kind AND relay = :relay LIMIT 1") fun getPermissionForRelay( key: String, diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt index 777fd9b6..e6de66cc 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt @@ -21,7 +21,7 @@ import androidx.paging.PagingSource class CachingApplicationDao( private val delegate: ApplicationDao, ) : ApplicationDao { - private enum class Method { SIGN_POLICY, PERM, PERM_KIND, PERM_RELAY, PERM_WILDCARD } + private enum class Method { SIGN_POLICY, PERM, PERM_KIND, PERM_ALL_KINDS, PERM_RELAY, PERM_WILDCARD } private data class Key( val method: Method, @@ -81,6 +81,14 @@ class CachingApplicationDao( return result } + override fun getPermissionAllKinds(key: String, type: String): ApplicationPermissionsEntity? { + val k = Key(Method.PERM_ALL_KINDS, key, type, null, null) + lookup(k)?.let { return it.permission } + val result = delegate.getPermissionAllKinds(key, type) + store(k, Value(null, result)) + return result + } + override fun getPermissionForRelay(key: String, type: String, kind: Int, relay: String): ApplicationPermissionsEntity? { val k = Key(Method.PERM_RELAY, key, type, kind, relay) lookup(k)?.let { return it.permission }