pakstr 0.31.0 → 1.0.1

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.
Files changed (19) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +1 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
  4. package/android-template/app/src/main/assets/nip07.js +124 -74
  5. package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
  7. package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
  10. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberRequestChannel.kt +156 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/signer/AmberSigner.kt +142 -390
  12. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
  13. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +13 -2
  14. package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
  15. package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
  16. package/android-template/app/src/test/java/com/pakstr/app/signer/AmberRequestChannelTest.kt +294 -0
  17. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
  18. package/android-template/gradle/libs.versions.toml +2 -0
  19. package/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.NostrBridge
4
+ import com.pakstr.app.signer.UnsupportedBridgeMethodException
5
+ import kotlinx.coroutines.CancellationException
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.launch
8
+ import org.json.JSONObject
9
+
10
+ /** Where a bridge message came from (android.net.Uri components plus the frame flag). */
11
+ internal data class MessageSource(
12
+ val scheme: String?,
13
+ val host: String?,
14
+ val port: Int,
15
+ val userInfo: String?,
16
+ val path: String?,
17
+ val isMainFrame: Boolean
18
+ )
19
+
20
+ internal fun interface BridgeHandler {
21
+ suspend fun handle(method: String, params: JSONObject?): Any?
22
+ }
23
+
24
+ /**
25
+ * Validates and dispatches messages posted to the injected bridge object.
26
+ *
27
+ * Fail closed: messages from subframes or other origins, non-string or
28
+ * oversized data, and malformed envelopes are dropped without a reply.
29
+ * A request `{ v: 1, id, method, params? }` is answered once, only to the
30
+ * frame that sent it, with `{ v: 1, id, ok: true, result }` or
31
+ * `{ v: 1, id, ok: false, error: { error, message } }`. Notifications
32
+ * (no reply) carry no id.
33
+ */
34
+ internal class WebMessageRouter(
35
+ private val handler: BridgeHandler,
36
+ private val scope: CoroutineScope,
37
+ private val runOnMain: (() -> Unit) -> Unit,
38
+ private val log: (String) -> Unit = {}
39
+ ) {
40
+
41
+ fun onMessage(
42
+ source: MessageSource,
43
+ data: String?,
44
+ reply: (String) -> Unit
45
+ ) {
46
+ rejectionReason(source, data)?.let { reason ->
47
+ log("Bridge message rejected: $reason")
48
+ return
49
+ }
50
+ val message = try {
51
+ JSONObject(requireNotNull(data))
52
+ } catch (_: Exception) {
53
+ log("Bridge message rejected: malformed message")
54
+ return
55
+ }
56
+ if (message.opt("v") != PROTOCOL_VERSION) {
57
+ log("Bridge message rejected: unsupported protocol version")
58
+ return
59
+ }
60
+
61
+ val method = message.opt("method") as? String
62
+ val params = when (val value = message.opt("params")) {
63
+ null, JSONObject.NULL -> null
64
+ is JSONObject -> value
65
+ else -> INVALID_PARAMS
66
+ }
67
+
68
+ if (method in NOTIFICATIONS) {
69
+ if (params !== INVALID_PARAMS) {
70
+ scope.launch {
71
+ try {
72
+ handler.handle(requireNotNull(method), params)
73
+ } catch (error: CancellationException) {
74
+ throw error
75
+ } catch (_: Exception) {
76
+ // Notifications have no reply channel.
77
+ }
78
+ }
79
+ }
80
+ return
81
+ }
82
+
83
+ val id = message.opt("id") as? String
84
+ if (id == null || !REQUEST_ID.matches(id)) {
85
+ log("Bridge message rejected: invalid request id")
86
+ return
87
+ }
88
+ if (method == null || params === INVALID_PARAMS) {
89
+ val response = failure(id, IllegalArgumentException("Invalid request"))
90
+ runOnMain { reply(response) }
91
+ return
92
+ }
93
+
94
+ scope.launch {
95
+ val response = try {
96
+ success(id, handler.handle(method, params))
97
+ } catch (error: CancellationException) {
98
+ throw error
99
+ } catch (error: Exception) {
100
+ failure(id, error)
101
+ }
102
+ runOnMain { reply(response) }
103
+ }
104
+ }
105
+
106
+ private fun rejectionReason(
107
+ source: MessageSource,
108
+ data: String?
109
+ ): String? = when {
110
+ !source.isMainFrame -> "subframe"
111
+ !AppOrigin.isTrustedOrigin(
112
+ source.scheme,
113
+ source.host,
114
+ source.port,
115
+ source.userInfo,
116
+ source.path
117
+ ) -> "untrusted origin"
118
+ data == null -> "non-string message"
119
+ data.length > MAX_MESSAGE_BYTES ||
120
+ data.toByteArray(Charsets.UTF_8).size > MAX_MESSAGE_BYTES -> "message too large"
121
+ else -> null
122
+ }
123
+
124
+ private fun success(id: String, result: Any?): String =
125
+ envelope(id)
126
+ .put("ok", true)
127
+ .put("result", result ?: JSONObject.NULL)
128
+ .toString()
129
+
130
+ private fun failure(id: String, error: Exception): String =
131
+ envelope(id)
132
+ .put("ok", false)
133
+ .put("error", JSONObject(NostrBridge.signerErrorResponse(error)))
134
+ .toString()
135
+
136
+ private fun envelope(id: String): JSONObject =
137
+ JSONObject()
138
+ .put("v", PROTOCOL_VERSION)
139
+ .put("id", id)
140
+
141
+ companion object {
142
+ const val PROTOCOL_VERSION = 1
143
+
144
+ /** Same limit as nip07.js, which rejects larger requests before posting them. */
145
+ const val MAX_MESSAGE_BYTES = 1024 * 1024
146
+
147
+ const val DEBUG_LOG_METHOD = "debug.log"
148
+
149
+ private val NOTIFICATIONS = setOf(DEBUG_LOG_METHOD)
150
+ private val REQUEST_ID = Regex("[A-Za-z0-9-]{1,64}")
151
+ private val INVALID_PARAMS = JSONObject()
152
+ }
153
+ }
154
+
155
+ /** Routes `nostr.*`, `runtime.*`, and `debug.log` to their native handlers. */
156
+ internal class PakstrBridgeMethods(
157
+ private val nostr: NostrBridge,
158
+ private val runtime: PakstrBridge,
159
+ private val debugLog: (level: String, message: String) -> Unit
160
+ ) : BridgeHandler {
161
+
162
+ override suspend fun handle(method: String, params: JSONObject?): Any? =
163
+ when (method) {
164
+ "runtime.getApiBaseUrl" -> runtime.getApiBaseUrl()
165
+ "runtime.setApiBaseUrl" ->
166
+ (params?.opt("url") as? String)?.let(runtime::setApiBaseUrl) ?: false
167
+ "runtime.getNip55CallbackUrl" -> runtime.getNip55CallbackUrl()
168
+ WebMessageRouter.DEBUG_LOG_METHOD -> {
169
+ debugLog(
170
+ params?.opt("level") as? String ?: "LOG",
171
+ params?.opt("message") as? String ?: ""
172
+ )
173
+ null
174
+ }
175
+ else ->
176
+ if (method.startsWith(NOSTR_PREFIX)) {
177
+ nostr.handle(method.removePrefix(NOSTR_PREFIX), params)
178
+ } else {
179
+ throw UnsupportedBridgeMethodException()
180
+ }
181
+ }
182
+
183
+ private companion object {
184
+ const val NOSTR_PREFIX = "nostr."
185
+ }
186
+ }
@@ -31,8 +31,7 @@ import kotlinx.coroutines.withContext
31
31
  * Responsibilities:
