pakstr 0.31.0 → 1.0.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.
@@ -1,514 +1,90 @@
1
1
  package com.pakstr.app.signer
2
2
 
3
- import android.os.Handler
4
- import android.os.Looper
5
- import android.webkit.JavascriptInterface
6
- import android.webkit.WebView
7
-
8
- import com.pakstr.app.debug.AppDebugLogger
9
3
  import com.pakstr.app.signer.exceptions.SignerUnavailableException
10
- import kotlinx.coroutines.CoroutineScope
11
- import kotlinx.coroutines.Dispatchers
12
- import kotlinx.coroutines.SupervisorJob
13
- import kotlinx.coroutines.launch
4
+ import com.pakstr.app.signer.interfaces.NostrSigner
14
5
  import org.json.JSONObject
15
6
 
7
+ /**
8
+ * Native side of `window.nostr`. WebMessageRouter calls [handle] for
9
+ * `nostr.*` messages that already passed its origin and frame checks.
10
+ *
11
+ * Results are returned as the signer produced them (signEvent returns the
12
+ * signed event JSON, which nip07.js parses). Failures are thrown and mapped
13
+ * to `{ error, message }` by [signerErrorResponse].
14
+ */
16
15
  class NostrBridge(
17
- private val webView: WebView,
18
- private val signerManager: SignerManager
16
+ private val signerProvider: suspend () -> NostrSigner,
17
+ private val log: (String) -> Unit = {},
18
+ private val logError: (String, Throwable) -> Unit = { _, _ -> }
19
19
  ) {
20
20
 
21
- private val mainHandler = Handler(Looper.getMainLooper())
22
-
23
- private val scope = CoroutineScope(
24
- SupervisorJob() + Dispatchers.IO
25
- )
26
-
27
- @JavascriptInterface
28
- fun call(
21
+ suspend fun handle(
29
22
  method: String,
30
- callbackId: String,
31
- payload: String?
32
- ) {
33
- AppDebugLogger.log(
34
-
35
- webView.context,
36
-
37
- "SIGNER",
38
-
39
- "Request: $method"
40
-
41
- )
42
- when (method) {
43
-
44
- "getPublicKey" -> {
45
-
46
- scope.launch {
47
-
48
- try {
49
- val pk =
50
- signerManager
51
- .getConfiguredSigner()
52
- .getPublicKey()
53
- resolve(
54
- callbackId,
55
- pk
56
- )
57
- } catch (e: SignerUnavailableException) {
58
- AppDebugLogger.error(
59
-
60
- webView.context,
61
-
62
- "SIGNER",
63
-
64
- "getPublicKey failed",
65
-
66
- e
67
-
68
- )
69
- resolve(
70
- callbackId,
71
- errorResponse(
72
- "SIGNER_UNAVAILABLE",
73
- e.message ?: "Signer unavailable"
74
- )
75
- )
76
-
77
- } catch (e: Exception) {
78
- AppDebugLogger.error(
79
-
80
- webView.context,
81
-
82
- "SIGNER",
83
-
84
- "getPublicKey failed",
85
-
86
- e
87
-
88
- )
89
- resolve(
90
- callbackId,
91
- errorResponse(
92
- "SIGNER_ERROR",
93
- e.message ?: "Unknown signer error"
94
- )
95
- )
23
+ params: JSONObject?
24
+ ): String {
25
+ if (method !in METHODS) {
26
+ log("Request: unsupported method")
27
+ throw UnsupportedBridgeMethodException()
28
+ }
29
+ log("Request: $method")
30
+
31
+ return try {
32
+ val result = when (method) {
33
+ "getPublicKey" -> signerProvider().getPublicKey()
34
+ "signEvent" -> {
35
+ val event = requireNotNull(params) {
36
+ "Event payload is empty"
96
37
  }
38
+ signerProvider().signEvent(event.toString())
97
39
  }
98
- }
99
-
100
- "signEvent" -> {
101
- scope.launch {
102
-
103
- try {
104
- require(!payload.isNullOrBlank()) {
105
- "Event payload is empty"
106
- }
107
-
108
- val result =
109
- signerManager
110
- .getConfiguredSigner()
111
- .signEvent(
112
- payload
113
- )
114
-
115
- AppDebugLogger.log(
116
-
117
- webView.context,
118
-
119
- "SIGNER",
120
-
121
- "signEvent success"
122
-
123
- )
124
-
125
- resolve(
126
- callbackId,
127
- result
128
- )
129
-
130
- } catch (e: SignerUnavailableException) {
131
- resolve(
132
- callbackId,
133
- errorResponse(
134
- "SIGNER_UNAVAILABLE",
135
- e.message ?: "Signer unavailable"
136
- )
137
- )
138
-
139
- } catch (e: Exception) {
140
- AppDebugLogger.error(
141
-
142
- webView.context,
143
-
144
- "SIGNER",
145
-
146
- "signEvent failed",
147
-
148
- e
149
-
150
- )
151
- resolve(
152
- callbackId,
153
- errorResponse(
154
- "SIGNER_ERROR",
155
- e.message ?: "Unknown signer error"
156
- )
157
- )
40
+ "nip04_encrypt" -> {
41
+ val json = requireNotNull(params) {
42
+ "NIP04 encrypt payload is empty"
158
43
  }
44
+ val pubkey = json.getString("pubkey")
45
+ val plaintext = json.getString("plaintext")
46
+ signerProvider().nip04Encrypt(pubkey, plaintext)
159
47
  }
160
- }
161
-
162
- "nip04_encrypt" -> {
163
-
164
- scope.launch {
165
-
166
- try {
167
-
168
- require(!payload.isNullOrBlank()) {
169
- "NIP04 encrypt payload is empty"
170
- }
171
-
172
- val json =
173
- JSONObject(payload)
174
-
175
- val pubkey =
176
- json.getString("pubkey")
177
-
178
- val plaintext =
179
- json.getString("plaintext")
180
-
181
- val result =
182
- signerManager
183
- .getConfiguredSigner()
184
- .nip04Encrypt(
185
- pubkey,
186
- plaintext
187
- )
188
-
189
- AppDebugLogger.log(
190
-
191
- webView.context,
192
-
193
- "SIGNER",
194
-
195
- "nip04_encrypt success"
196
-
197
- )
198
- resolve(
199
- callbackId,
200
- result
201
- )
202
-
203
- } catch (e: SignerUnavailableException) {
204
-
205
- resolve(
206
- callbackId,
207
- errorResponse(
208
- "SIGNER_UNAVAILABLE",
209
- e.message ?: "Signer unavailable"
210
- )
211
- )
212
-
213
- } catch (e: Exception) {
214
- AppDebugLogger.error(
215
-
216
- webView.context,
217
-
218
- "SIGNER",
219
-
220
- "nip04_encrypt failed",
221
-
222
- e
223
-
224
- )
225
- resolve(
226
- callbackId,
227
- errorResponse(
228
- "SIGNER_ERROR",
229
- e.message ?: "Unknown signer error"
230
- )
231
- )
48
+ "nip04_decrypt" -> {
49
+ val json = requireNotNull(params) {
50
+ "NIP04 decrypt payload is empty"
232
51
  }
52
+ val pubkey = json.getString("pubkey")
53
+ val ciphertext = json.getString("ciphertext")
54
+ signerProvider().nip04Decrypt(pubkey, ciphertext)
233
55
  }
234
- }
235
-
236
- "nip04_decrypt" -> {
237
-
238
- scope.launch {
239
-
240
- try {
241
-
242
- require(!payload.isNullOrBlank()) {
243
- "NIP04 decrypt payload is empty"
244
- }
245
-
246
- val json =
247
- JSONObject(payload)
248
-
249
- val pubkey =
250
- json.getString("pubkey")
251
-
252
- val ciphertext =
253
- json.getString("ciphertext")
254
-
255
- val result =
256
- signerManager
257
- .getConfiguredSigner()
258
- .nip04Decrypt(
259
- pubkey,
260
- ciphertext
261
- )
262
-
263
- AppDebugLogger.log(
264
-
265
- webView.context,
266
-
267
- "SIGNER",
268
-
269
- "nip04_decrypt success"
270
-
271
- )
272
- resolve(
273
- callbackId,
274
- result
275
- )
276
-
277
- } catch (e: SignerUnavailableException) {
278
-
279
- resolve(
280
- callbackId,
281
- errorResponse(
282
- "SIGNER_UNAVAILABLE",
283
- e.message ?: "Signer unavailable"
284
- )
285
- )
286
-
287
- } catch (e: Exception) {
288
- AppDebugLogger.error(
289
-
290
- webView.context,
291
-
292
- "SIGNER",
293
-
294
- "nip04_decrypt failed",
295
-
296
- e
297
-
298
- )
299
- resolve(
300
- callbackId,
301
- errorResponse(
302
- "SIGNER_ERROR",
303
- e.message ?: "Unknown signer error"
304
- )
305
- )
306
- }
56
+ "nip44_encrypt" -> {
57
+ val json = params ?: JSONObject()
58
+ val pubkey = json.getString("pubkey")
59
+ val plaintext = json.getString("plaintext")
60
+ signerProvider().nip44Encrypt(pubkey, plaintext)
307
61
  }
308
- }
309
-
310
- "nip44_encrypt" -> {
311
-
312
- scope.launch {
313
-
314
- try {
315
-
316
- val json =
317
- JSONObject(payload ?: "")
318
-
319
- val result =
320
- signerManager
321
- .getConfiguredSigner()
322
- .nip44Encrypt(
323
- json.getString("pubkey"),
324
- json.getString("plaintext")
325
- )
326
- AppDebugLogger.log(
327
-
328
- webView.context,
329
-
330
- "SIGNER",
331
-
332
- "nip44_encrypt success"
333
-
334
- )
335
- resolve(
336
- callbackId,
337
- result
338
- )
339
-
340
- } catch (e: SignerUnavailableException) {
341
- resolve(
342
- callbackId,
343
- signerErrorResponse(e)
344
- )
345
- } catch (e: Exception) {
346
- AppDebugLogger.error(
347
-
348
- webView.context,
349
-
350
- "SIGNER",
351
-
352
- "nip44_encrypt failed",
353
-
354
- e
355
-
356
- )
357
- resolve(
358
- callbackId,
359
- signerErrorResponse(e)
360
- )
361
- }
62
+ "nip44_decrypt" -> {
63
+ val json = params ?: JSONObject()
64
+ val pubkey = json.getString("pubkey")
65
+ val ciphertext = json.getString("ciphertext")
66
+ signerProvider().nip44Decrypt(pubkey, ciphertext)
362
67
  }
68
+ else -> throw UnsupportedBridgeMethodException()
363
69
  }
364
-
365
- "nip44_decrypt" -> {
366
-
367
- scope.launch {
368
-
369
- try {
370
-
371
- val json =
372
- JSONObject(payload ?: "")
373
-
374
- val result =
375
- signerManager
376
- .getConfiguredSigner()
377
- .nip44Decrypt(
378
- json.getString("pubkey"),
379
- json.getString("ciphertext")
380
- )
381
- AppDebugLogger.log(
382
-
383
- webView.context,
384
-
385
- "SIGNER",
386
-
387
- "nip44_decrypt success"
388
-
389
- )
390
- resolve(
391
- callbackId,
392
- result
393
- )
394
-
395
- } catch (e: SignerUnavailableException) {
396
- resolve(
397
- callbackId,
398
- signerErrorResponse(e)
399
- )
400
- } catch (e: Exception) {
401
- AppDebugLogger.error(
402
-
403
- webView.context,
404
-
405
- "SIGNER",
406
-
407
- "nip44_decrypt failed",
408
-
409
- e
410
-
411
- )
412
- resolve(
413
- callbackId,
414
- signerErrorResponse(e)
415
- )
416
- }
417
- }
418
- }
419
-
420
- else -> {
421
-
422
- resolve(
423
- callbackId,
424
- ""
425
- )
426
- }
70
+ log("$method success")
71
+ result
72
+ } catch (e: Exception) {
73
+ logError("$method failed", e)
74
+ throw e
427
75
  }
428
76
  }
429
77
 
430
- @JavascriptInterface
431
- fun debugLog(
432
- level: String,
433
- message: String
434
- ) {
435
-
436
- when (level.uppercase()) {
437
-
438
- "ERROR" -> {
439
-
440
- AppDebugLogger.error(
441
- webView.context,
442
- "WEB-JS",
443
- message
444
- )
445
-
446
- }
447
-
448
- "WARN" -> {
449
-
450
- AppDebugLogger.log(
451
- webView.context,
452
- "WEB-WARN",
453
- message
454
- )
455
-
456
- }
457
-
458
- else -> {
459
-
460
- AppDebugLogger.log(
461
- webView.context,
462
- "WEB",
463
- message
464
- )
465
-
466
- }
467
- }
468
- }
469
-
470
- private fun resolve(
471
- callbackId: String,
472
- result: String
473
- ) {
474
-
475
- mainHandler.post {
476
-
477
- val js =
478
- """
479
- window.__nostrResolve(
480
- ${JSONObject.quote(callbackId)},
481
- ${JSONObject.quote(result)}
482
- );
483
- """.trimIndent()
484
-
485
- webView.evaluateJavascript(js, null)
486
- }
487
- }
488
-
489
- private fun errorResponse(
490
- code: String,
491
- message: String
492
- ): String {
493
-
494
- return JSONObject()
495
- .apply {
496
-
497
- put(
498
- "error",
499
- code
500
- )
501
-
502
- put(
503
- "message",
504
- message
505
- )
506
-
507
- }
508
- .toString()
509
- }
510
-
511
78
  companion object {
79
+ private val METHODS = setOf(
80
+ "getPublicKey",
81
+ "signEvent",
82
+ "nip04_encrypt",
83
+ "nip04_decrypt",
84
+ "nip44_encrypt",
85
+ "nip44_decrypt"
86
+ )
87
+
512
88
  internal fun signerErrorResponse(error: Exception): String {
513
89
  return JSONObject()
514
90
  .put(
@@ -531,3 +107,7 @@ class NostrBridge(
531
107
  }
532
108
  }
533
109
  }
110
+
111
+ class UnsupportedBridgeMethodException : IllegalArgumentException(
112
+ "Unsupported method"
113
+ )
@@ -0,0 +1,114 @@
1
+ package com.pakstr.app
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Test
7
+
8
+ class AppOriginTest {
9
+ private val port = Config.PORT
10
+
11
+ @Test
12
+ fun originRuleIsTheExactLocalServerOrigin() {
13
+ assertEquals("http://127.0.0.1:$port", AppOrigin.ORIGIN)
14
+ }
15
+
16
+ @Test
17
+ fun onlyTheExactAppOriginIsTrusted() {
18
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, null))
19
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, ""))
20
+ assertTrue(AppOrigin.isTrustedOrigin("http", "127.0.0.1", port, null, "/"))
21
+ }
22
+
23
+ @Test
24
+ fun nearMissOriginsAreRejected() {
25
+ val rejected = listOf(
26
+ arrayOf<Any?>("https", "127.0.0.1", port, null, null),
27
+ arrayOf<Any?>("HTTP", "127.0.0.1", port, null, null),
28
+ arrayOf<Any?>("http", "127.0.0.1", port + 1, null, null),
29
+ arrayOf<Any?>("http", "127.0.0.1", -1, null, null),
30
+ arrayOf<Any?>("http", "127.0.0.1", 80, null, null),
31
+ arrayOf<Any?>("http", "localhost", port, null, null),
32
+ arrayOf<Any?>("http", "127.0.0.2", port, null, null),
33
+ arrayOf<Any?>("http", "[::1]", port, null, null),
34
+ arrayOf<Any?>("http", "127.0.0.1.example.com", port, null, null),
35
+ arrayOf<Any?>("http", "example.com", port, null, null),
36
+ arrayOf<Any?>("http", "127.0.0.1", port, "user", null),
37
+ arrayOf<Any?>("http", "127.0.0.1", port, null, "/index.html"),
38
+ arrayOf<Any?>("file", null, -1, null, "/android_asset/www/index.html"),
39
+ arrayOf<Any?>("data", null, -1, null, "text/html,x"),
40
+ arrayOf<Any?>(null, null, -1, null, null)
41
+ )
42
+ for (origin in rejected) {
43
+ assertFalse(
44
+ origin.contentToString(),
45
+ AppOrigin.isTrustedOrigin(
46
+ origin[0] as String?,
47
+ origin[1] as String?,
48
+ origin[2] as Int,
49
+ origin[3] as String?,
50
+ origin[4] as String?
51
+ )
52
+ )
53
+ }
54
+ }
55
+
56
+ @Test
57
+ fun mainFrameNavigationStaysOnTheAppOrigin() {
58
+ assertEquals(
59
+ NavigationDecision.ALLOW,
60
+ navigationDecision("http", "127.0.0.1", port, null, isForMainFrame = true)
61
+ )
62
+ assertEquals(
63
+ NavigationDecision.ALLOW,
64
+ navigationDecision("http", "127.0.0.1", port, null, isForMainFrame = false)
65
+ )
66
+ }
67
+
68
+ @Test
69
+ fun navigationOffTheAppOriginIsBlocked() {
70
+ val blocked = listOf(
71
+ Triple("http", "127.0.0.1", port + 1),
72
+ Triple("http", "127.0.0.1", -1),
73
+ Triple("https", "127.0.0.1", port),
74
+ Triple("http", "localhost", port),
75
+ Triple("https", "example.com", -1),
76
+ Triple("intent", null, -1),
77
+ Triple("javascript", null, -1),
78
+ Triple("file", null, -1),
79
+ Triple("about", null, -1),
80
+ Triple("data", null, -1)
81
+ )
82
+ for ((scheme, host, navigationPort) in blocked) {
83
+ assertEquals(
84
+ "$scheme://$host:$navigationPort",
85
+ NavigationDecision.BLOCK,
86
+ navigationDecision(scheme, host, navigationPort, null, isForMainFrame = true)
87
+ )
88
+ }
89
+ assertEquals(
90
+ NavigationDecision.BLOCK,
91
+ navigationDecision("http", "127.0.0.1", port, "user:pass", isForMainFrame = true)
92
+ )
93
+ assertEquals(
94
+ NavigationDecision.BLOCK,
95
+ navigationDecision(null, null, -1, null, isForMainFrame = true)
96
+ )
97
+ }
98
+
99
+ @Test
100
+ fun nip55RequestsAreDispatchedOnlyFromTheMainFrame() {
101
+ assertEquals(
102
+ NavigationDecision.NIP55_SIGN_REQUEST,
103
+ navigationDecision("nostrsigner", null, -1, null, isForMainFrame = true)
104
+ )
105
+ assertEquals(
106
+ NavigationDecision.NIP55_SIGN_REQUEST,
107
+ navigationDecision("NostrSigner", null, -1, null, isForMainFrame = true)
108
+ )
109
+ assertEquals(
110
+ NavigationDecision.REJECT_SUBFRAME_NIP55,
111
+ navigationDecision("nostrsigner", null, -1, null, isForMainFrame = false)
112
+ )
113
+ }
114
+ }