desktop: scope encrypt/decrypt permissions by type, like Android

- NIP-04/NIP-44 encrypt/decrypt payloads are classified as clear text,
  event or tag array; lookup tries the content-type grant
  (ENCRYPT_CLEAR_TEXT, DECRYPT_EVENT, ...), then the whole-NIP grant, then
  nip:4/44. Requested content-type perms and the basic sign policy now
  store those types instead of expanding to both NIPs.
- NIP-44 v3 grants are per context kind with an "all kinds" fallback;
  missing-kind and failed v3 decrypts get Android's error responses.
- Request cards show Android's encrypt/decrypt wording, the v3 context
  (kind + scope) and the "Encryption scope" toggle (method/kind only vs
  all); S toggles it from the keyboard.
- App detail shows the kind on v3 permissions and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
greenart7c3
2026-10-02 13:08:14 -03:00
co-authored by Claude Opus 5.5
parent 65e8a13824
commit 4796deecec
24 changed files with 526 additions and 49 deletions
@@ -292,7 +292,7 @@ fun main(args: Array<String>) {
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
@@ -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<String> = 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())
@@ -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()
}
@@ -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
}
}
@@ -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,
@@ -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<Map<String, RememberType>>(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<Map<String, EncryptionScope>>(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)
@@ -760,6 +760,7 @@
<string name="d_request_approved">Anfrage genehmigt</string>
<string name="d_request_rejected">Anfrage abgelehnt</string>
<string name="d_shortcut_hint">↑↓ auswählen · ←→ merken · %1$s genehmigen · %2$s ablehnen</string>
<string name="d_shortcut_hint_scope">↑↓ auswählen · ←→ merken · S Bereich · %1$s genehmigen · %2$s ablehnen</string>
<string name="d_shortcut_hint_connect">↑↓ auswählen · ←→ löschen nach · 1–3 Berechtigungen · A Konto · %1$s genehmigen · %2$s ablehnen</string>
<string name="d_remember_prefix">Merken: %1$s</string>
<string name="d_qr_code">QR-Code</string>
@@ -771,6 +771,7 @@
<string name="d_request_approved">Request approved</string>
<string name="d_request_rejected">Request rejected</string>
<string name="d_shortcut_hint">↑↓ select · ←→ remember · %1$s approve · %2$s reject</string>
<string name="d_shortcut_hint_scope">↑↓ select · ←→ remember · S scope · %1$s approve · %2$s reject</string>
<string name="d_shortcut_hint_connect">↑↓ select · ←→ delete after · 1–3 permissions · A account · %1$s approve · %2$s reject</string>
<string name="d_remember_prefix">Remember: %1$s</string>
<string name="d_qr_code">QR code</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">Solicitud aprobada</string>
<string name="d_request_rejected">Solicitud rechazada</string>
<string name="d_shortcut_hint">↑↓ seleccionar · ←→ recordar · %1$s aprobar · %2$s rechazar</string>
<string name="d_shortcut_hint_scope">↑↓ seleccionar · ←→ recordar · S alcance · %1$s aprobar · %2$s rechazar</string>
<string name="d_shortcut_hint_connect">↑↓ seleccionar · ←→ eliminar después de · 1–3 permisos · A cuenta · %1$s aprobar · %2$s rechazar</string>
<string name="d_remember_prefix">Recordar: %1$s</string>
<string name="d_qr_code">Código QR</string>
@@ -760,6 +760,7 @@
<string name="d_request_approved">Requête approuvée</string>
<string name="d_request_rejected">Requête rejetée</string>
<string name="d_shortcut_hint">↑↓ sélectionner · ←→ mémoriser · %1$s approuver · %2$s rejeter</string>
<string name="d_shortcut_hint_scope">↑↓ sélectionner · ←→ mémoriser · S portée · %1$s approuver · %2$s rejeter</string>
<string name="d_shortcut_hint_connect">↑↓ sélectionner · ←→ supprimer après · 1–3 autorisations · A compte · %1$s approuver · %2$s rejeter</string>
<string name="d_remember_prefix">Mémoriser : %1$s</string>
<string name="d_qr_code">QR code</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">Permintaan disetujui</string>
<string name="d_request_rejected">Permintaan ditolak</string>
<string name="d_shortcut_hint">↑↓ pilih · ←→ ingat · %1$s setujui · %2$s tolak</string>
<string name="d_shortcut_hint_scope">↑↓ pilih · ←→ ingat · S cakupan · %1$s setujui · %2$s tolak</string>
<string name="d_shortcut_hint_connect">↑↓ pilih · ←→ hapus setelah · 1–3 izin · A akun · %1$s setujui · %2$s tolak</string>
<string name="d_remember_prefix">Ingat: %1$s</string>
<string name="d_qr_code">Kode QR</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">Richiesta approvata</string>
<string name="d_request_rejected">Richiesta rifiutata</string>
<string name="d_shortcut_hint">↑↓ seleziona · ←→ ricorda · %1$s approva · %2$s rifiuta</string>
<string name="d_shortcut_hint_scope">↑↓ seleziona · ←→ ricorda · S ambito · %1$s approva · %2$s rifiuta</string>
<string name="d_shortcut_hint_connect">↑↓ seleziona · ←→ elimina dopo · 1–3 permessi · A account · %1$s approva · %2$s rifiuta</string>
<string name="d_remember_prefix">Ricorda: %1$s</string>
<string name="d_qr_code">Codice QR</string>
@@ -739,6 +739,7 @@
<string name="d_request_approved">リクエストを承認しました</string>
<string name="d_request_rejected">リクエストを拒否しました</string>
<string name="d_shortcut_hint">↑↓ 選択 · ←→ 記憶 · %1$s 承認 · %2$s 拒否</string>
<string name="d_shortcut_hint_scope">↑↓ 選択 · ←→ 記憶 · S 範囲 · %1$s 承認 · %2$s 拒否</string>
<string name="d_shortcut_hint_connect">↑↓ 選択 · ←→ 削除までの期間 · 1–3 権限 · A アカウント · %1$s 承認 · %2$s 拒否</string>
<string name="d_remember_prefix">記憶: %1$s</string>
<string name="d_qr_code">QR コード</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">요청이 승인됨</string>
<string name="d_request_rejected">요청이 거부됨</string>
<string name="d_shortcut_hint">↑↓ 선택 · ←→ 기억 · %1$s 승인 · %2$s 거부</string>
<string name="d_shortcut_hint_scope">↑↓ 선택 · ←→ 기억 · S 범위 · %1$s 승인 · %2$s 거부</string>
<string name="d_shortcut_hint_connect">↑↓ 선택 · ←→ 삭제 시점 · 1–3 권한 · A 계정 · %1$s 승인 · %2$s 거부</string>
<string name="d_remember_prefix">기억: %1$s</string>
<string name="d_qr_code">QR 코드</string>
@@ -758,6 +758,7 @@
<string name="d_request_approved">Solicitação aprovada</string>
<string name="d_request_rejected">Solicitação rejeitada</string>
<string name="d_shortcut_hint">↑↓ selecionar · ←→ lembrar · %1$s aprovar · %2$s rejeitar</string>
<string name="d_shortcut_hint_scope">↑↓ selecionar · ←→ lembrar · S escopo · %1$s aprovar · %2$s rejeitar</string>
<string name="d_shortcut_hint_connect">↑↓ selecionar · ←→ excluir após · 1–3 permissões · A conta · %1$s aprovar · %2$s rejeitar</string>
<string name="d_remember_prefix">Lembrar: %1$s</string>
<string name="d_qr_code">Código QR</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">Запрос одобрен</string>
<string name="d_request_rejected">Запрос отклонён</string>
<string name="d_shortcut_hint">↑↓ выбрать · ←→ запомнить · %1$s одобрить · %2$s отклонить</string>
<string name="d_shortcut_hint_scope">↑↓ выбрать · ←→ запомнить · S область · %1$s одобрить · %2$s отклонить</string>
<string name="d_shortcut_hint_connect">↑↓ выбрать · ←→ удалить через · 1–3 разрешения · A аккаунт · %1$s одобрить · %2$s отклонить</string>
<string name="d_remember_prefix">Запомнить: %1$s</string>
<string name="d_qr_code">QR-код</string>
@@ -739,6 +739,7 @@
<string name="d_request_approved">อนุมัติคำขอแล้ว</string>
<string name="d_request_rejected">ปฏิเสธคำขอแล้ว</string>
<string name="d_shortcut_hint">↑↓ เลือก · ←→ จดจำ · %1$s อนุมัติ · %2$s ปฏิเสธ</string>
<string name="d_shortcut_hint_scope">↑↓ เลือก · ←→ จดจำ · S ขอบเขต · %1$s อนุมัติ · %2$s ปฏิเสธ</string>
<string name="d_shortcut_hint_connect">↑↓ เลือก · ←→ ลบหลังจาก · 1–3 สิทธิ์ · A บัญชี · %1$s อนุมัติ · %2$s ปฏิเสธ</string>
<string name="d_remember_prefix">จดจำ: %1$s</string>
<string name="d_qr_code">QR code</string>
@@ -759,6 +759,7 @@
<string name="d_request_approved">İstek onaylandı</string>
<string name="d_request_rejected">İstek reddedildi</string>
<string name="d_shortcut_hint">↑↓ seç · ←→ hatırla · %1$s onayla · %2$s reddet</string>
<string name="d_shortcut_hint_scope">↑↓ seç · ←→ hatırla · S kapsam · %1$s onayla · %2$s reddet</string>
<string name="d_shortcut_hint_connect">↑↓ seç · ←→ şu süre sonra sil · 1–3 izinler · A hesap · %1$s onayla · %2$s reddet</string>
<string name="d_remember_prefix">Hatırla: %1$s</string>
<string name="d_qr_code">QR kodu</string>
@@ -739,6 +739,7 @@
<string name="d_request_approved">Đã phê duyệt yêu cầu</string>
<string name="d_request_rejected">Đã từ chối yêu cầu</string>
<string name="d_shortcut_hint">↑↓ chọn · ←→ ghi nhớ · %1$s phê duyệt · %2$s từ chối</string>
<string name="d_shortcut_hint_scope">↑↓ chọn · ←→ ghi nhớ · S phạm vi · %1$s phê duyệt · %2$s từ chối</string>
<string name="d_shortcut_hint_connect">↑↓ chọn · ←→ xóa sau · 1–3 quyền · A tài khoản · %1$s phê duyệt · %2$s từ chối</string>
<string name="d_remember_prefix">Ghi nhớ: %1$s</string>
<string name="d_qr_code">Mã QR</string>
@@ -744,6 +744,7 @@
<string name="d_request_approved">请求已批准</string>
<string name="d_request_rejected">请求已拒绝</string>
<string name="d_shortcut_hint">↑↓ 选择 · ←→ 记住 · %1$s 批准 · %2$s 拒绝</string>
<string name="d_shortcut_hint_scope">↑↓ 选择 · ←→ 记住 · S 范围 · %1$s 批准 · %2$s 拒绝</string>
<string name="d_shortcut_hint_connect">↑↓ 选择 · ←→ 删除时间 · 1–3 权限 · A 账户 · %1$s 批准 · %2$s 拒绝</string>
<string name="d_remember_prefix">记住:%1$s</string>
<string name="d_qr_code">QR 码</string>
@@ -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<Int?>(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<Int?>(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
@@ -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(),
@@ -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"))
}
}
@@ -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