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() val request = pending.last()
var notified = false var notified = false
if (settings.showNotifications) { 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 // Prefer the OS-native notification channel on Linux
// (freedesktop / notify-send, incl. Wayland/Hyprland). On // (freedesktop / notify-send, incl. Wayland/Hyprland). On
// Windows and macOS the AWT tray notification is the native // Windows and macOS the AWT tray notification is the native
@@ -69,6 +69,10 @@ data class PendingBunkerRequest(
val encryptionType: EncryptionType = EncryptionType.NIP44, val encryptionType: EncryptionType = EncryptionType.NIP44,
val isNostrConnectUri: Boolean = false, val isNostrConnectUri: Boolean = false,
val signerPrivKey: String = "", 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 * 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 * to keep it until answered. NIP-46 gives no signal when a client stops
@@ -85,6 +89,12 @@ data class PendingBunkerRequest(
*/ */
val canSwitchAccount: Boolean val canSwitchAccount: Boolean
get() = type == SignerType.CONNECT && (isNostrConnectUri || signerPrivKey.isNotEmpty()) 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 return
} }
val kind = if (bunkerRequest is BunkerRequestSign) bunkerRequest.event.kind else null // NIP-44 v3 carries its context kind/scope at params[1..2].
val signPolicy = app?.app?.signPolicy val kind = when {
val permissionType = if (type == SignerType.SIGN_EVENT) { bunkerRequest is BunkerRequestSign -> bunkerRequest.event.kind
store.getPermission(event.pubKey, type.toString(), kind) type in nip44v3SignerTypes -> bunkerRequest.params.getOrNull(1)?.toIntOrNull()
} else { else -> null
store.getPermission(event.pubKey, type.toString())
} }
// A first-time `connect` always goes through the approval UI: approving val signPolicy = app?.app?.signPolicy
// 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)
// Compute the response payload (also serves as the approval preview). // Compute the response payload (also serves as the approval preview).
val computed = try { val computed = try {
@@ -500,17 +506,49 @@ class BunkerEngine(
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
store.addLog(relay.url, "bunker", "Rejecting request that cannot be fulfilled: ${e.message}") 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( sendResponse(
acc, acc,
effectivePrivKey, effectivePrivKey,
event.pubKey, event.pubKey,
encryptionType, encryptionType,
BunkerResponse(bunkerRequest.id, "", "could not process the request"), BunkerResponse(bunkerRequest.id, "", error),
relays, relays,
) )
return 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) { when (remembered) {
true -> { true -> {
store.addHistory(HistoryRecord(event.pubKey, type.toString(), kind, TimeUtils.now(), true)) store.addHistory(HistoryRecord(event.pubKey, type.toString(), kind, TimeUtils.now(), true))
@@ -559,6 +597,8 @@ class BunkerEngine(
result = computed.result, result = computed.result,
encryptionType = encryptionType, encryptionType = encryptionType,
signerPrivKey = effectivePrivKey, 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), expiresAt = minOf(TimeUtils.now() + PENDING_TTL_SECONDS, event.expiration() ?: Long.MAX_VALUE),
), ),
) )
@@ -694,7 +734,8 @@ class BunkerEngine(
signPolicy: Int? = null, signPolicy: Int? = null,
deleteAfter: Long = 0L, deleteAfter: Long = 0L,
accountNpub: String? = null, 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( private suspend fun doApprove(
req: PendingBunkerRequest, req: PendingBunkerRequest,
@@ -703,6 +744,7 @@ class BunkerEngine(
signPolicy: Int? = null, signPolicy: Int? = null,
deleteAfter: Long = 0L, deleteAfter: Long = 0L,
accountNpub: String? = null, accountNpub: String? = null,
encryptionScope: EncryptionScope = EncryptionScope.ALL,
) { ) {
PassphraseLock.touch() PassphraseLock.touch()
removePending(req.request.id) removePending(req.request.id)
@@ -786,7 +828,7 @@ class BunkerEngine(
) )
} }
} else if (rememberType != RememberType.NEVER) { } 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) store.upsert(application)
@@ -820,11 +862,13 @@ class BunkerEngine(
fun reject( fun reject(
req: PendingBunkerRequest, req: PendingBunkerRequest,
rememberType: RememberType, rememberType: RememberType,
): Job = scope.launch { doReject(req, rememberType) } encryptionScope: EncryptionScope = EncryptionScope.ALL,
): Job = scope.launch { doReject(req, rememberType, encryptionScope) }
private suspend fun doReject( private suspend fun doReject(
req: PendingBunkerRequest, req: PendingBunkerRequest,
rememberType: RememberType, rememberType: RememberType,
encryptionScope: EncryptionScope = EncryptionScope.ALL,
) { ) {
PassphraseLock.touch() PassphraseLock.touch()
removePending(req.request.id) removePending(req.request.id)
@@ -857,7 +901,7 @@ class BunkerEngine(
) )
if (rememberType != RememberType.NEVER) { 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) { 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( private fun acceptOrRejectPermission(
application: AppWithPermissions, application: AppWithPermissions,
type: SignerType, type: SignerType,
kind: Int?, kind: Int?,
accepted: Boolean, accepted: Boolean,
rememberType: RememberType, rememberType: RememberType,
content: EncryptedContent? = null,
scope: EncryptionScope = EncryptionScope.ALL,
) { ) {
val until = rememberType.acceptUntil() 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) { if (kind != null) {
application.permissions.removeIf { it.kind == kind && it.type == typeStr && it.relay.isEmpty() } application.permissions.removeIf { it.kind == kind && it.type == typeStr && it.relay.isEmpty() }
} else { } else {
application.permissions.removeIf { it.type == typeStr && it.type != "SIGN_EVENT" } 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( application.permissions.add(
@@ -1146,11 +1205,11 @@ class BunkerEngine(
/** /**
* Maps a requested permission string to the stored permission type(s) * Maps a requested permission string to the stored permission type(s)
* the request path actually queries. Content-scoped or generic * the request path queries. Amber's content-type perms
* encrypt/decrypt perms (Amber uses `encrypt_clear_text`, * (`encrypt_clear_text`, `decrypt_event`, `encrypt_tag_array`, …) are
* `encrypt_event`, `encrypt_tag_array`; standard NIP-46 uses * stored as-is (ENCRYPT_CLEAR_TEXT, …) and checked first, like Android;
* `nip04_encrypt`/`nip44_encrypt`) grant both NIP variants because the * standard NIP-46 `nip04_encrypt`/`nip44_decrypt` are whole-NIP grants,
* desktop request path is keyed by NIP, not by content type. * and a generic `encrypt`/`decrypt` grants both NIPs.
*/ */
fun expandPermissionTypes(type: String): List<String> = when (type.lowercase()) { fun expandPermissionTypes(type: String): List<String> = when (type.lowercase()) {
"connect" -> listOf(SignerType.CONNECT.toString()) "connect" -> listOf(SignerType.CONNECT.toString())
@@ -1162,10 +1221,11 @@ class BunkerEngine(
"nip44_decrypt" -> listOf(SignerType.NIP44_DECRYPT.toString()) "nip44_decrypt" -> listOf(SignerType.NIP44_DECRYPT.toString())
"nip44v3_encrypt" -> listOf(SignerType.NIP44_V3_ENCRYPT.toString()) "nip44v3_encrypt" -> listOf(SignerType.NIP44_V3_ENCRYPT.toString())
"nip44v3_decrypt" -> listOf(SignerType.NIP44_V3_DECRYPT.toString()) "nip44v3_decrypt" -> listOf(SignerType.NIP44_V3_DECRYPT.toString())
"encrypt_clear_text", "encrypt_event", "encrypt_tag_array", "encrypt" -> "encrypt_clear_text", "encrypt_event", "encrypt_tag_array",
listOf(SignerType.NIP04_ENCRYPT.toString(), SignerType.NIP44_ENCRYPT.toString()) "decrypt_clear_text", "decrypt_event", "decrypt_tag_array",
"decrypt_clear_text", "decrypt_event", "decrypt_tag_array", "decrypt" -> -> listOf(type.uppercase())
listOf(SignerType.NIP04_DECRYPT.toString(), SignerType.NIP44_DECRYPT.toString()) "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()) "decrypt_zap_event" -> listOf(SignerType.DECRYPT_ZAP_EVENT.toString())
"ping" -> listOf(SignerType.PING.toString()) "ping" -> listOf(SignerType.PING.toString())
"sign_psbt" -> listOf(SignerType.SIGN_PSBT.toString()) "sign_psbt" -> listOf(SignerType.SIGN_PSBT.toString())
@@ -1,5 +1,6 @@
package com.greenart7c3.nostrsigner.desktop.core 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.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -103,10 +104,12 @@ data class RequestedPermission(
val basicPermissions = listOf( val basicPermissions = listOf(
RequestedPermission("get_public_key", null), RequestedPermission("get_public_key", null),
RequestedPermission("nip04_encrypt", null), RequestedPermission("encrypt_clear_text", null),
RequestedPermission("nip04_decrypt", null), RequestedPermission("decrypt_clear_text", null),
RequestedPermission("nip44_encrypt", null), RequestedPermission("encrypt_event", null),
RequestedPermission("nip44_decrypt", null), RequestedPermission("decrypt_event", null),
RequestedPermission("encrypt_tag_array", null),
RequestedPermission("decrypt_tag_array", null),
RequestedPermission("decrypt_zap_event", null), RequestedPermission("decrypt_zap_event", null),
RequestedPermission("sign_event", 0), RequestedPermission("sign_event", 0),
RequestedPermission("sign_event", 1), RequestedPermission("sign_event", 1),
@@ -128,6 +131,78 @@ val basicPermissions = listOf(
RequestedPermission("sign_event", 30023), 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` * Persisted connection record. Mirrors the Android `ApplicationEntity`
* column-for-column so behavior (and future import/export) matches. * 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.LOGOUT -> Strings.get("logout", language)
SignerType.INVALID -> Strings.get("invalid_request", language) SignerType.INVALID -> Strings.get("invalid_request", language)
SignerType.PING -> Strings.get("ping", 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)}" 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) val description = SignerDescriptions.permission(type, kind, language)
return when (type.trim().lowercase()) { return when (type.trim().lowercase()) {
"sign_event", "nip" -> Strings.format("sign", description, language = language) "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 else -> description
} }
} }
@@ -22,6 +22,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.AccountsStore
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount 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.PendingBunkerRequest
import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.RememberType
import com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions import com.greenart7c3.nostrsigner.desktop.core.SignerDescriptions
import com.greenart7c3.nostrsigner.desktop.core.SignerType import com.greenart7c3.nostrsigner.desktop.core.SignerType
import com.greenart7c3.nostrsigner.desktop.core.Strings 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.describe
import com.greenart7c3.nostrsigner.desktop.core.nip44v3SignerTypes
import com.greenart7c3.nostrsigner.desktop.core.toShortenHex import com.greenart7c3.nostrsigner.desktop.core.toShortenHex
@Composable @Composable
@@ -124,7 +128,7 @@ private fun RequestCard(
Text(req.appUrl, style = MaterialTheme.typography.bodySmall) Text(req.appUrl, style = MaterialTheme.typography.bodySmall)
} }
Spacer(Modifier.height(4.dp)) 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)) Spacer(Modifier.height(4.dp))
// The account that signs this request; a connect can be moved to // The account that signs this request; a connect can be moved to
// another account, like the Android connect screen's picker. // another account, like the Android connect screen's picker.
@@ -185,6 +189,41 @@ private fun RequestCard(
} }
Spacer(Modifier.height(12.dp)) 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) { if (req.type != SignerType.CONNECT) {
RememberTypeSelector(rememberType) { UiState.setRememberChoice(req.request.id, it) } RememberTypeSelector(rememberType) { UiState.setRememberChoice(req.request.id, it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -210,14 +249,18 @@ private fun RequestCard(
text = Strings.get("reject", language), text = Strings.get("reject", language),
onClick = { onClick = {
working = true working = true
AmberDesktop.engine.reject(req, rememberType) UiState.reject(req)
Toaster.toast(Strings.get("d_request_rejected", language)) Toaster.toast(Strings.get("d_request_rejected", language))
}, },
) )
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Text( Text(
Strings.format( 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("↵"),
shortcutLabel("↵", shift = true), shortcutLabel("↵", shift = true),
language = language, 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.AccountsStore
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.DeleteAfterType 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.PassphraseLock
import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest
import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.RememberType
import com.greenart7c3.nostrsigner.desktop.core.RequestedPermission import com.greenart7c3.nostrsigner.desktop.core.RequestedPermission
import com.greenart7c3.nostrsigner.desktop.core.SignerType import com.greenart7c3.nostrsigner.desktop.core.SignerType
import com.greenart7c3.nostrsigner.desktop.core.Strings 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 com.greenart7c3.nostrsigner.desktop.core.rememberTypeDisplayOrder
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -45,6 +48,20 @@ object UiState {
/** Per-request "Remember" choice, shared by the dropdown and the shortcuts. */ /** Per-request "Remember" choice, shared by the dropdown and the shortcuts. */
val rememberChoices = MutableStateFlow<Map<String, RememberType>>(emptyMap()) 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 * Per-connect-request choices (sign policy, granted permissions, delete
* after), shared by the request card and the approve shortcut. * after), shared by the request card and the approve shortcut.
@@ -74,10 +91,19 @@ object UiState {
accountNpub = choice.accountNpub, accountNpub = choice.accountNpub,
) )
} else { } 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) { fun navigate(route: Route) {
selectedApplication.value = null selectedApplication.value = null
currentRoute.value = route currentRoute.value = route
@@ -127,6 +153,17 @@ object UiState {
return true 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. */ /** A on a selected connect request: cycles the account it will be saved under. */
fun cycleSelectedAccount(): Boolean { fun cycleSelectedAccount(): Boolean {
val request = selectedRequest()?.takeIf { it.canSwitchAccount } ?: return false val request = selectedRequest()?.takeIf { it.canSwitchAccount } ?: return false
@@ -150,6 +187,9 @@ object UiState {
if (connectChoices.value.keys.any { it !in ids }) { if (connectChoices.value.keys.any { it !in ids }) {
connectChoices.value = connectChoices.value.filterKeys { 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) * (on a connect request: its "Delete after" choice)
* - 1/2/3 (connect request): basic / manual / full-trust sign policy * - 1/2/3 (connect request): basic / manual / full-trust sign policy
* - A (connect request): cycle the account the connection is saved under * - 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/⌘ Enter: approve the selected request with the chosen duration
* - Ctrl/⌘ Shift Enter: reject the selected request * - Ctrl/⌘ Shift Enter: reject the selected request
* - Ctrl/⌘ L: lock (when a passphrase is set) * - Ctrl/⌘ L: lock (when a passphrase is set)
@@ -231,6 +273,8 @@ fun handleShortcut(
Key.Three -> return UiState.setSelectedSignPolicy(2) Key.Three -> return UiState.setSelectedSignPolicy(2)
Key.A -> return UiState.cycleSelectedAccount() Key.A -> return UiState.cycleSelectedAccount()
Key.S -> return UiState.toggleSelectedScope()
} }
} }
return false return false
@@ -245,9 +289,8 @@ fun handleShortcut(
Key.Enter -> { Key.Enter -> {
if (!loggedIn) return false if (!loggedIn) return false
val request = UiState.selectedRequest() ?: return false val request = UiState.selectedRequest() ?: return false
val rememberType = UiState.rememberChoiceFor(request.request.id)
if (event.isShiftPressed) { if (event.isShiftPressed) {
AmberDesktop.engine.reject(request, rememberType) UiState.reject(request)
Toaster.toast(Strings.get("d_request_rejected")) Toaster.toast(Strings.get("d_request_rejected"))
} else { } else {
UiState.approve(request) UiState.approve(request)
@@ -760,6 +760,7 @@
<string name="d_request_approved">Anfrage genehmigt</string> <string name="d_request_approved">Anfrage genehmigt</string>
<string name="d_request_rejected">Anfrage abgelehnt</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">↑↓ 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_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_remember_prefix">Merken: %1$s</string>
<string name="d_qr_code">QR-Code</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_approved">Request approved</string>
<string name="d_request_rejected">Request rejected</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">↑↓ 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_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_remember_prefix">Remember: %1$s</string>
<string name="d_qr_code">QR code</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_approved">Solicitud aprobada</string>
<string name="d_request_rejected">Solicitud rechazada</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">↑↓ 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_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_remember_prefix">Recordar: %1$s</string>
<string name="d_qr_code">Código QR</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_approved">Requête approuvée</string>
<string name="d_request_rejected">Requête rejeté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">↑↓ 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_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_remember_prefix">Mémoriser : %1$s</string>
<string name="d_qr_code">QR code</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_approved">Permintaan disetujui</string>
<string name="d_request_rejected">Permintaan ditolak</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">↑↓ 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_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_remember_prefix">Ingat: %1$s</string>
<string name="d_qr_code">Kode QR</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_approved">Richiesta approvata</string>
<string name="d_request_rejected">Richiesta rifiutata</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">↑↓ 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_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_remember_prefix">Ricorda: %1$s</string>
<string name="d_qr_code">Codice QR</string> <string name="d_qr_code">Codice QR</string>
@@ -739,6 +739,7 @@
<string name="d_request_approved">リクエストを承認しました</string> <string name="d_request_approved">リクエストを承認しました</string>
<string name="d_request_rejected">リクエストを拒否しました</string> <string name="d_request_rejected">リクエストを拒否しました</string>
<string name="d_shortcut_hint">↑↓ 選択 · ←→ 記憶 · %1$s 承認 · %2$s 拒否</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_shortcut_hint_connect">↑↓ 選択 · ←→ 削除までの期間 · 1–3 権限 · A アカウント · %1$s 承認 · %2$s 拒否</string>
<string name="d_remember_prefix">記憶: %1$s</string> <string name="d_remember_prefix">記憶: %1$s</string>
<string name="d_qr_code">QR コード</string> <string name="d_qr_code">QR コード</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">요청이 승인됨</string> <string name="d_request_approved">요청이 승인됨</string>
<string name="d_request_rejected">요청이 거부됨</string> <string name="d_request_rejected">요청이 거부됨</string>
<string name="d_shortcut_hint">↑↓ 선택 · ←→ 기억 · %1$s 승인 · %2$s 거부</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_shortcut_hint_connect">↑↓ 선택 · ←→ 삭제 시점 · 1–3 권한 · A 계정 · %1$s 승인 · %2$s 거부</string>
<string name="d_remember_prefix">기억: %1$s</string> <string name="d_remember_prefix">기억: %1$s</string>
<string name="d_qr_code">QR 코드</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_approved">Solicitação aprovada</string>
<string name="d_request_rejected">Solicitação rejeitada</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">↑↓ 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_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_remember_prefix">Lembrar: %1$s</string>
<string name="d_qr_code">Código QR</string> <string name="d_qr_code">Código QR</string>
@@ -763,6 +763,7 @@
<string name="d_request_approved">Запрос одобрен</string> <string name="d_request_approved">Запрос одобрен</string>
<string name="d_request_rejected">Запрос отклонён</string> <string name="d_request_rejected">Запрос отклонён</string>
<string name="d_shortcut_hint">↑↓ выбрать · ←→ запомнить · %1$s одобрить · %2$s отклонить</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_shortcut_hint_connect">↑↓ выбрать · ←→ удалить через · 1–3 разрешения · A аккаунт · %1$s одобрить · %2$s отклонить</string>
<string name="d_remember_prefix">Запомнить: %1$s</string> <string name="d_remember_prefix">Запомнить: %1$s</string>
<string name="d_qr_code">QR-код</string> <string name="d_qr_code">QR-код</string>
@@ -739,6 +739,7 @@
<string name="d_request_approved">อนุมัติคำขอแล้ว</string> <string name="d_request_approved">อนุมัติคำขอแล้ว</string>
<string name="d_request_rejected">ปฏิเสธคำขอแล้ว</string> <string name="d_request_rejected">ปฏิเสธคำขอแล้ว</string>
<string name="d_shortcut_hint">↑↓ เลือก · ←→ จดจำ · %1$s อนุมัติ · %2$s ปฏิเสธ</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_shortcut_hint_connect">↑↓ เลือก · ←→ ลบหลังจาก · 1–3 สิทธิ์ · A บัญชี · %1$s อนุมัติ · %2$s ปฏิเสธ</string>
<string name="d_remember_prefix">จดจำ: %1$s</string> <string name="d_remember_prefix">จดจำ: %1$s</string>
<string name="d_qr_code">QR code</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_approved">İstek onaylandı</string>
<string name="d_request_rejected">İstek reddedildi</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">↑↓ 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_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_remember_prefix">Hatırla: %1$s</string>
<string name="d_qr_code">QR kodu</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_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_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">↑↓ 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_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_remember_prefix">Ghi nhớ: %1$s</string>
<string name="d_qr_code">Mã QR</string> <string name="d_qr_code">Mã QR</string>
@@ -744,6 +744,7 @@
<string name="d_request_approved">请求已批准</string> <string name="d_request_approved">请求已批准</string>
<string name="d_request_rejected">请求已拒绝</string> <string name="d_request_rejected">请求已拒绝</string>
<string name="d_shortcut_hint">↑↓ 选择 · ←→ 记住 · %1$s 批准 · %2$s 拒绝</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_shortcut_hint_connect">↑↓ 选择 · ←→ 删除时间 · 1–3 权限 · A 账户 · %1$s 批准 · %2$s 拒绝</string>
<string name="d_remember_prefix">记住:%1$s</string> <string name="d_remember_prefix">记住:%1$s</string>
<string name="d_qr_code">QR 码</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.AccountManager
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop 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.RememberType
import com.greenart7c3.nostrsigner.desktop.core.SettingsStore import com.greenart7c3.nostrsigner.desktop.core.SettingsStore
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -316,6 +318,140 @@ class BunkerE2eTest {
client.stop() 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, * 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 * 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("SIGN_EVENT"), BunkerEngine.expandPermissionTypes("sign_event"))
assertEquals(listOf("GET_PUBLIC_KEY"), BunkerEngine.expandPermissionTypes("get_public_key")) assertEquals(listOf("GET_PUBLIC_KEY"), BunkerEngine.expandPermissionTypes("get_public_key"))
// Content-scoped / generic encrypt-decrypt perms are NIP-agnostic, so a // Amber's content-type perms are stored as-is, like Android, and the
// grant must cover both NIP variants the request path can produce — // request path checks them before the whole-NIP grant.
// otherwise the grant is stored under a key never queried and the assertEquals(listOf("ENCRYPT_EVENT"), BunkerEngine.expandPermissionTypes("encrypt_event"))
// client is re-prompted every time. assertEquals(listOf("DECRYPT_CLEAR_TEXT"), BunkerEngine.expandPermissionTypes("decrypt_clear_text"))
assertEquals( assertEquals(listOf("ENCRYPT_TAG_ARRAY"), BunkerEngine.expandPermissionTypes("encrypt_tag_array"))
setOf("NIP04_ENCRYPT", "NIP44_ENCRYPT"),
BunkerEngine.expandPermissionTypes("encrypt_event").toSet(), // A generic encrypt/decrypt perm grants both NIPs.
)
assertEquals(
setOf("NIP04_DECRYPT", "NIP44_DECRYPT"),
BunkerEngine.expandPermissionTypes("decrypt_clear_text").toSet(),
)
assertEquals( assertEquals(
setOf("NIP04_ENCRYPT", "NIP44_ENCRYPT"), setOf("NIP04_ENCRYPT", "NIP44_ENCRYPT"),
BunkerEngine.expandPermissionTypes("encrypt").toSet(), 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 package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop 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.PendingBunkerRequest
import com.greenart7c3.nostrsigner.desktop.core.RememberType import com.greenart7c3.nostrsigner.desktop.core.RememberType
import com.greenart7c3.nostrsigner.desktop.core.SignerType import com.greenart7c3.nostrsigner.desktop.core.SignerType
@@ -9,7 +10,9 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import java.io.File import java.io.File
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.BeforeClass import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
@@ -34,6 +37,43 @@ class UiStateTest {
AmberDesktop.engine.pending.value = emptyList() AmberDesktop.engine.pending.value = emptyList()
UiState.selectedRequestId.value = null UiState.selectedRequestId.value = null
UiState.rememberChoices.value = emptyMap() 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 @Test