diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 67a8b6d3..e525bd8c 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -292,7 +292,7 @@ fun main(args: Array) { val request = pending.last() var notified = false if (settings.showNotifications) { - val message = "${request.appName} ${request.type.describe(request.kind, language)}" + val message = "${request.appName} ${request.type.describe(request.kind, request.encryptedContent, language)}" // Prefer the OS-native notification channel on Linux // (freedesktop / notify-send, incl. Wayland/Hyprland). On // Windows and macOS the AWT tray notification is the native diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt index b942a1ab..7623e936 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt @@ -69,6 +69,10 @@ data class PendingBunkerRequest( val encryptionType: EncryptionType = EncryptionType.NIP44, val isNostrConnectUri: Boolean = false, val signerPrivKey: String = "", + /** What an NIP-04/NIP-44 encrypt/decrypt payload holds; drives its content-type permission. */ + val encryptedContent: EncryptedContent? = null, + /** NIP-44 v3 context scope string (params[2]); the context kind is [kind]. */ + val nip44v3Scope: String = "", /** * Unix seconds after which the request is dropped from the queue, or null * to keep it until answered. NIP-46 gives no signal when a client stops @@ -85,6 +89,12 @@ data class PendingBunkerRequest( */ val canSwitchAccount: Boolean get() = type == SignerType.CONNECT && (isNostrConnectUri || signerPrivKey.isNotEmpty()) + + /** + * Kind a remembered choice is stored under. NIP-44 v3: this kind for + * [EncryptionScope.SPECIFIC], or no kind (all kinds) for ALL, like Android. + */ + fun permissionKind(scope: EncryptionScope): Int? = if (type in nip44v3SignerTypes && scope == EncryptionScope.ALL) null else kind } /** @@ -481,17 +491,13 @@ class BunkerEngine( return } - val kind = if (bunkerRequest is BunkerRequestSign) bunkerRequest.event.kind else null - val signPolicy = app?.app?.signPolicy - val permissionType = if (type == SignerType.SIGN_EVENT) { - store.getPermission(event.pubKey, type.toString(), kind) - } else { - store.getPermission(event.pubKey, type.toString()) + // NIP-44 v3 carries its context kind/scope at params[1..2]. + val kind = when { + bunkerRequest is BunkerRequestSign -> bunkerRequest.event.kind + type in nip44v3SignerTypes -> bunkerRequest.params.getOrNull(1)?.toIntOrNull() + else -> null } - // A first-time `connect` always goes through the approval UI: approving - // is what migrates the bunker placeholder to the client key and grants - // the default permissions. Reconnects were already acked above. - val remembered = if (type == SignerType.CONNECT) null else isRemembered(signPolicy, permissionType) + val signPolicy = app?.app?.signPolicy // Compute the response payload (also serves as the approval preview). val computed = try { @@ -500,17 +506,49 @@ class BunkerEngine( throw e } catch (e: Exception) { store.addLog(relay.url, "bunker", "Rejecting request that cannot be fulfilled: ${e.message}") + // Mirrors Android's validateNip44v3Request errors; kept generic so + // a failed decrypt doesn't leak the ciphertext's embedded context. + val error = when { + type in nip44v3SignerTypes && kind == null -> "kind is required for nip44v3" + type == SignerType.NIP44_V3_DECRYPT -> "could not decrypt the message" + else -> "could not process the request" + } sendResponse( acc, effectivePrivKey, event.pubKey, encryptionType, - BunkerResponse(bunkerRequest.id, "", "could not process the request"), + BunkerResponse(bunkerRequest.id, "", error), relays, ) return } + // NIP-04/NIP-44 encrypt/decrypt grants are per content type, like + // Android: classify the plaintext (the encrypt input, or the decrypt + // result) and try that grant, then the whole-NIP grant, then `nip:N`. + val encryptedContent = if (type in contentScopedSignerTypes) EncryptedContent.classify(computed.preview) else null + val permissionType = when (type) { + SignerType.SIGN_EVENT -> store.getPermission(event.pubKey, type.toString(), kind) + + // v3 grants are kind-scoped, falling back to the explicit "all + // kinds" grant only (never another kind's), like Android. + in nip44v3SignerTypes -> + store.getPermission(event.pubKey, type.toString(), kind) + ?: store.getPermission(event.pubKey, type.toString(), null) + + in contentScopedSignerTypes -> + store.getPermission(event.pubKey, type.contentPermissionType(encryptedContent)) + ?: store.getPermission(event.pubKey, type.toString()) + ?: store.getPermission(event.pubKey, "NIP", if (type.name.startsWith("NIP04")) 4 else 44) + + else -> store.getPermission(event.pubKey, type.toString()) + } + // A first-time `connect` always goes through the approval UI: approving + // is what migrates the bunker placeholder to the client key and grants + // the default permissions. Reconnects were already acked above. + val remembered = if (type == SignerType.CONNECT) null else isRemembered(signPolicy, permissionType) + when (remembered) { true -> { store.addHistory(HistoryRecord(event.pubKey, type.toString(), kind, TimeUtils.now(), true)) @@ -559,6 +597,8 @@ class BunkerEngine( result = computed.result, encryptionType = encryptionType, signerPrivKey = effectivePrivKey, + encryptedContent = encryptedContent, + nip44v3Scope = if (type in nip44v3SignerTypes) bunkerRequest.params.getOrElse(2) { "" } else "", expiresAt = minOf(TimeUtils.now() + PENDING_TTL_SECONDS, event.expiration() ?: Long.MAX_VALUE), ), ) @@ -694,7 +734,8 @@ class BunkerEngine( signPolicy: Int? = null, deleteAfter: Long = 0L, accountNpub: String? = null, - ): Job = scope.launch { doApprove(req, rememberType, grantedPermissions, signPolicy, deleteAfter, accountNpub) } + encryptionScope: EncryptionScope = EncryptionScope.ALL, + ): Job = scope.launch { doApprove(req, rememberType, grantedPermissions, signPolicy, deleteAfter, accountNpub, encryptionScope) } private suspend fun doApprove( req: PendingBunkerRequest, @@ -703,6 +744,7 @@ class BunkerEngine( signPolicy: Int? = null, deleteAfter: Long = 0L, accountNpub: String? = null, + encryptionScope: EncryptionScope = EncryptionScope.ALL, ) { PassphraseLock.touch() removePending(req.request.id) @@ -786,7 +828,7 @@ class BunkerEngine( ) } } else if (rememberType != RememberType.NEVER) { - acceptOrRejectPermission(application, req.type, req.kind, true, rememberType) + acceptOrRejectPermission(application, req.type, req.permissionKind(encryptionScope), true, rememberType, req.encryptedContent, encryptionScope) } store.upsert(application) @@ -820,11 +862,13 @@ class BunkerEngine( fun reject( req: PendingBunkerRequest, rememberType: RememberType, - ): Job = scope.launch { doReject(req, rememberType) } + encryptionScope: EncryptionScope = EncryptionScope.ALL, + ): Job = scope.launch { doReject(req, rememberType, encryptionScope) } private suspend fun doReject( req: PendingBunkerRequest, rememberType: RememberType, + encryptionScope: EncryptionScope = EncryptionScope.ALL, ) { PassphraseLock.touch() removePending(req.request.id) @@ -857,7 +901,7 @@ class BunkerEngine( ) if (rememberType != RememberType.NEVER) { - acceptOrRejectPermission(application, req.type, req.kind, false, rememberType) + acceptOrRejectPermission(application, req.type, req.permissionKind(encryptionScope), false, rememberType, req.encryptedContent, encryptionScope) } if (req.request !is BunkerRequestConnect) { @@ -912,21 +956,36 @@ class BunkerEngine( } } - /** Mirrors `AmberUtils.acceptPermission` / rejection with ALL scope. */ + /** + * Mirrors `AmberUtils.updatePermission`. For NIP-04/NIP-44 encrypt and + * decrypt, [scope] ALL stores the whole-NIP type (NIP44_DECRYPT) and drops + * the narrower content-type grant; SPECIFIC stores the content type + * (DECRYPT_CLEAR_TEXT) and drops the broader NIP grant. + */ private fun acceptOrRejectPermission( application: AppWithPermissions, type: SignerType, kind: Int?, accepted: Boolean, rememberType: RememberType, + content: EncryptedContent? = null, + scope: EncryptionScope = EncryptionScope.ALL, ) { val until = rememberType.acceptUntil() - val typeStr = type.toString() + val contentScoped = type in contentScopedSignerTypes + val typeStr = if (contentScoped && scope == EncryptionScope.SPECIFIC) type.contentPermissionType(content) else type.toString() if (kind != null) { application.permissions.removeIf { it.kind == kind && it.type == typeStr && it.relay.isEmpty() } } else { application.permissions.removeIf { it.type == typeStr && it.type != "SIGN_EVENT" } + if (contentScoped) { + if (scope == EncryptionScope.ALL) { + application.permissions.removeIf { it.type == type.contentPermissionType(content) } + } else { + application.permissions.removeIf { it.type == type.toString() } + } + } } application.permissions.add( @@ -1146,11 +1205,11 @@ class BunkerEngine( /** * Maps a requested permission string to the stored permission type(s) - * the request path actually queries. Content-scoped or generic - * encrypt/decrypt perms (Amber uses `encrypt_clear_text`, - * `encrypt_event`, `encrypt_tag_array`; standard NIP-46 uses - * `nip04_encrypt`/`nip44_encrypt`) grant both NIP variants because the - * desktop request path is keyed by NIP, not by content type. + * the request path queries. Amber's content-type perms + * (`encrypt_clear_text`, `decrypt_event`, `encrypt_tag_array`, …) are + * stored as-is (ENCRYPT_CLEAR_TEXT, …) and checked first, like Android; + * standard NIP-46 `nip04_encrypt`/`nip44_decrypt` are whole-NIP grants, + * and a generic `encrypt`/`decrypt` grants both NIPs. */ fun expandPermissionTypes(type: String): List = when (type.lowercase()) { "connect" -> listOf(SignerType.CONNECT.toString()) @@ -1162,10 +1221,11 @@ class BunkerEngine( "nip44_decrypt" -> listOf(SignerType.NIP44_DECRYPT.toString()) "nip44v3_encrypt" -> listOf(SignerType.NIP44_V3_ENCRYPT.toString()) "nip44v3_decrypt" -> listOf(SignerType.NIP44_V3_DECRYPT.toString()) - "encrypt_clear_text", "encrypt_event", "encrypt_tag_array", "encrypt" -> - listOf(SignerType.NIP04_ENCRYPT.toString(), SignerType.NIP44_ENCRYPT.toString()) - "decrypt_clear_text", "decrypt_event", "decrypt_tag_array", "decrypt" -> - listOf(SignerType.NIP04_DECRYPT.toString(), SignerType.NIP44_DECRYPT.toString()) + "encrypt_clear_text", "encrypt_event", "encrypt_tag_array", + "decrypt_clear_text", "decrypt_event", "decrypt_tag_array", + -> listOf(type.uppercase()) + "encrypt" -> listOf(SignerType.NIP04_ENCRYPT.toString(), SignerType.NIP44_ENCRYPT.toString()) + "decrypt" -> listOf(SignerType.NIP04_DECRYPT.toString(), SignerType.NIP44_DECRYPT.toString()) "decrypt_zap_event" -> listOf(SignerType.DECRYPT_ZAP_EVENT.toString()) "ping" -> listOf(SignerType.PING.toString()) "sign_psbt" -> listOf(SignerType.SIGN_PSBT.toString()) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt index af299647..3166c137 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -1,5 +1,6 @@ package com.greenart7c3.nostrsigner.desktop.core +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.TimeUtils @@ -103,10 +104,12 @@ data class RequestedPermission( val basicPermissions = listOf( RequestedPermission("get_public_key", null), - RequestedPermission("nip04_encrypt", null), - RequestedPermission("nip04_decrypt", null), - RequestedPermission("nip44_encrypt", null), - RequestedPermission("nip44_decrypt", null), + RequestedPermission("encrypt_clear_text", null), + RequestedPermission("decrypt_clear_text", null), + RequestedPermission("encrypt_event", null), + RequestedPermission("decrypt_event", null), + RequestedPermission("encrypt_tag_array", null), + RequestedPermission("decrypt_tag_array", null), RequestedPermission("decrypt_zap_event", null), RequestedPermission("sign_event", 0), RequestedPermission("sign_event", 1), @@ -128,6 +131,78 @@ val basicPermissions = listOf( RequestedPermission("sign_event", 30023), ) +/** + * What an encrypt/decrypt payload holds, mirroring the Android + * `EncryptedDataKind` (clear text, a tag array, or an event of some kind). + * Encrypt/decrypt permissions are granted per content type. + */ +enum class EncryptedContentType { + CLEAR_TEXT, + EVENT, + TAG_ARRAY, +} + +data class EncryptedContent( + val type: EncryptedContentType, + /** Kind of the event, for [EncryptedContentType.EVENT]. */ + val eventKind: Int? = null, +) { + companion object { + /** Classifies a plaintext (the input of an encrypt, the result of a decrypt). */ + fun classify(plaintext: String): EncryptedContent { + val trimmed = plaintext.trimStart() + if (trimmed.startsWith("{")) { + val node = runCatching { JacksonMapper.mapper.readTree(trimmed) }.getOrNull() + val kind = node?.get("kind") + if (node != null && node.isObject && kind != null && kind.canConvertToInt()) { + return EncryptedContent(EncryptedContentType.EVENT, kind.asInt()) + } + } else if (trimmed.startsWith("[")) { + val node = runCatching { JacksonMapper.mapper.readTree(trimmed) }.getOrNull() + if (node != null && node.isArray && node.all { tag -> tag.isArray && tag.all { it.isTextual } }) { + return EncryptedContent(EncryptedContentType.TAG_ARRAY) + } + } + return EncryptedContent(EncryptedContentType.CLEAR_TEXT) + } + } +} + +/** + * Scope of a remembered encrypt/decrypt choice, mirroring the Android + * `DecryptTypeScope`: [SPECIFIC] covers only this content type (e.g. + * `DECRYPT_CLEAR_TEXT`), [ALL] the whole NIP (e.g. `NIP44_DECRYPT`). + */ +enum class EncryptionScope { + SPECIFIC, + ALL, +} + +/** NIP-04/NIP-44 encrypt and decrypt: the request types granted per content type. */ +val contentScopedSignerTypes = setOf( + SignerType.NIP04_ENCRYPT, + SignerType.NIP44_ENCRYPT, + SignerType.NIP04_DECRYPT, + SignerType.NIP44_DECRYPT, +) + +/** NIP-44 v3 encrypt/decrypt: granted per event kind (the v3 context), not per content type. */ +val nip44v3SignerTypes = setOf(SignerType.NIP44_V3_ENCRYPT, SignerType.NIP44_V3_DECRYPT) + +/** + * The content-type permission for a request, mirroring the Android + * `toPermissionTypeString`: e.g. NIP44_DECRYPT of a tag array -> + * `DECRYPT_TAG_ARRAY`. Other types keep their own name. + */ +fun SignerType.contentPermissionType(content: EncryptedContent?): String { + val isEncrypt = this == SignerType.NIP04_ENCRYPT || this == SignerType.NIP44_ENCRYPT + return if (this in contentScopedSignerTypes) { + (if (isEncrypt) "ENCRYPT_" else "DECRYPT_") + (content?.type ?: EncryptedContentType.CLEAR_TEXT).name + } else { + toString() + } +} + /** * Persisted connection record. Mirrors the Android `ApplicationEntity` * column-for-column so behavior (and future import/export) matches. @@ -286,5 +361,31 @@ fun SignerType.describe(kind: Int?, language: String = Strings.currentLanguage.v SignerType.LOGOUT -> Strings.get("logout", language) SignerType.INVALID -> Strings.get("invalid_request", language) SignerType.PING -> Strings.get("ping", language) + SignerType.NIP44_V3_ENCRYPT -> Strings.get("nip44_v3_wants_to_encrypt", language) + SignerType.NIP44_V3_DECRYPT -> Strings.get("nip44_v3_wants_to_decrypt", language) else -> "${Strings.get("requests", language)} ${SignerDescriptions.permission(methodString(), kind, language)}" } + +/** + * Mirrors the Android encrypt/decrypt approval text: "wants to encrypt this + * text with NIP44", "wants to read Short text note from NIP04 encrypted + * content", … Falls back to [describe] for other request types. + */ +fun SignerType.describe(kind: Int?, content: EncryptedContent?, language: String = Strings.currentLanguage.value): String { + if (this !in contentScopedSignerTypes || content == null) return describe(kind, language) + val nip = name.substringBefore('_') + val isEncrypt = this == SignerType.NIP04_ENCRYPT || this == SignerType.NIP44_ENCRYPT + val text = when (content.type) { + EncryptedContentType.EVENT -> { + val what = SignerDescriptions.signEventDescription(content.eventKind, language) + Strings.format(if (isEncrypt) "wants_to_encrypt_with" else "wants_to_read_from_encrypted_content", what, nip, language = language) + } + + EncryptedContentType.TAG_ARRAY -> + Strings.format(if (isEncrypt) "wants_to_encrypt_this_list_of_tags_with" else "wants_to_read_this_list_of_tags_from_encrypted_content", nip, language = language) + + EncryptedContentType.CLEAR_TEXT -> + Strings.format(if (isEncrypt) "wants_to_encrypt_this_text_with" else "wants_to_read_this_text_from_encrypted_content", nip, language = language) + } + return text.trim() +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt index e2f96932..8b34ff81 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt @@ -378,6 +378,9 @@ private fun permissionTitle(type: String, kind: Int?, language: String): String val description = SignerDescriptions.permission(type, kind, language) return when (type.trim().lowercase()) { "sign_event", "nip" -> Strings.format("sign", description, language = language) + // NIP-44 v3 grants are per context kind, or for all kinds without one. + "nip44_v3_encrypt", "nip44_v3_decrypt" -> + "$description · " + (kind?.let { "$it (${SignerDescriptions.signEventDescription(it, language)})" } ?: Strings.get("for_all_kinds", language)) else -> description } } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt index d01eedd3..3cf6370b 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,12 +40,15 @@ import androidx.compose.ui.unit.dp import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.EncryptionScope import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions import com.greenart7c3.nostrsigner.desktop.core.SignerType import com.greenart7c3.nostrsigner.desktop.core.Strings +import com.greenart7c3.nostrsigner.desktop.core.contentScopedSignerTypes import com.greenart7c3.nostrsigner.desktop.core.describe +import com.greenart7c3.nostrsigner.desktop.core.nip44v3SignerTypes import com.greenart7c3.nostrsigner.desktop.core.toShortenHex @Composable @@ -124,7 +128,7 @@ private fun RequestCard( Text(req.appUrl, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.height(4.dp)) - Text(req.type.describe(req.kind, language), style = MaterialTheme.typography.bodyLarge) + Text(req.type.describe(req.kind, req.encryptedContent, language), style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(4.dp)) // The account that signs this request; a connect can be moved to // another account, like the Android connect screen's picker. @@ -185,6 +189,41 @@ private fun RequestCard( } Spacer(Modifier.height(12.dp)) + val isV3 = req.type in nip44v3SignerTypes + if (isV3) { + // Mirrors Android's Nip44v3ContextBox: the kind + scope the + // ciphertext is bound to, i.e. what is being granted. + Spacer(Modifier.height(4.dp)) + Text(Strings.get("nip44_v3_context", language), style = MaterialTheme.typography.labelMedium) + val kindLabel = req.kind?.let { "$it (${SignerDescriptions.signEventDescription(it, language)})" } ?: "-" + Text(Strings.format("nip44_v3_kind", kindLabel, language = language), style = MaterialTheme.typography.bodySmall) + Text( + Strings.format("nip44_v3_scope", req.nip44v3Scope.ifEmpty { Strings.get("nip44_v3_no_scope", language) }, language = language), + style = MaterialTheme.typography.bodySmall, + ) + } + if (req.type in contentScopedSignerTypes || isV3) { + // Mirrors the Android "Encryption scope" toggle: NIP-04/44 grants + // this content type or the whole NIP; NIP-44 v3 this kind or all. + val scopeChoices by UiState.scopeChoices.collectAsState() + val scope = scopeChoices[req.request.id] ?: UiState.defaultScope(req) + Text(Strings.get("encryption_scope", language), style = MaterialTheme.typography.labelMedium) + val options = if (isV3) { + listOf(EncryptionScope.SPECIFIC to "for_this_kind_only", EncryptionScope.ALL to "for_all_kinds") + } else { + listOf(EncryptionScope.SPECIFIC to "for_this_method_only", EncryptionScope.ALL to "for_all_methods") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + options.forEach { (value, key) -> + FilterChip( + selected = scope == value, + onClick = { UiState.setScopeChoice(req.request.id, value) }, + label = { Text(Strings.get(key, language)) }, + ) + } + } + Spacer(Modifier.height(4.dp)) + } if (req.type != SignerType.CONNECT) { RememberTypeSelector(rememberType) { UiState.setRememberChoice(req.request.id, it) } Spacer(Modifier.height(8.dp)) @@ -210,14 +249,18 @@ private fun RequestCard( text = Strings.get("reject", language), onClick = { working = true - AmberDesktop.engine.reject(req, rememberType) + UiState.reject(req) Toaster.toast(Strings.get("d_request_rejected", language)) }, ) Spacer(Modifier.weight(1f)) Text( Strings.format( - if (req.type == SignerType.CONNECT) "d_shortcut_hint_connect" else "d_shortcut_hint", + when { + req.type == SignerType.CONNECT -> "d_shortcut_hint_connect" + req.type in contentScopedSignerTypes || req.type in nip44v3SignerTypes -> "d_shortcut_hint_scope" + else -> "d_shortcut_hint" + }, shortcutLabel("↵"), shortcutLabel("↵", shift = true), language = language, diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt index 534f3192..e782d483 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/UiState.kt @@ -12,12 +12,15 @@ import com.greenart7c3.nostrsigner.desktop.Session import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DeleteAfterType +import com.greenart7c3.nostrsigner.desktop.core.EncryptionScope import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.RequestedPermission import com.greenart7c3.nostrsigner.desktop.core.SignerType import com.greenart7c3.nostrsigner.desktop.core.Strings +import com.greenart7c3.nostrsigner.desktop.core.contentScopedSignerTypes +import com.greenart7c3.nostrsigner.desktop.core.nip44v3SignerTypes import com.greenart7c3.nostrsigner.desktop.core.rememberTypeDisplayOrder import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +48,20 @@ object UiState { /** Per-request "Remember" choice, shared by the dropdown and the shortcuts. */ val rememberChoices = MutableStateFlow>(emptyMap()) + /** + * Per-request encrypt/decrypt "Encryption scope". Android defaults NIP-04/44 + * to all methods and NIP-44 v3 to this kind only. + */ + val scopeChoices = MutableStateFlow>(emptyMap()) + + fun scopeChoiceFor(request: PendingBunkerRequest): EncryptionScope = scopeChoices.value[request.request.id] ?: defaultScope(request) + + fun defaultScope(request: PendingBunkerRequest): EncryptionScope = if (request.type in nip44v3SignerTypes) EncryptionScope.SPECIFIC else EncryptionScope.ALL + + fun setScopeChoice(requestId: String, scope: EncryptionScope) { + scopeChoices.value = scopeChoices.value + (requestId to scope) + } + /** * Per-connect-request choices (sign policy, granted permissions, delete * after), shared by the request card and the approve shortcut. @@ -74,10 +91,19 @@ object UiState { accountNpub = choice.accountNpub, ) } else { - AmberDesktop.engine.approve(request, rememberChoiceFor(request.request.id)) + AmberDesktop.engine.approve( + request, + rememberChoiceFor(request.request.id), + encryptionScope = scopeChoiceFor(request), + ) } } + /** Rejects [request] with the "Remember" and scope choices made on its card. */ + fun reject(request: PendingBunkerRequest) { + AmberDesktop.engine.reject(request, rememberChoiceFor(request.request.id), scopeChoiceFor(request)) + } + fun navigate(route: Route) { selectedApplication.value = null currentRoute.value = route @@ -127,6 +153,17 @@ object UiState { return true } + /** + * S on a selected encrypt/decrypt request: toggles its "Encryption scope" + * (this method / kind only <-> all methods / kinds). + */ + fun toggleSelectedScope(): Boolean { + val request = selectedRequest()?.takeIf { it.type in contentScopedSignerTypes || it.type in nip44v3SignerTypes } ?: return false + val next = if (scopeChoiceFor(request) == EncryptionScope.ALL) EncryptionScope.SPECIFIC else EncryptionScope.ALL + setScopeChoice(request.request.id, next) + return true + } + /** A on a selected connect request: cycles the account it will be saved under. */ fun cycleSelectedAccount(): Boolean { val request = selectedRequest()?.takeIf { it.canSwitchAccount } ?: return false @@ -150,6 +187,9 @@ object UiState { if (connectChoices.value.keys.any { it !in ids }) { connectChoices.value = connectChoices.value.filterKeys { it in ids } } + if (scopeChoices.value.keys.any { it !in ids }) { + scopeChoices.value = scopeChoices.value.filterKeys { it in ids } + } } } @@ -171,6 +211,8 @@ fun shortcutLabel(key: String, shift: Boolean = false): String = buildString { * (on a connect request: its "Delete after" choice) * - 1/2/3 (connect request): basic / manual / full-trust sign policy * - A (connect request): cycle the account the connection is saved under + * - S (encrypt/decrypt request): toggle the encryption scope (this method or + * kind only / all methods or kinds) * - Ctrl/⌘ Enter: approve the selected request with the chosen duration * - Ctrl/⌘ Shift Enter: reject the selected request * - Ctrl/⌘ L: lock (when a passphrase is set) @@ -231,6 +273,8 @@ fun handleShortcut( Key.Three -> return UiState.setSelectedSignPolicy(2) Key.A -> return UiState.cycleSelectedAccount() + + Key.S -> return UiState.toggleSelectedScope() } } return false @@ -245,9 +289,8 @@ fun handleShortcut( Key.Enter -> { if (!loggedIn) return false val request = UiState.selectedRequest() ?: return false - val rememberType = UiState.rememberChoiceFor(request.request.id) if (event.isShiftPressed) { - AmberDesktop.engine.reject(request, rememberType) + UiState.reject(request) Toaster.toast(Strings.get("d_request_rejected")) } else { UiState.approve(request) diff --git a/desktop/src/main/resources/i18n/strings_de.xml b/desktop/src/main/resources/i18n/strings_de.xml index bbd90142..5dcf38ea 100644 --- a/desktop/src/main/resources/i18n/strings_de.xml +++ b/desktop/src/main/resources/i18n/strings_de.xml @@ -760,6 +760,7 @@ Anfrage genehmigt Anfrage abgelehnt ↑↓ auswählen · ←→ merken · %1$s genehmigen · %2$s ablehnen + ↑↓ auswählen · ←→ merken · S Bereich · %1$s genehmigen · %2$s ablehnen ↑↓ auswählen · ←→ löschen nach · 1–3 Berechtigungen · A Konto · %1$s genehmigen · %2$s ablehnen Merken: %1$s QR-Code diff --git a/desktop/src/main/resources/i18n/strings_en.xml b/desktop/src/main/resources/i18n/strings_en.xml index 3b9cac0e..265f4c75 100644 --- a/desktop/src/main/resources/i18n/strings_en.xml +++ b/desktop/src/main/resources/i18n/strings_en.xml @@ -771,6 +771,7 @@ Request approved Request rejected ↑↓ select · ←→ remember · %1$s approve · %2$s reject + ↑↓ select · ←→ remember · S scope · %1$s approve · %2$s reject ↑↓ select · ←→ delete after · 1–3 permissions · A account · %1$s approve · %2$s reject Remember: %1$s QR code diff --git a/desktop/src/main/resources/i18n/strings_es.xml b/desktop/src/main/resources/i18n/strings_es.xml index 40705bba..27c95600 100644 --- a/desktop/src/main/resources/i18n/strings_es.xml +++ b/desktop/src/main/resources/i18n/strings_es.xml @@ -763,6 +763,7 @@ Solicitud aprobada Solicitud rechazada ↑↓ seleccionar · ←→ recordar · %1$s aprobar · %2$s rechazar + ↑↓ seleccionar · ←→ recordar · S alcance · %1$s aprobar · %2$s rechazar ↑↓ seleccionar · ←→ eliminar después de · 1–3 permisos · A cuenta · %1$s aprobar · %2$s rechazar Recordar: %1$s Código QR diff --git a/desktop/src/main/resources/i18n/strings_fr.xml b/desktop/src/main/resources/i18n/strings_fr.xml index 84b392ab..60beb420 100644 --- a/desktop/src/main/resources/i18n/strings_fr.xml +++ b/desktop/src/main/resources/i18n/strings_fr.xml @@ -760,6 +760,7 @@ Requête approuvée Requête rejetée ↑↓ sélectionner · ←→ mémoriser · %1$s approuver · %2$s rejeter + ↑↓ sélectionner · ←→ mémoriser · S portée · %1$s approuver · %2$s rejeter ↑↓ sélectionner · ←→ supprimer après · 1–3 autorisations · A compte · %1$s approuver · %2$s rejeter Mémoriser : %1$s QR code diff --git a/desktop/src/main/resources/i18n/strings_in.xml b/desktop/src/main/resources/i18n/strings_in.xml index c9c3af2f..07a25b46 100644 --- a/desktop/src/main/resources/i18n/strings_in.xml +++ b/desktop/src/main/resources/i18n/strings_in.xml @@ -763,6 +763,7 @@ Permintaan disetujui Permintaan ditolak ↑↓ pilih · ←→ ingat · %1$s setujui · %2$s tolak + ↑↓ pilih · ←→ ingat · S cakupan · %1$s setujui · %2$s tolak ↑↓ pilih · ←→ hapus setelah · 1–3 izin · A akun · %1$s setujui · %2$s tolak Ingat: %1$s Kode QR diff --git a/desktop/src/main/resources/i18n/strings_it.xml b/desktop/src/main/resources/i18n/strings_it.xml index 857c9042..ddfcbbd3 100644 --- a/desktop/src/main/resources/i18n/strings_it.xml +++ b/desktop/src/main/resources/i18n/strings_it.xml @@ -763,6 +763,7 @@ Richiesta approvata Richiesta rifiutata ↑↓ seleziona · ←→ ricorda · %1$s approva · %2$s rifiuta + ↑↓ seleziona · ←→ ricorda · S ambito · %1$s approva · %2$s rifiuta ↑↓ seleziona · ←→ elimina dopo · 1–3 permessi · A account · %1$s approva · %2$s rifiuta Ricorda: %1$s Codice QR diff --git a/desktop/src/main/resources/i18n/strings_ja.xml b/desktop/src/main/resources/i18n/strings_ja.xml index 98f4061f..bd19d68c 100644 --- a/desktop/src/main/resources/i18n/strings_ja.xml +++ b/desktop/src/main/resources/i18n/strings_ja.xml @@ -739,6 +739,7 @@ リクエストを承認しました リクエストを拒否しました ↑↓ 選択 · ←→ 記憶 · %1$s 承認 · %2$s 拒否 + ↑↓ 選択 · ←→ 記憶 · S 範囲 · %1$s 承認 · %2$s 拒否 ↑↓ 選択 · ←→ 削除までの期間 · 1–3 権限 · A アカウント · %1$s 承認 · %2$s 拒否 記憶: %1$s QR コード diff --git a/desktop/src/main/resources/i18n/strings_ko.xml b/desktop/src/main/resources/i18n/strings_ko.xml index 97cc76e6..a5e37856 100644 --- a/desktop/src/main/resources/i18n/strings_ko.xml +++ b/desktop/src/main/resources/i18n/strings_ko.xml @@ -763,6 +763,7 @@ 요청이 승인됨 요청이 거부됨 ↑↓ 선택 · ←→ 기억 · %1$s 승인 · %2$s 거부 + ↑↓ 선택 · ←→ 기억 · S 범위 · %1$s 승인 · %2$s 거부 ↑↓ 선택 · ←→ 삭제 시점 · 1–3 권한 · A 계정 · %1$s 승인 · %2$s 거부 기억: %1$s QR 코드 diff --git a/desktop/src/main/resources/i18n/strings_pt-BR.xml b/desktop/src/main/resources/i18n/strings_pt-BR.xml index 5406d66b..7d620f89 100644 --- a/desktop/src/main/resources/i18n/strings_pt-BR.xml +++ b/desktop/src/main/resources/i18n/strings_pt-BR.xml @@ -758,6 +758,7 @@ Solicitação aprovada Solicitação rejeitada ↑↓ selecionar · ←→ lembrar · %1$s aprovar · %2$s rejeitar + ↑↓ selecionar · ←→ lembrar · S escopo · %1$s aprovar · %2$s rejeitar ↑↓ selecionar · ←→ excluir após · 1–3 permissões · A conta · %1$s aprovar · %2$s rejeitar Lembrar: %1$s Código QR diff --git a/desktop/src/main/resources/i18n/strings_ru.xml b/desktop/src/main/resources/i18n/strings_ru.xml index cbc52670..a769fdaf 100644 --- a/desktop/src/main/resources/i18n/strings_ru.xml +++ b/desktop/src/main/resources/i18n/strings_ru.xml @@ -763,6 +763,7 @@ Запрос одобрен Запрос отклонён ↑↓ выбрать · ←→ запомнить · %1$s одобрить · %2$s отклонить + ↑↓ выбрать · ←→ запомнить · S область · %1$s одобрить · %2$s отклонить ↑↓ выбрать · ←→ удалить через · 1–3 разрешения · A аккаунт · %1$s одобрить · %2$s отклонить Запомнить: %1$s QR-код diff --git a/desktop/src/main/resources/i18n/strings_th.xml b/desktop/src/main/resources/i18n/strings_th.xml index 3d6efbcc..80b0f94b 100644 --- a/desktop/src/main/resources/i18n/strings_th.xml +++ b/desktop/src/main/resources/i18n/strings_th.xml @@ -739,6 +739,7 @@ อนุมัติคำขอแล้ว ปฏิเสธคำขอแล้ว ↑↓ เลือก · ←→ จดจำ · %1$s อนุมัติ · %2$s ปฏิเสธ + ↑↓ เลือก · ←→ จดจำ · S ขอบเขต · %1$s อนุมัติ · %2$s ปฏิเสธ ↑↓ เลือก · ←→ ลบหลังจาก · 1–3 สิทธิ์ · A บัญชี · %1$s อนุมัติ · %2$s ปฏิเสธ จดจำ: %1$s QR code diff --git a/desktop/src/main/resources/i18n/strings_tr.xml b/desktop/src/main/resources/i18n/strings_tr.xml index 1ca600b4..5c42df8f 100644 --- a/desktop/src/main/resources/i18n/strings_tr.xml +++ b/desktop/src/main/resources/i18n/strings_tr.xml @@ -759,6 +759,7 @@ İstek onaylandı İstek reddedildi ↑↓ seç · ←→ hatırla · %1$s onayla · %2$s reddet + ↑↓ seç · ←→ hatırla · S kapsam · %1$s onayla · %2$s reddet ↑↓ seç · ←→ şu süre sonra sil · 1–3 izinler · A hesap · %1$s onayla · %2$s reddet Hatırla: %1$s QR kodu diff --git a/desktop/src/main/resources/i18n/strings_vi.xml b/desktop/src/main/resources/i18n/strings_vi.xml index b1443eaf..1e904cd8 100644 --- a/desktop/src/main/resources/i18n/strings_vi.xml +++ b/desktop/src/main/resources/i18n/strings_vi.xml @@ -739,6 +739,7 @@ Đã phê duyệt yêu cầu Đã từ chối yêu cầu ↑↓ chọn · ←→ ghi nhớ · %1$s phê duyệt · %2$s từ chối + ↑↓ chọn · ←→ ghi nhớ · S phạm vi · %1$s phê duyệt · %2$s từ chối ↑↓ chọn · ←→ xóa sau · 1–3 quyền · A tài khoản · %1$s phê duyệt · %2$s từ chối Ghi nhớ: %1$s Mã QR diff --git a/desktop/src/main/resources/i18n/strings_zh.xml b/desktop/src/main/resources/i18n/strings_zh.xml index a49d9b97..70ed8a3c 100644 --- a/desktop/src/main/resources/i18n/strings_zh.xml +++ b/desktop/src/main/resources/i18n/strings_zh.xml @@ -744,6 +744,7 @@ 请求已批准 请求已拒绝 ↑↓ 选择 · ←→ 记住 · %1$s 批准 · %2$s 拒绝 + ↑↓ 选择 · ←→ 记住 · S 范围 · %1$s 批准 · %2$s 拒绝 ↑↓ 选择 · ←→ 删除时间 · 1–3 权限 · A 账户 · %1$s 批准 · %2$s 拒绝 记住:%1$s QR 码 diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt index b2f4850a..e739126d 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt @@ -2,6 +2,8 @@ package com.greenart7c3.nostrsigner.desktop import com.greenart7c3.nostrsigner.desktop.core.AccountManager import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.EncryptedContentType +import com.greenart7c3.nostrsigner.desktop.core.EncryptionScope import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.SettingsStore import com.vitorpamplona.quartz.nip01Core.core.Event @@ -316,6 +318,140 @@ class BunkerE2eTest { client.stop() } + /** + * Encrypt grants are per content type, like Android: approving a text + * encryption "for this method only" auto-approves the next text, but a + * tag array still needs approval; "all methods" then covers it too. + */ + @Test + fun encryptPermissionsAreScopedByContentType() = runBlocking { + assumeTrue("Set AMBER_E2E=1 to run the relay round-trip test", System.getenv("AMBER_E2E") != null) + + val relay = RelayUrlNormalizer.normalize("wss://nos.lol/") + SettingsStore.update { it.copy(defaultRelays = listOf(relay.url)) } + + val account = AccountManager.addAccount(KeyPair(), name = "scope") + val engine = AmberDesktop.engine + engine.start() + val bunkerUri = engine.createBunkerConnection(account, "scope-app", listOf(relay)) + val signerPubKey = bunkerUri.removePrefix("bunker://").substringBefore("?") + val secret = bunkerUri.substringAfter("secret=") + val client = Client(relay, KeyPair()) + val peer = KeyPair().pubKey.toHexKey() + delay(3000) + + suspend fun request(json: String) { + client.send(signerPubKey, relay, json) + } + suspend fun pendingFor(id: String) = withTimeout(30_000) { + engine.pending.first { list -> list.any { it.request.id == id } } + }.first { it.request.id == id } + suspend fun responseFor(id: String) = withTimeout(30_000) { + client.responses.first { l -> l.any { it.id == id } } + }.first { it.id == id } + + // Connect with the manual policy and no requested perms. + request("""{"id":"sc-connect","method":"connect","params":["$signerPubKey","$secret"]}""") + engine.approve(pendingFor("sc-connect"), RememberType.ALWAYS, signPolicy = 1).join() + responseFor("sc-connect") + + // Text: approve and remember for this content type only. + request("""{"id":"sc-t1","method":"nip44_encrypt","params":["$peer","hello"]}""") + val t1 = pendingFor("sc-t1") + assertEquals(EncryptedContentType.CLEAR_TEXT, t1.encryptedContent?.type) + engine.approve(t1, RememberType.ALWAYS, encryptionScope = EncryptionScope.SPECIFIC).join() + responseFor("sc-t1") + val stored = AmberDesktop.store(account.npub).apps.value.first { it.app.name == "scope-app" }.permissions.map { it.type } + assertTrue(stored.toString(), "ENCRYPT_CLEAR_TEXT" in stored && "NIP44_ENCRYPT" !in stored) + + // Another text is answered without prompting. + request("""{"id":"sc-t2","method":"nip44_encrypt","params":["$peer","again"]}""") + assertTrue(responseFor("sc-t2").result!!.isNotEmpty()) + + // A tag array is a different content type: it prompts. Approve for all methods. + request("""{"id":"sc-tags","method":"nip44_encrypt","params":["$peer","[[\"p\",\"$peer\"]]"]}""") + val tags = pendingFor("sc-tags") + assertEquals(EncryptedContentType.TAG_ARRAY, tags.encryptedContent?.type) + engine.approve(tags, RememberType.ALWAYS, encryptionScope = EncryptionScope.ALL).join() + responseFor("sc-tags") + + // The whole-NIP grant now covers an event too. + request("""{"id":"sc-ev","method":"nip44_encrypt","params":["$peer","{\"kind\":1,\"content\":\"x\",\"tags\":[]}"]}""") + assertTrue(responseFor("sc-ev").result!!.isNotEmpty()) + + client.stop() + } + + /** + * NIP-44 v3 grants are per context kind, like Android: "this kind only" + * covers the next request of that kind but not another kind; "all kinds" + * replaces them with a kind-less grant. A request without a kind is + * rejected without prompting. + */ + @Test + fun nip44v3PermissionsAreScopedByKind() = runBlocking { + assumeTrue("Set AMBER_E2E=1 to run the relay round-trip test", System.getenv("AMBER_E2E") != null) + + val relay = RelayUrlNormalizer.normalize("wss://nos.lol/") + SettingsStore.update { it.copy(defaultRelays = listOf(relay.url)) } + + val account = AccountManager.addAccount(KeyPair(), name = "v3") + val engine = AmberDesktop.engine + engine.start() + val bunkerUri = engine.createBunkerConnection(account, "v3-app", listOf(relay)) + val signerPubKey = bunkerUri.removePrefix("bunker://").substringBefore("?") + val secret = bunkerUri.substringAfter("secret=") + val client = Client(relay, KeyPair()) + val peer = KeyPair().pubKey.toHexKey() + val plain = java.util.Base64.getEncoder().encodeToString("hi".toByteArray()) + delay(3000) + + suspend fun request(json: String) { + client.send(signerPubKey, relay, json) + } + suspend fun pendingFor(id: String) = withTimeout(30_000) { + engine.pending.first { list -> list.any { it.request.id == id } } + }.first { it.request.id == id } + suspend fun responseFor(id: String) = withTimeout(30_000) { + client.responses.first { l -> l.any { it.id == id } } + }.first { it.id == id } + fun v3Perms() = AmberDesktop.store(account.npub).apps.value.first { it.app.name == "v3-app" } + .permissions.filter { it.type == "NIP44_V3_ENCRYPT" }.map { it.kind } + + request("""{"id":"v3-connect","method":"connect","params":["$signerPubKey","$secret"]}""") + engine.approve(pendingFor("v3-connect"), RememberType.ALWAYS, signPolicy = 1).join() + responseFor("v3-connect") + + // Kind 1, this kind only. + request("""{"id":"v3-a","method":"nip44v3_encrypt","params":["$peer","1","chat","$plain"]}""") + val a = pendingFor("v3-a") + assertEquals(1, a.kind) + assertEquals("chat", a.nip44v3Scope) + engine.approve(a, RememberType.ALWAYS, encryptionScope = EncryptionScope.SPECIFIC).join() + responseFor("v3-a") + assertEquals(listOf(1), v3Perms()) + + // Same kind: answered without prompting. + request("""{"id":"v3-b","method":"nip44v3_encrypt","params":["$peer","1","chat","$plain"]}""") + assertTrue(responseFor("v3-b").result!!.isNotEmpty()) + + // Another kind prompts; grant all kinds. + request("""{"id":"v3-c","method":"nip44v3_encrypt","params":["$peer","7","","$plain"]}""") + engine.approve(pendingFor("v3-c"), RememberType.ALWAYS, encryptionScope = EncryptionScope.ALL).join() + responseFor("v3-c") + assertEquals(listOf(null), v3Perms()) + + // Any kind is now covered. + request("""{"id":"v3-d","method":"nip44v3_encrypt","params":["$peer","30023","","$plain"]}""") + assertTrue(responseFor("v3-d").result!!.isNotEmpty()) + + // No kind: rejected up front. + request("""{"id":"v3-e","method":"nip44v3_encrypt","params":["$peer","","","$plain"]}""") + assertEquals("kind is required for nip44v3", responseFor("v3-e").error) + + client.stop() + } + /** * The nostrconnect:// flow real web apps use: the client publishes a URI, * Amber imports it, the user approves, Amber sends the connect ack from a diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt index 82cab7fc..7b05eacf 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt @@ -129,18 +129,13 @@ class DesktopCoreTest { assertEquals(listOf("SIGN_EVENT"), BunkerEngine.expandPermissionTypes("sign_event")) assertEquals(listOf("GET_PUBLIC_KEY"), BunkerEngine.expandPermissionTypes("get_public_key")) - // Content-scoped / generic encrypt-decrypt perms are NIP-agnostic, so a - // grant must cover both NIP variants the request path can produce — - // otherwise the grant is stored under a key never queried and the - // client is re-prompted every time. - assertEquals( - setOf("NIP04_ENCRYPT", "NIP44_ENCRYPT"), - BunkerEngine.expandPermissionTypes("encrypt_event").toSet(), - ) - assertEquals( - setOf("NIP04_DECRYPT", "NIP44_DECRYPT"), - BunkerEngine.expandPermissionTypes("decrypt_clear_text").toSet(), - ) + // Amber's content-type perms are stored as-is, like Android, and the + // request path checks them before the whole-NIP grant. + assertEquals(listOf("ENCRYPT_EVENT"), BunkerEngine.expandPermissionTypes("encrypt_event")) + assertEquals(listOf("DECRYPT_CLEAR_TEXT"), BunkerEngine.expandPermissionTypes("decrypt_clear_text")) + assertEquals(listOf("ENCRYPT_TAG_ARRAY"), BunkerEngine.expandPermissionTypes("encrypt_tag_array")) + + // A generic encrypt/decrypt perm grants both NIPs. assertEquals( setOf("NIP04_ENCRYPT", "NIP44_ENCRYPT"), BunkerEngine.expandPermissionTypes("encrypt").toSet(), diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/EncryptedContentTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/EncryptedContentTest.kt new file mode 100644 index 00000000..52d2e676 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/EncryptedContentTest.kt @@ -0,0 +1,42 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.EncryptedContent +import com.greenart7c3.nostrsigner.desktop.core.EncryptedContentType +import com.greenart7c3.nostrsigner.desktop.core.SignerType +import com.greenart7c3.nostrsigner.desktop.core.contentPermissionType +import com.greenart7c3.nostrsigner.desktop.core.describe +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EncryptedContentTest { + @Test + fun classifiesLikeTheAndroidEncryptedDataKind() { + assertEquals(EncryptedContent(EncryptedContentType.CLEAR_TEXT), EncryptedContent.classify("hello")) + assertEquals(EncryptedContent(EncryptedContentType.EVENT, 14), EncryptedContent.classify("""{"kind":14,"content":"hi","tags":[]}""")) + assertEquals(EncryptedContent(EncryptedContentType.TAG_ARRAY), EncryptedContent.classify("""[["p","abc"],["e","def"]]""")) + // Broken JSON (or JSON that is not an event / tag array) is just text. + assertEquals(EncryptedContentType.CLEAR_TEXT, EncryptedContent.classify("{not json").type) + assertEquals(EncryptedContentType.CLEAR_TEXT, EncryptedContent.classify("""{"a":1}""").type) + assertEquals(EncryptedContentType.CLEAR_TEXT, EncryptedContent.classify("[1,2]").type) + } + + @Test + fun permissionTypesMatchTheAndroidNames() { + val text = EncryptedContent(EncryptedContentType.CLEAR_TEXT) + val event = EncryptedContent(EncryptedContentType.EVENT, 1) + val tags = EncryptedContent(EncryptedContentType.TAG_ARRAY) + assertEquals("ENCRYPT_CLEAR_TEXT", SignerType.NIP44_ENCRYPT.contentPermissionType(text)) + assertEquals("ENCRYPT_EVENT", SignerType.NIP04_ENCRYPT.contentPermissionType(event)) + assertEquals("DECRYPT_TAG_ARRAY", SignerType.NIP44_DECRYPT.contentPermissionType(tags)) + assertEquals("DECRYPT_CLEAR_TEXT", SignerType.NIP04_DECRYPT.contentPermissionType(null)) + assertEquals("SIGN_EVENT", SignerType.SIGN_EVENT.contentPermissionType(text)) + } + + @Test + fun describesWhatIsEncrypted() { + assertEquals("wants to encrypt this text with NIP44", SignerType.NIP44_ENCRYPT.describe(null, EncryptedContent(EncryptedContentType.CLEAR_TEXT), "en")) + assertEquals("wants to read this list of tags from NIP04 encrypted content", SignerType.NIP04_DECRYPT.describe(null, EncryptedContent(EncryptedContentType.TAG_ARRAY), "en")) + assertTrue(SignerType.NIP44_ENCRYPT.describe(null, EncryptedContent(EncryptedContentType.EVENT, 1), "en").contains("Short text note")) + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt index 508a8b21..96326bd4 100644 --- a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/UiStateTest.kt @@ -1,6 +1,7 @@ package com.greenart7c3.nostrsigner.desktop import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.EncryptionScope import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.SignerType @@ -9,7 +10,9 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import java.io.File import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.BeforeClass import org.junit.Test @@ -34,6 +37,43 @@ class UiStateTest { AmberDesktop.engine.pending.value = emptyList() UiState.selectedRequestId.value = null UiState.rememberChoices.value = emptyMap() + UiState.scopeChoices.value = emptyMap() + } + + @Test + fun scopeShortcutTogglesEncryptionScope() = runBlocking { + val account = com.greenart7c3.nostrsigner.desktop.core.AccountManager.addAccount( + com.vitorpamplona.quartz.nip01Core.crypto.KeyPair(), + ) + fun req(id: String, type: SignerType) = PendingBunkerRequest( + request = BunkerRequest(id, type.name.lowercase(), arrayOf()), + type = type, + account = account, + localKey = "k$id", + relays = emptyList(), + ) + val v2 = req("v2", SignerType.NIP44_DECRYPT) + val v3 = req("v3", SignerType.NIP44_V3_ENCRYPT) + val sign = req("sign", SignerType.SIGN_EVENT) + AmberDesktop.engine.pending.value = listOf(v2, v3, sign) + + // Android defaults: NIP-04/44 all methods, NIP-44 v3 this kind only. + assertEquals(EncryptionScope.ALL, UiState.scopeChoiceFor(v2)) + assertEquals(EncryptionScope.SPECIFIC, UiState.scopeChoiceFor(v3)) + + UiState.selectedRequestId.value = "v2" + assertTrue(UiState.toggleSelectedScope()) + assertEquals(EncryptionScope.SPECIFIC, UiState.scopeChoiceFor(v2)) + assertTrue(UiState.toggleSelectedScope()) + assertEquals(EncryptionScope.ALL, UiState.scopeChoiceFor(v2)) + + UiState.selectedRequestId.value = "v3" + assertTrue(UiState.toggleSelectedScope()) + assertEquals(EncryptionScope.ALL, UiState.scopeChoiceFor(v3)) + + // Not an encrypt/decrypt request: the key is left alone. + UiState.selectedRequestId.value = "sign" + assertFalse(UiState.toggleSelectedScope()) } @Test