pakstr 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ class Nip55CallbackTest {
10
10
 
11
11
  @Test
12
12
  fun accepts_fragment_callback_and_preserves_logical_event() {
13
- val event = "{\"kind\":27235,\"content\":\"hello & goodbye / 100%\"}"
13
+ val event = "{\"id\":\"${"a".repeat(64)}\",\"pubkey\":\"${"b".repeat(64)}\",\"created_at\":1789552517,\"kind\":27235,\"tags\":[],\"content\":\"hello & goodbye / 100%\",\"sig\":\"${"c".repeat(128)}\"}"
14
14
  val intent = callbackIntent(
15
15
  "${Nip55Callback.PARAM}=${Uri.encode(event)}"
16
16
  )
@@ -10,6 +10,9 @@ internal object Nip55Callback {
10
10
  private const val FRAGMENT_PREFIX = "$PARAM="
11
11
  private const val MAX_RESULT_LENGTH = 128 * 1024
12
12
 
13
+ val callbackPrefix: String
14
+ get() = "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://$HOST#$FRAGMENT_PREFIX"
15
+
13
16
  fun result(intent: Intent?): String? {
14
17
  if (intent?.action != Intent.ACTION_VIEW) {
15
18
  return null
@@ -0,0 +1,303 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.crypto.NostrEventHasher
4
+ import com.pakstr.app.signer.SignerType
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import java.io.ByteArrayOutputStream
8
+ import java.nio.ByteBuffer
9
+ import java.nio.charset.CodingErrorAction
10
+
11
+ internal sealed interface Nip55SignEventRequest {
12
+ data object External : Nip55SignEventRequest
13
+
14
+ data class Rejected(
15
+ val reason: String
16
+ ) : Nip55SignEventRequest
17
+
18
+ data class Parsed(
19
+ val event: JSONObject,
20
+ val callbackUrl: String
21
+ ) : Nip55SignEventRequest
22
+ }
23
+
24
+ internal sealed interface Nip55NormalizedEvent {
25
+ data class Rejected(
26
+ val reason: String
27
+ ) : Nip55NormalizedEvent
28
+
29
+ data class Ready(
30
+ val eventJson: String
31
+ ) : Nip55NormalizedEvent
32
+ }
33
+
34
+ internal object Nip55SignEventParser {
35
+ private const val SCHEME_PREFIX = "nostrsigner:"
36
+ private const val MAX_REQUEST_BYTES = 128 * 1024
37
+ private val RELEVANT_PARAMETERS = setOf(
38
+ "type",
39
+ "returnType",
40
+ "compressionType",
41
+ "callbackUrl"
42
+ )
43
+ private val EVENT_FIELDS = setOf(
44
+ "kind",
45
+ "created_at",
46
+ "tags",
47
+ "content",
48
+ "pubkey",
49
+ "id",
50
+ "sig"
51
+ )
52
+
53
+ fun parse(
54
+ rawUrl: String,
55
+ expectedCallbackUrl: String
56
+ ): Nip55SignEventRequest {
57
+ if (rawUrl.toByteArray(Charsets.UTF_8).size > MAX_REQUEST_BYTES) {
58
+ return rejected("request too large")
59
+ }
60
+ if (!rawUrl.startsWith(SCHEME_PREFIX, ignoreCase = true)) {
61
+ return Nip55SignEventRequest.External
62
+ }
63
+
64
+ val schemeSpecificPart = rawUrl.substring(SCHEME_PREFIX.length)
65
+ if ('#' in schemeSpecificPart) {
66
+ return rejected("malformed request")
67
+ }
68
+ val separator = schemeSpecificPart.indexOf('?')
69
+ val encodedEvent = if (separator >= 0) {
70
+ schemeSpecificPart.substring(0, separator)
71
+ } else {
72
+ schemeSpecificPart
73
+ }
74
+ val rawQuery = if (separator >= 0) {
75
+ schemeSpecificPart.substring(separator + 1)
76
+ } else {
77
+ ""
78
+ }
79
+ if (encodedEvent.isEmpty()) {
80
+ return rejected("missing event")
81
+ }
82
+ if (!hasOpaquePayloadEncoding(encodedEvent)) {
83
+ return rejected("malformed event encoding")
84
+ }
85
+
86
+ val parameters = parseParameters(rawQuery)
87
+ ?: return rejected("malformed parameters")
88
+ val type = parameters["type"]
89
+ if (type != "sign_event") {
90
+ return Nip55SignEventRequest.External
91
+ }
92
+ if (parameters["returnType"] != "event") {
93
+ return rejected("unsupported return type")
94
+ }
95
+ val compressionType = parameters["compressionType"]
96
+ if (compressionType != null && compressionType != "none") {
97
+ return rejected("unsupported compression")
98
+ }
99
+ val callbackUrl = parameters["callbackUrl"]
100
+ ?: return rejected("missing callback")
101
+ if (callbackUrl != expectedCallbackUrl) {
102
+ return rejected("unexpected callback")
103
+ }
104
+
105
+ val eventText = percentDecode(encodedEvent)
106
+ ?: return rejected("malformed event encoding")
107
+ val event = try {
108
+ JSONObject(eventText)
109
+ } catch (_: Exception) {
110
+ return rejected("invalid event")
111
+ }
112
+ if (event.keys().asSequence().any { it !in EVENT_FIELDS }) {
113
+ return rejected("unsupported event field")
114
+ }
115
+ if (!hasIntegral(event, "kind") ||
116
+ event.getLong("kind") !in 0L..65535L ||
117
+ !hasIntegral(event, "created_at") ||
118
+ event.optJSONArray("tags") == null ||
119
+ !hasString(event, "content") ||
120
+ !hasValidTags(event.getJSONArray("tags"))
121
+ ) {
122
+ return rejected("invalid event fields")
123
+ }
124
+ if (event.has("sig") &&
125
+ (!hasString(event, "sig") || event.getString("sig").isNotEmpty())
126
+ ) {
127
+ return rejected("event already signed")
128
+ }
129
+ if (event.has("pubkey") && !hasString(event, "pubkey")) {
130
+ return rejected("invalid pubkey")
131
+ }
132
+ if (event.has("id") && !hasString(event, "id")) {
133
+ return rejected("invalid event id")
134
+ }
135
+
136
+ return Nip55SignEventRequest.Parsed(event, callbackUrl)
137
+ }
138
+
139
+ fun normalize(
140
+ request: Nip55SignEventRequest.Parsed,
141
+ activePubkey: String
142
+ ): Nip55NormalizedEvent {
143
+ if (activePubkey.isBlank()) {
144
+ return normalizedRejected("signer unavailable")
145
+ }
146
+ val event = JSONObject(request.event.toString())
147
+ if (event.has("pubkey") && event.getString("pubkey") != activePubkey) {
148
+ return normalizedRejected("pubkey mismatch")
149
+ }
150
+
151
+ if (event.has("id")) {
152
+ val expectedId = event.getString("id")
153
+ event.put("pubkey", activePubkey)
154
+ val actualId = try {
155
+ NostrEventHasher.hashEvent(event)
156
+ } catch (_: Exception) {
157
+ return normalizedRejected("invalid event id")
158
+ }
159
+ if (actualId != expectedId) {
160
+ return normalizedRejected("event id mismatch")
161
+ }
162
+ }
163
+
164
+ return Nip55NormalizedEvent.Ready(
165
+ JSONObject()
166
+ .put("kind", event.getInt("kind"))
167
+ .put("created_at", event.getLong("created_at"))
168
+ .put("tags", event.getJSONArray("tags"))
169
+ .put("content", event.getString("content"))
170
+ .toString()
171
+ )
172
+ }
173
+
174
+ private fun hasOpaquePayloadEncoding(value: String): Boolean {
175
+ var index = 0
176
+ while (index < value.length) {
177
+ if (value[index] == '%') {
178
+ if (index + 2 >= value.length ||
179
+ value[index + 1].digitToIntOrNull(16) == null ||
180
+ value[index + 2].digitToIntOrNull(16) == null
181
+ ) {
182
+ return false
183
+ }
184
+ index += 3
185
+ } else {
186
+ val character = value[index]
187
+ if (!character.isLetterOrDigit() && character !in "-._~") {
188
+ return false
189
+ }
190
+ index++
191
+ }
192
+ }
193
+ return true
194
+ }
195
+
196
+ private fun parseParameters(rawQuery: String): Map<String, String>? {
197
+ val parameters = mutableMapOf<String, String>()
198
+ if (rawQuery.isEmpty()) {
199
+ return parameters
200
+ }
201
+ for (part in rawQuery.split('&')) {
202
+ val separator = part.indexOf('=')
203
+ if (separator < 0) {
204
+ return null
205
+ }
206
+ val name = percentDecode(part.substring(0, separator)) ?: return null
207
+ val value = percentDecode(part.substring(separator + 1)) ?: return null
208
+ if (name in RELEVANT_PARAMETERS && parameters.containsKey(name)) {
209
+ return null
210
+ }
211
+ parameters[name] = value
212
+ }
213
+ return parameters
214
+ }
215
+
216
+ private fun percentDecode(value: String): String? {
217
+ val bytes = ByteArrayOutputStream(value.length)
218
+ var index = 0
219
+ while (index < value.length) {
220
+ val character = value[index]
221
+ if (character == '%') {
222
+ if (index + 2 >= value.length) return null
223
+ val high = value[index + 1].digitToIntOrNull(16) ?: return null
224
+ val low = value[index + 2].digitToIntOrNull(16) ?: return null
225
+ bytes.write((high shl 4) or low)
226
+ index += 3
227
+ } else {
228
+ val codePoint = value.codePointAt(index)
229
+ bytes.write(String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8))
230
+ index += Character.charCount(codePoint)
231
+ }
232
+ }
233
+ return try {
234
+ Charsets.UTF_8.newDecoder()
235
+ .onMalformedInput(CodingErrorAction.REPORT)
236
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
237
+ .decode(ByteBuffer.wrap(bytes.toByteArray()))
238
+ .toString()
239
+ } catch (_: Exception) {
240
+ null
241
+ }
242
+ }
243
+
244
+ private fun hasIntegral(event: JSONObject, name: String): Boolean {
245
+ if (!event.has(name) || event.isNull(name)) return false
246
+ return event.get(name) is Byte ||
247
+ event.get(name) is Short ||
248
+ event.get(name) is Int ||
249
+ event.get(name) is Long
250
+ }
251
+
252
+ private fun hasString(event: JSONObject, name: String): Boolean {
253
+ return event.has(name) && !event.isNull(name) && event.get(name) is String
254
+ }
255
+
256
+ private fun hasValidTags(tags: JSONArray): Boolean {
257
+ for (tagIndex in 0 until tags.length()) {
258
+ val tag = tags.optJSONArray(tagIndex) ?: return false
259
+ if (tag.length() == 0) return false
260
+ for (valueIndex in 0 until tag.length()) {
261
+ if (tag.get(valueIndex) !is String) return false
262
+ }
263
+ }
264
+ return true
265
+ }
266
+
267
+ private fun rejected(reason: String) = Nip55SignEventRequest.Rejected(reason)
268
+
269
+ private fun normalizedRejected(reason: String) = Nip55NormalizedEvent.Rejected(reason)
270
+ }
271
+
272
+ internal fun dispatchNip55SignEvent(
273
+ rawUrl: String,
274
+ activeSignerType: SignerType?,
275
+ expectedCallbackUrl: String,
276
+ forwardExternally: () -> Unit,
277
+ launchSigning: (suspend () -> Unit) -> Unit,
278
+ getActivePubkey: suspend () -> String,
279
+ signEvent: suspend (String) -> String,
280
+ deliverSignedEvent: suspend (String) -> Unit,
281
+ logRejection: (String) -> Unit
282
+ ) {
283
+ if (activeSignerType != SignerType.BUNKER) {
284
+ forwardExternally()
285
+ return
286
+ }
287
+
288
+ when (val request = Nip55SignEventParser.parse(rawUrl, expectedCallbackUrl)) {
289
+ Nip55SignEventRequest.External -> forwardExternally()
290
+ is Nip55SignEventRequest.Rejected -> logRejection(request.reason)
291
+ is Nip55SignEventRequest.Parsed -> launchSigning {
292
+ when (val normalized = Nip55SignEventParser.normalize(
293
+ request,
294
+ getActivePubkey()
295
+ )) {
296
+ is Nip55NormalizedEvent.Rejected -> logRejection(normalized.reason)
297
+ is Nip55NormalizedEvent.Ready -> deliverSignedEvent(
298
+ signEvent(normalized.eventJson)
299
+ )
300
+ }
301
+ }
302
+ }
303
+ }
@@ -6,6 +6,6 @@ class PakstrBridge {
6
6
 
7
7
  @JavascriptInterface
8
8
  fun getNip55CallbackUrl(): String {
9
- return "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://nip55-callback#nip55_event="
9
+ return Nip55Callback.callbackPrefix
10
10
  }
11
11
  }
@@ -17,6 +17,13 @@ import com.pakstr.app.debug.AppDebugLogger
17
17
  import com.pakstr.app.permissions.interfaces.PermissionHandler
18
18
  import com.pakstr.app.signer.NostrBridge
19
19
  import com.pakstr.app.signer.SignerManager
20
+ import kotlinx.coroutines.CancellationException
21
+ import kotlinx.coroutines.CoroutineScope
22
+ import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.SupervisorJob
24
+ import kotlinx.coroutines.cancel
25
+ import kotlinx.coroutines.launch
26
+ import kotlinx.coroutines.withContext
20
27
 
21
28
  /**
22
29
  * Controls WebView configuration and lifecycle.
@@ -44,6 +51,7 @@ class WebViewController(
44
51
  ) {
45
52
  private var pendingNip55Result: String? = null
46
53
  private var isSetup = false
54
+ private val nip55Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
47
55
 
48
56
  // Keeps a reference to the runnable to prevent memory leaks if destroyed early
49
57
  private val loadRunnable = Runnable {
@@ -228,22 +236,37 @@ class WebViewController(
228
236
  ): Boolean {
229
237
  val url = request?.url ?: return true
230
238
  if (url.scheme.equals("nostrsigner", ignoreCase = true)) {
231
- try {
232
- val intent = Intent(Intent.ACTION_VIEW, url).apply {
233
- putExtra(
234
- Browser.EXTRA_APPLICATION_ID,
235
- context.packageName
236
- )
237
- }
238
- context.startActivity(intent)
239
- } catch (error: ActivityNotFoundException) {
240
- AppDebugLogger.error(
241
- context,
242
- "WEBVIEW",
243
- "No app can handle nostrsigner URL",
244
- error
245
- )
246
- }
239
+ dispatchNip55SignEvent(
240
+ rawUrl = url.toString(),
241
+ activeSignerType = signerManager.getActiveType(),
242
+ expectedCallbackUrl = Nip55Callback.callbackPrefix,
243
+ forwardExternally = { launchExternalNip55(url) },
244
+ launchSigning = { operation ->
245
+ nip55Scope.launch {
246
+ try {
247
+ operation()
248
+ } catch (error: CancellationException) {
249
+ throw error
250
+ } catch (error: Exception) {
251
+ AppDebugLogger.log(
252
+ context,
253
+ "NIP55",
254
+ "Bunker flow failed: ${error::class.java.simpleName}"
255
+ )
256
+ }
257
+ }
258
+ },
259
+ getActivePubkey = signerManager::getCurrentUserPubkey,
260
+ signEvent = { event ->
261
+ signerManager.getSigner().signEvent(event)
262
+ },
263
+ deliverSignedEvent = { signedEvent ->
264
+ withContext(Dispatchers.Main) {
265
+ handleNip55Callback(signedEvent)
266
+ }
267
+ },
268
+ logRejection = ::logNip55Rejection
269
+ )
247
270
  return true
248
271
  }
249
272
  return url.host != "127.0.0.1"
@@ -355,7 +378,34 @@ class WebViewController(
355
378
  (context as? Activity)?.findViewById<View>(R.id.loader)?.visibility = View.GONE
356
379
  }
357
380
 
381
+ private fun launchExternalNip55(url: android.net.Uri) {
382
+ try {
383
+ val intent = Intent(Intent.ACTION_VIEW, url).apply {
384
+ putExtra(
385
+ Browser.EXTRA_APPLICATION_ID,
386
+ context.packageName
387
+ )
388
+ }
389
+ context.startActivity(intent)
390
+ } catch (_: ActivityNotFoundException) {
391
+ AppDebugLogger.error(
392
+ context,
393
+ "WEBVIEW",
394
+ "No app can handle nostrsigner URL"
395
+ )
396
+ }
397
+ }
398
+
399
+ private fun logNip55Rejection(reason: String) {
400
+ AppDebugLogger.log(
401
+ context,
402
+ "NIP55",
403
+ "Request rejected: $reason"
404
+ )
405
+ }
406
+
358
407
  fun destroy() {
408
+ nip55Scope.cancel()
359
409
  webView.stopLoading()
360
410
 
361
411
  webView.clearHistory()
@@ -0,0 +1,115 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.SignerType
4
+ import kotlinx.coroutines.runBlocking
5
+ import org.json.JSONArray
6
+ import org.json.JSONObject
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class Nip55SignEventDispatchTest {
13
+ private val callback = "pakstr-test://nip55-callback#nip55_event="
14
+ private val activePubkey = "1".repeat(64)
15
+
16
+ @Test
17
+ fun bunker_valid_sign_event_signs_internally_without_external_intent() {
18
+ val result = dispatch(SignerType.BUNKER, validRequest())
19
+
20
+ assertTrue(result.signed)
21
+ assertFalse(result.forwarded)
22
+ assertEquals(SIGNED_EVENT, result.delivered)
23
+ }
24
+
25
+ @Test
26
+ fun bunker_invalid_sign_event_is_consumed_without_external_intent() {
27
+ val result = dispatch(
28
+ SignerType.BUNKER,
29
+ validRequest().replace("returnType=event", "returnType=signature")
30
+ )
31
+
32
+ assertFalse(result.signed)
33
+ assertFalse(result.forwarded)
34
+ assertTrue(result.rejection != null)
35
+ }
36
+
37
+ @Test
38
+ fun bunker_other_operation_is_forwarded_externally() {
39
+ val result = dispatch(
40
+ SignerType.BUNKER,
41
+ validRequest().replace("type=sign_event", "type=get_public_key")
42
+ )
43
+
44
+ assertFalse(result.signed)
45
+ assertTrue(result.forwarded)
46
+ }
47
+
48
+ @Test
49
+ fun amber_sign_event_is_forwarded_externally() {
50
+ val result = dispatch(SignerType.AMBER, validRequest())
51
+
52
+ assertFalse(result.signed)
53
+ assertTrue(result.forwarded)
54
+ }
55
+
56
+ private fun dispatch(type: SignerType, rawUrl: String): Result {
57
+ var forwarded = false
58
+ var signed = false
59
+ var delivered: String? = null
60
+ var rejection: String? = null
61
+
62
+ dispatchNip55SignEvent(
63
+ rawUrl = rawUrl,
64
+ activeSignerType = type,
65
+ expectedCallbackUrl = callback,
66
+ forwardExternally = { forwarded = true },
67
+ launchSigning = { operation -> runBlocking { operation() } },
68
+ getActivePubkey = { activePubkey },
69
+ signEvent = {
70
+ signed = true
71
+ SIGNED_EVENT
72
+ },
73
+ deliverSignedEvent = { delivered = it },
74
+ logRejection = { rejection = it }
75
+ )
76
+
77
+ return Result(forwarded, signed, delivered, rejection)
78
+ }
79
+
80
+ private fun validRequest(): String {
81
+ val event = JSONObject()
82
+ .put("kind", 27235)
83
+ .put("created_at", 1789552517L)
84
+ .put("tags", JSONArray())
85
+ .put("content", "")
86
+ .toString()
87
+ return "nostrsigner:${encode(event)}" +
88
+ "?type=sign_event&returnType=event&compressionType=none" +
89
+ "&callbackUrl=${encode(callback)}"
90
+ }
91
+
92
+ private fun encode(value: String): String {
93
+ return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
94
+ val valueInt = byte.toInt() and 0xff
95
+ val character = valueInt.toChar()
96
+ if (character.isLetterOrDigit() || character in "-._~") {
97
+ character.toString()
98
+ } else {
99
+ "%${valueInt.toString(16).uppercase().padStart(2, '0')}"
100
+ }
101
+ }
102
+ }
103
+
104
+ private data class Result(
105
+ val forwarded: Boolean,
106
+ val signed: Boolean,
107
+ val delivered: String?,
108
+ val rejection: String?
109
+ )
110
+
111
+ private companion object {
112
+ const val SIGNED_EVENT =
113
+ "{\"id\":\"signed-id\",\"pubkey\":\"signed-pubkey\",\"sig\":\"signed-sig\"}"
114
+ }
115
+ }
@@ -0,0 +1,90 @@
1
+ package com.pakstr.app
2
+
3
+ import org.json.JSONObject
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class Nip55SignEventRequestTest {
9
+ private val callback =
10
+ "pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c://nip55-callback#nip55_event="
11
+
12
+ @Test
13
+ fun parses_exact_regress_request() {
14
+ val rawUrl =
15
+ "nostrsigner:%7B%22kind%22%3A27235%2C%22created_at%22%3A1789552517%2C%22content%22%3A%22%22%2C%22tags%22%3A%5B%5B%22u%22%2C%22http%3A%2F%2F127.0.0.1%3A8080%2Fapi%2Fauth%2Flogin%22%5D%2C%5B%22method%22%2C%22POST%22%5D%5D%7D?compressionType=none&returnType=event&type=sign_event&callbackUrl=pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c%3A%2F%2Fnip55-callback%23nip55_event%3D"
16
+
17
+ val parsed = Nip55SignEventParser.parse(rawUrl, callback)
18
+
19
+ assertTrue(parsed is Nip55SignEventRequest.Parsed)
20
+ parsed as Nip55SignEventRequest.Parsed
21
+ assertEquals(callback, parsed.callbackUrl)
22
+ assertEquals(27235, parsed.event.getInt("kind"))
23
+ assertEquals(1789552517L, parsed.event.getLong("created_at"))
24
+ assertEquals("", parsed.event.getString("content"))
25
+ assertEquals(
26
+ "http://127.0.0.1:8080/api/auth/login",
27
+ parsed.event.getJSONArray("tags").getJSONArray(0).getString(1)
28
+ )
29
+ }
30
+
31
+ @Test
32
+ fun accepts_event_return_with_none_or_omitted_compression() {
33
+ val base = requestQuery("type=sign_event&returnType=event&callbackUrl=${encode(callback)}")
34
+ val withNone = requestQuery(
35
+ "type=sign_event&returnType=event&compressionType=none&callbackUrl=${encode(callback)}"
36
+ )
37
+
38
+ assertTrue(Nip55SignEventParser.parse(base, callback) is Nip55SignEventRequest.Parsed)
39
+ assertTrue(Nip55SignEventParser.parse(withNone, callback) is Nip55SignEventRequest.Parsed)
40
+ }
41
+
42
+ @Test
43
+ fun rejects_malformed_or_unsupported_sign_event() {
44
+ val unsupported = requestQuery(
45
+ "type=sign_event&returnType=event&compressionType=gzip&callbackUrl=${encode(callback)}"
46
+ )
47
+ val malformed =
48
+ "nostrsigner:%7Bbad?type=sign_event&returnType=event&callbackUrl=${encode(callback)}"
49
+
50
+ assertTrue(
51
+ Nip55SignEventParser.parse(unsupported, callback) is Nip55SignEventRequest.Rejected
52
+ )
53
+ assertTrue(
54
+ Nip55SignEventParser.parse(malformed, callback) is Nip55SignEventRequest.Rejected
55
+ )
56
+ }
57
+
58
+ @Test
59
+ fun rejects_foreign_callback() {
60
+ val rawUrl = requestQuery(
61
+ "type=sign_event&returnType=event&callbackUrl=${encode("other://nip55-callback#nip55_event=")}"
62
+ )
63
+
64
+ assertTrue(
65
+ Nip55SignEventParser.parse(rawUrl, callback) is Nip55SignEventRequest.Rejected
66
+ )
67
+ }
68
+
69
+ private fun requestQuery(query: String): String {
70
+ val event = JSONObject()
71
+ .put("kind", 27235)
72
+ .put("created_at", 1789552517L)
73
+ .put("tags", org.json.JSONArray())
74
+ .put("content", "")
75
+ .toString()
76
+ return "nostrsigner:${encode(event)}?$query"
77
+ }
78
+
79
+ private fun encode(value: String): String {
80
+ return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
81
+ val valueInt = byte.toInt() and 0xff
82
+ val character = valueInt.toChar()
83
+ if (character.isLetterOrDigit() || character in "-._~") {
84
+ character.toString()
85
+ } else {
86
+ "%${valueInt.toString(16).uppercase().padStart(2, '0')}"
87
+ }
88
+ }
89
+ }
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",