32
32
  * - Configure secure WebView settings
33
33
  * - Load local web application from NanoHTTPD server
34
- * - Inject NIP-07 JavaScript provider
35
- * - Expose Android signer bridge to JavaScript
34
+ * - Install the origin-checked JavaScript bridge and NIP-07 provider
36
35
  * - Handle Web permissions
37
36
  * - Cleanup WebView resources
38
37
  */
@@ -52,6 +51,7 @@ class WebViewController(
52
51
  private var pendingNip55Result: String? = null
53
52
  private var isSetup = false
54
53
  private val nip55Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
54
+ private val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
55
55
 
56
56
  // Keeps a reference to the runnable to prevent memory leaks if destroyed early
57
57
  private val loadRunnable = Runnable {
@@ -63,8 +63,6 @@ class WebViewController(
63
63
  //webView.loadUrl("http://127.0.0.1:$port/not-found.html")
64
64
  }
65
65
 
66
- private var nip07Injected = false
67
-
68
66
  fun setup() {
69
67
  showLoader()
70
68
  setupCookies()
@@ -121,10 +119,6 @@ class WebViewController(
121
119
 
122
120
  hideLoader()
123
121
 
124
- if (!nip07Injected) {
125
- nip07Injected = injectNip07()
126
-
127
- }
128
122
  if (AppDebugLogger.isWebViewLoggingEnabled(context)) {
129
123
  injectDebugLogger()
130
124
  }
@@ -235,7 +229,18 @@ class WebViewController(
235
229
  view: WebView?, request: WebResourceRequest?
236
230
  ): Boolean {
237
231
  val url = request?.url ?: return true
238
- if (url.scheme.equals("nostrsigner", ignoreCase = true)) {
232
+ val decision = navigationDecision(
233
+ url.scheme,
234
+ url.host,
235
+ url.port,
236
+ url.userInfo,
237
+ request.isForMainFrame
238
+ )
239
+ if (decision == NavigationDecision.REJECT_SUBFRAME_NIP55) {
240
+ logNip55Rejection("subframe request")
241
+ return true
242
+ }
243
+ if (decision == NavigationDecision.NIP55_SIGN_REQUEST) {
239
244
  dispatchNip55SignEvent(
240
245
  rawUrl = url.toString(),
241
246
  activeSignerType = signerManager.getActiveType(),
@@ -269,7 +274,7 @@ class WebViewController(
269
274
  )
270
275
  return true
271
276
  }
272
- return url.host != "127.0.0.1"
277
+ return decision != NavigationDecision.ALLOW
273
278
  }
274
279
 
275
280
  }
@@ -323,25 +328,7 @@ class WebViewController(
323
328
  }
324
329
  }
325
330
 
326
- // Exposes native Nostr signer APIs to the WebView.
327
- // Available in JavaScript as window.AndroidBridge.
328
- webView.addJavascriptInterface(
329
-
330
- NostrBridge(
331
-
332
- signerManager = signerManager,
333
-
334
- webView = webView,
335
-
336
- ),
337
-
338
- "AndroidBridge"
339
-
340
- )
341
- webView.addJavascriptInterface(
342
- PakstrBridge(RuntimeApiBaseSettings.from(context)),
343
- "PakstrBridge"
344
- )
331
+ installBridge()
345
332
 
346
333
  }
347
334
 
@@ -406,6 +393,7 @@ class WebViewController(
406
393
 
407
394
  fun destroy() {
408
395
  nip55Scope.cancel()
396
+ bridgeScope.cancel()
409
397
  webView.stopLoading()
410
398
 
411
399
  webView.clearHistory()
@@ -422,24 +410,48 @@ class WebViewController(
422
410
  webView.destroy()
423
411
  }
424
412
 
425
- private fun injectNip07(): Boolean {
426
-
427
- return try {
428
- val js = context.assets.open("nip07.js")
413
+ /**
414
+ * Exposes window.nostr and window.pakstr to the app origin's top-level
415
+ * frame only (see PakstrWebBridge). Runs before the first load.
416
+ */
417
+ private fun installBridge() {
418
+ val providerScript = try {
419
+ context.assets.open("nip07.js")
429
420
  .bufferedReader()
430
- .readText()
431
-
432
- webView.evaluateJavascript(
433
- js, null
434
- )
435
- true
421
+ .use { it.readText() }
436
422
  } catch (e: Exception) {
437
- if (AppDebugLogger.isWebViewLoggingEnabled(context)) {
438
- AppDebugLogger.log(
439
- context, "WebViewController - WEBVIEW", "Failed loading nip07.js: ${e.message}"
440
- )
441
- }
442
- false
423
+ AppDebugLogger.error(context, "BRIDGE", "Failed loading nip07.js", e)
424
+ return
425
+ }
426
+ val router = WebMessageRouter(
427
+ handler = PakstrBridgeMethods(
428
+ nostr = NostrBridge(
429
+ signerProvider = signerManager::getConfiguredSigner,
430
+ log = { message -> AppDebugLogger.log(context, "SIGNER", message) },
431
+ logError = { message, error ->
432
+ AppDebugLogger.error(context, "SIGNER", message, error)
433
+ }
434
+ ),
435
+ runtime = PakstrBridge(RuntimeApiBaseSettings.from(context)),
436
+ debugLog = ::logWebConsole
437
+ ),
438
+ scope = bridgeScope,
439
+ runOnMain = { block -> webView.post { block() } },
440
+ log = { message -> AppDebugLogger.log(context, "BRIDGE", message) }
441
+ )
442
+ PakstrWebBridge.install(webView, router, providerScript) { message ->
443
+ AppDebugLogger.error(context, "BRIDGE", message)
444
+ }
445
+ }
446
+
447
+ private fun logWebConsole(level: String, message: String) {
448
+ if (!AppDebugLogger.isWebViewLoggingEnabled(context)) {
449
+ return
450
+ }
451
+ when (level.uppercase()) {
452
+ "ERROR" -> AppDebugLogger.error(context, "WEB-JS", message)
453
+ "WARN" -> AppDebugLogger.log(context, "WEB-WARN", message)
454
+ else -> AppDebugLogger.log(context, "WEB", message)
443
455
  }
444
456
  }
445
457
 
@@ -448,7 +460,8 @@ class WebViewController(
448
460
  val js = """
449
461
  (function(){
450
462
 
451
- if (!window.AndroidBridge) {
463
+ const channel = window.__pakstrNative;
464
+ if (!channel) {
452
465
  return;
453
466
  }
454
467
 
@@ -475,9 +488,12 @@ class WebViewController(
475
488
  })
476
489
  .join(" ");
477
490
 
478
- window.AndroidBridge.debugLog(
479
- level,
480
- message
491
+ channel.postMessage(
492
+ JSON.stringify({
493
+ v: 1,
494
+ method: "debug.log",
495
+ params: { level, message }
496
+ })
481
497
  );
482
498
 
483
499
  } catch(e){
@@ -0,0 +1,156 @@
1
+ package com.pakstr.app.signer
2
+
3
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
4
+ import kotlinx.coroutines.CompletableDeferred
5
+ import kotlinx.coroutines.TimeoutCancellationException
6
+ import kotlinx.coroutines.coroutineScope
7
+ import kotlinx.coroutines.launch
8
+ import kotlinx.coroutines.sync.Mutex
9
+ import kotlinx.coroutines.sync.withLock
10
+ import kotlinx.coroutines.withTimeout
11
+ import java.util.UUID
12
+
13
+ /**
14
+ * Matches NIP-55 activity results to the request that launched them.
15
+ *
16
+ * One ActivityResultLauncher delivers every result through the same
17
+ * callback, so one channel serves the launcher and at most one signer
18
+ * activity is outstanding at a time. Each launch carries an `id` extra.
19
+ *
20
+ * Every launch yields exactly one result. A request abandoned by its caller
21
+ * (timeout, cancellation, closed session) therefore keeps its slot until its
22
+ * result arrives, and a result echoing a different id means the matching one
23
+ * can never follow: the outstanding request fails without receiving that
24
+ * result's data and the slot is released.
25
+ */
26
+ internal class AmberRequestChannel(
27
+ private val timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS,
28
+ private val newRequestId: () -> String = { UUID.randomUUID().toString() }
29
+ ) {
30
+ private val mutex = Mutex()
31
+ private val lock = Any()
32
+ private var outstanding: Outstanding? = null // guarded by lock
33
+
34
+ private class Outstanding(val id: String) {
35
+ val result = CompletableDeferred<String>()
36
+ }
37
+
38
+ /** Opens a request session for one AmberSigner instance. */
39
+ fun openSession(): Session = Session()
40
+
41
+ /**
42
+ * Resolves the outstanding request with [outcome]. A [requestId] of null
43
+ * (signers that do not echo ids, cancelled activities) matches the
44
+ * outstanding request. Returns false if the result was not delivered.
45
+ */
46
+ fun deliver(requestId: String?, outcome: Result<String>): Boolean {
47
+ val current = synchronized(lock) {
48
+ outstanding.also { outstanding = null }
49
+ } ?: return false
50
+ if (requestId != null && requestId != current.id) {
51
+ current.result.completeExceptionally(AmberResponseMismatchException())
52
+ return false
53
+ }
54
+ outcome.fold(current.result::complete, current.result::completeExceptionally)
55
+ return true
56
+ }
57
+
58
+ private suspend fun exchange(
59
+ session: Session,
60
+ start: (requestId: String) -> Unit
61
+ ): String = mutex.withLock {
62
+ // Wait out an abandoned launch so its result cannot resolve this one.
63
+ synchronized(lock) { outstanding }?.result?.join()
64
+
65
+ val current = Outstanding(newRequestId())
66
+ // Holding the session lock makes close() and launch mutually exclusive.
67
+ synchronized(session) {
68
+ if (session.closed) throw closedException()
69
+ synchronized(lock) { outstanding = current }
70
+ try {
71
+ start(current.id)
72
+ } catch (error: Throwable) {
73
+ synchronized(lock) {
74
+ if (outstanding === current) outstanding = null
75
+ }
76
+ throw error
77
+ }
78
+ }
79
+ current.result.await()
80
+ }
81
+
82
+ inner class Session internal constructor() {
83
+ internal var closed = false // guarded by this
84
+ private val waiters = mutableSetOf<CompletableDeferred<String>>()
85
+
86
+ /**
87
+ * Runs [block] under the request timeout, failing with
88
+ * [AmberRequestTimeoutException] instead of a cancellation so callers
89
+ * still get an error response.
90
+ */
91
+ suspend fun <T> withRequestTimeout(block: suspend () -> T): T {
92
+ return try {
93
+ withTimeout(timeoutMillis) { block() }
94
+ } catch (_: TimeoutCancellationException) {
95
+ throw AmberRequestTimeoutException()
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Waits for the signer to be free, calls [start] with the request id
101
+ * and suspends until the matching result is delivered. If [start]
102
+ * throws, nothing is outstanding and the exception propagates.
103
+ */
104
+ suspend fun request(start: (requestId: String) -> Unit): String = coroutineScope {
105
+ val waiter = CompletableDeferred<String>()
106
+ synchronized(this@Session) {
107
+ if (closed) throw closedException()
108
+ waiters += waiter
109
+ }
110
+ val worker = launch {
111
+ try {
112
+ waiter.complete(exchange(this@Session, start))
113
+ } catch (error: Throwable) {
114
+ waiter.completeExceptionally(error)
115
+ }
116
+ }
117
+ try {
118
+ waiter.await()
119
+ } finally {
120
+ worker.cancel()
121
+ synchronized(this@Session) { waiters -= waiter }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Fails this session's waiting and queued requests and rejects new
127
+ * ones. A launch already in flight keeps the slot until its result.
128
+ */
129
+ fun close() {
130
+ val pending = synchronized(this) {
131
+ closed = true
132
+ waiters.toList()
133
+ }
134
+ pending.forEach { it.completeExceptionally(closedException()) }
135
+ }
136
+ }
137
+
138
+ private fun closedException() =
139
+ SignerUnavailableException("Amber signer was closed")
140
+
141
+ companion object {
142
+ const val DEFAULT_TIMEOUT_MILLIS = 30_000L
143
+ }
144
+ }
145
+
146
+ class AmberRequestTimeoutException : Exception(
147
+ "Amber request timed out"
148
+ )
149
+
150
+ class AmberRequestRejectedException : Exception(
151
+ "Amber request was rejected"
152
+ )
153
+
154
+ class AmberResponseMismatchException : Exception(
155
+ "Amber returned a response for a different request"
156
+ )