mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
65e8a13824
commit
4796deecec
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
+3
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user