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.
package/README.md CHANGED
@@ -49,7 +49,7 @@ build:
49
49
  # apiBase: https://api.example.com # Optional packaged /api/* proxy target.
50
50
  ```
51
51
 
52
- `runtime.apiBase` must be an absolute HTTPS URL. It is the immutable packaged/default proxy target and public APK metadata, not a secret. The effective target precedence is persistent runtime override, then packaged default, then disabled. A web app may call `window.PakstrBridge.setApiBaseUrl(url)` to validate and synchronously persist an HTTPS override, and `window.PakstrBridge.getApiBaseUrl()` to read the effective URL. Developer Tools uses the same setting. Reset removes the override and restores the packaged default; without a packaged default it disables `/api/*`. Changes affect the next proxy request without an app restart, LocalServer restart, or WebView reload.
52
+ `runtime.apiBase` must be an absolute HTTPS URL. It is the immutable packaged/default proxy target and public APK metadata, not a secret. The effective target precedence is persistent runtime override, then packaged default, then disabled. On Android, a web app may call `await window.pakstr.setApiBaseUrl(url)` to validate and persist an HTTPS override (it resolves `true` on success) and `await window.pakstr.getApiBaseUrl()` to read the effective URL; `window.pakstr` exists only in the app's top-level page. Developer Tools uses the same setting. Reset removes the override and restores the packaged default; without a packaged default it disables `/api/*`. Changes affect the next proxy request without an app restart, LocalServer restart, or WebView reload.
53
53
 
54
54
  ### 3. Build, sign, and publish
55
55
  ```bash
@@ -146,5 +146,6 @@ dependencies {
146
146
  implementation(libs.okhttp)
147
147
  implementation(libs.androidx.security.crypto)
148
148
  implementation(libs.zxing.core)
149
+ implementation(libs.androidx.webkit)
149
150
  implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
150
151
  }
@@ -0,0 +1,153 @@
1
+ package com.pakstr.app
2
+
3
+ import android.os.SystemClock
4
+ import android.webkit.WebView
5
+ import androidx.test.ext.junit.runners.AndroidJUnit4
6
+ import androidx.test.platform.app.InstrumentationRegistry
7
+ import kotlinx.coroutines.CoroutineScope
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.SupervisorJob
10
+ import kotlinx.coroutines.cancel
11
+ import org.junit.After
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assume.assumeTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import java.util.concurrent.CountDownLatch
18
+ import java.util.concurrent.TimeUnit
19
+ import java.util.concurrent.atomic.AtomicInteger
20
+ import java.util.concurrent.atomic.AtomicReference
21
+
22
+ /**
23
+ * Exercises the real androidx.webkit bridge: origin scoping, document-start
24
+ * injection, subframe rejection, and a full request/reply round trip.
25
+ * Pages are loaded with loadDataWithBaseURL so no LocalServer is needed.
26
+ */
27
+ @RunWith(AndroidJUnit4::class)
28
+ class PakstrWebBridgeTest {
29
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
30
+ private val handled = AtomicInteger()
31
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
32
+ private lateinit var webView: WebView
33
+
34
+ @Before
35
+ fun setUp() {
36
+ assumeTrue("WebView lacks the required androidx.webkit features", PakstrWebBridge.isSupported())
37
+ val providerScript = instrumentation.targetContext.assets.open("nip07.js")
38
+ .bufferedReader()
39
+ .use { it.readText() }
40
+ val installed = AtomicReference<Boolean>()
41
+ instrumentation.runOnMainSync {
42
+ webView = WebView(instrumentation.targetContext)
43
+ webView.settings.javaScriptEnabled = true
44
+ val router = WebMessageRouter(
45
+ handler = { method, _ ->
46
+ handled.incrementAndGet()
47
+ if (method == "runtime.getNip55CallbackUrl") CALLBACK else null
48
+ },
49
+ scope = scope,
50
+ runOnMain = { block -> webView.post { block() } }
51
+ )
52
+ installed.set(PakstrWebBridge.install(webView, router, providerScript) {})
53
+ }
54
+ assertEquals(true, installed.get())
55
+ }
56
+
57
+ @After
58
+ fun tearDown() {
59
+ scope.cancel()
60
+ if (::webView.isInitialized) {
61
+ instrumentation.runOnMainSync { webView.destroy() }
62
+ }
63
+ }
64
+
65
+ @Test
66
+ fun providerExistsBeforePageScriptsOnTheAppOrigin() {
67
+ load(
68
+ "${AppOrigin.ORIGIN}/",
69
+ "<script>document.title = [typeof window.nostr, typeof window.pakstr, " +
70
+ "typeof window.AndroidBridge, typeof window.PakstrBridge].join(',')</script>"
71
+ )
72
+
73
+ assertEquals("object,object,undefined,undefined", awaitTitle())
74
+ }
75
+
76
+ @Test
77
+ fun foreignOriginsGetNeitherTheChannelNorTheProvider() {
78
+ for (base in listOf("https://example.com", "http://127.0.0.1:${Config.PORT + 1}", "http://localhost:${Config.PORT}")) {
79
+ // The origin in the title ties each result to the page that produced it.
80
+ load(
81
+ "$base/",
82
+ "<script>document.title = location.origin + '|' + [typeof window.nostr, typeof window.pakstr, " +
83
+ "typeof window.${PakstrWebBridge.JS_OBJECT_NAME}].join(',')</script>"
84
+ )
85
+
86
+ assertEquals("$base|undefined,undefined,undefined", awaitTitle { it.startsWith("$base|") })
87
+ }
88
+ }
89
+
90
+ @Test
91
+ fun sameOriginSubframesGetNoProviderAndCannotReachNativeCode() {
92
+ val frame = "<script>" +
93
+ "const c = window.${PakstrWebBridge.JS_OBJECT_NAME};" +
94
+ "if (c) c.postMessage(JSON.stringify({ v: 1, id: 'frame', method: 'runtime.getNip55CallbackUrl' }));" +
95
+ "parent.document.title = 'frame:' + typeof window.nostr + ',' + typeof window.pakstr;" +
96
+ "</script>"
97
+ load("${AppOrigin.ORIGIN}/", "<iframe srcdoc=\"$frame\"></iframe>")
98
+
99
+ assertEquals("frame:undefined,undefined", awaitTitle())
100
+ SystemClock.sleep(1_000)
101
+ assertEquals(0, handled.get())
102
+ }
103
+
104
+ @Test
105
+ fun appTopFrameRequestsRoundTripThroughTheRouter() {
106
+ load(
107
+ "${AppOrigin.ORIGIN}/",
108
+ "<script>window.pakstr.getNip55CallbackUrl().then(" +
109
+ "v => document.title = 'ok:' + v, e => document.title = 'err:' + e.error)</script>"
110
+ )
111
+
112
+ assertEquals("ok:$CALLBACK", awaitTitle())
113
+ assertEquals(1, handled.get())
114
+ }
115
+
116
+ private fun load(baseUrl: String, body: String) {
117
+ instrumentation.runOnMainSync {
118
+ webView.loadDataWithBaseURL(
119
+ baseUrl,
120
+ "<!doctype html><html><head><title></title></head><body>$body</body></html>",
121
+ "text/html",
122
+ "utf-8",
123
+ null
124
+ )
125
+ }
126
+ }
127
+
128
+ private fun awaitTitle(
129
+ timeoutMillis: Long = 10_000,
130
+ accept: (String) -> Boolean = { true }
131
+ ): String {
132
+ val deadline = SystemClock.uptimeMillis() + timeoutMillis
133
+ while (SystemClock.uptimeMillis() < deadline) {
134
+ val latch = CountDownLatch(1)
135
+ val title = AtomicReference<String>()
136
+ instrumentation.runOnMainSync {
137
+ webView.evaluateJavascript("document.title") { value ->
138
+ title.set(value?.trim('"').orEmpty())
139
+ latch.countDown()
140
+ }
141
+ }
142
+ latch.await(1, TimeUnit.SECONDS)
143
+ val current = title.get()
144
+ if (!current.isNullOrEmpty() && current != "null" && accept(current)) return current
145
+ SystemClock.sleep(100)
146
+ }
147
+ throw AssertionError("page did not set document.title in time")
148
+ }
149
+
150
+ private companion object {
151
+ const val CALLBACK = "pakstr-test://nip55-callback#nip55_event="
152
+ }
153
+ }
@@ -1,116 +1,166 @@
1
1
  (() => {
2
2
 
3
- if (window.nostr) return;
3
+ // Installed by PakstrWebBridge as a document-start script for the app
4
+ // origin only, so it runs before any page script. The native side also
5
+ // rejects messages from subframes; only the top-level app document gets
6
+ // window.nostr and window.pakstr.
7
+ if (window.top !== window) return;
4
8
 
9
+ // Same limit as WebMessageRouter.MAX_MESSAGE_BYTES.
10
+ const MAX_MESSAGE_BYTES = 1024 * 1024;
11
+
12
+ // Injected by addWebMessageListener before this script runs.
13
+ const channel = window.__pakstrNative;
5
14
  const pending = new Map();
6
15
 
7
- function callNative(method, payload) {
16
+ if (channel) {
17
+ channel.addEventListener("message", (event) => {
18
+ let response;
19
+ try {
20
+ response = JSON.parse(event.data);
21
+ } catch (_) {
22
+ return;
23
+ }
24
+ if (!response || response.v !== 1 || typeof response.id !== "string") return;
25
+
26
+ const p = pending.get(response.id);
27
+ if (!p) return;
28
+
29
+ pending.delete(response.id);
30
+
31
+ if (response.ok === true) {
32
+ p.resolve(response.result);
33
+ } else {
34
+ p.reject(response.error ?? {
35
+ error: "SIGNER_ERROR",
36
+ message: "Unknown signer error"
37
+ });
38
+ }
39
+ });
40
+ }
41
+
42
+ function callNative(method, params) {
8
43
  return new Promise((resolve, reject) => {
9
44
 
45
+ if (!channel) {
46
+ reject({
47
+ error: "SIGNER_UNAVAILABLE",
48
+ message: "Pakstr bridge is unavailable"
49
+ });
50
+ return;
51
+ }
52
+
10
53
  const id = crypto.randomUUID();
54
+ let message;
55
+ try {
56
+ message = JSON.stringify({ v: 1, id, method, params });
57
+ } catch (_) {
58
+ reject({ error: "SIGNER_ERROR", message: "Invalid request" });
59
+ return;
60
+ }
61
+ if (new TextEncoder().encode(message).length > MAX_MESSAGE_BYTES) {
62
+ reject({ error: "SIGNER_ERROR", message: "Request is too large" });
63
+ return;
64
+ }
11
65
 
12
66
  pending.set(id, {
13
67
  resolve,
14
68
  reject
15
69
  });
16
70
 
17
- window.AndroidBridge.call(
18
- method,
19
- id,
20
- payload ? JSON.stringify(payload) : ""
21
- );
71
+ channel.postMessage(message);
22
72
  });
23
73
  }
24
74
 
25
- window.__nostrResolve = (id, result) => {
75
+ if (!window.nostr) {
76
+ window.nostr = {
26
77
 
27
- const p = pending.get(id);
28
- if (!p) return;
78
+ getPublicKey: async () => {
79
+ return await callNative("nostr.getPublicKey");
80
+ },
29
81
 
30
- pending.delete(id);
82
+ signEvent: async (event) => {
31
83
 
32
- try {
33
- const parsed = JSON.parse(result);
84
+ const result = await callNative(
85
+ "nostr.signEvent",
86
+ event
87
+ );
34
88
 
35
- if (parsed.error) {
36
- p.reject(parsed);
37
- return;
38
- }
89
+ return JSON.parse(result);
90
+ },
39
91
 
40
- } catch (_) {
41
- // Normal string response
42
- }
92
+ nip04: {
43
93
 
44
- p.resolve(result);
45
- };
94
+ encrypt: async (pubkey, plaintext) => {
46
95
 
47
- window.nostr = {
96
+ return await callNative(
97
+ "nostr.nip04_encrypt",
98
+ {
99
+ pubkey,
100
+ plaintext
101
+ }
102
+ );
103
+ },
48
104
 
49
- getPublicKey: async () => {
50
- return await callNative("getPublicKey");
51
- },
105
+ decrypt: async (pubkey, ciphertext) => {
52
106
 
53
- signEvent: async (event) => {
107
+ return await callNative(
108
+ "nostr.nip04_decrypt",
109
+ {
110
+ pubkey,
111
+ ciphertext
112
+ }
113
+ );
114
+ }
54
115
 
55
- const result = await callNative(
56
- "signEvent",
57
- event
58
- );
116
+ },
59
117
 
60
- return JSON.parse(result);
61
- },
118
+ nip44: {
62
119
 
63
- nip04: {
120
+ encrypt: async (pubkey, plaintext) => {
64
121
 
65
- encrypt: async (pubkey, plaintext) => {
122
+ return await callNative(
123
+ "nostr.nip44_encrypt",
124
+ {
125
+ pubkey,
126
+ plaintext
127
+ }
128
+ );
129
+ },
66
130
 
67
- return await callNative(
68
- "nip04_encrypt",
69
- {
70
- pubkey,
71
- plaintext
72
- }
73
- );
74
- },
131
+ decrypt: async (pubkey, ciphertext) => {
75
132
 
76
- decrypt: async (pubkey, ciphertext) => {
133
+ return await callNative(
134
+ "nostr.nip44_decrypt",
135
+ {
136
+ pubkey,
137
+ ciphertext
138
+ }
139
+ );
140
+ }
77
141
 
78
- return await callNative(
79
- "nip04_decrypt",
80
- {
81
- pubkey,
82
- ciphertext
83
- }
84
- );
85
142
  }
143
+ };
144
+ }
86
145
 
87
- },
88
-
89
- nip44: {
146
+ if (!window.pakstr) {
147
+ window.pakstr = {
90
148
 
91
- encrypt: async (pubkey, plaintext) => {
149
+ version: 1,
92
150
 
93
- return await callNative(
94
- "nip44_encrypt",
95
- {
96
- pubkey,
97
- plaintext
98
- }
99
- );
151
+ getApiBaseUrl: async () => {
152
+ return await callNative("runtime.getApiBaseUrl");
100
153
  },
101
154
 
102
- decrypt: async (pubkey, ciphertext) => {
155
+ setApiBaseUrl: async (url) => {
156
+ if (typeof url !== "string") return false;
157
+ return await callNative("runtime.setApiBaseUrl", { url });
158
+ },
103
159
 
104
- return await callNative(
105
- "nip44_decrypt",
106
- {
107
- pubkey,
108
- ciphertext
109
- }
110
- );
160
+ getNip55CallbackUrl: async () => {
161
+ return await callNative("runtime.getNip55CallbackUrl");
111
162
  }
163
+ };
164
+ }
112
165
 
113
- }
114
- };
115
-
116
- })();
166
+ })();
@@ -0,0 +1,77 @@
1
+ package com.pakstr.app
2
+
3
+ /**
4
+ * The web app's only trusted origin: the bundled LocalServer. The JavaScript
5
+ * bridge and the NIP-07 provider exist only in this origin's top-level frame.
6
+ *
7
+ * Checks take URI components (from android.net.Uri at the call site) so they
8
+ * stay plain Kotlin, and fail closed on anything unexpected.
9
+ */
10
+ internal object AppOrigin {
11
+ private const val SCHEME = "http"
12
+ private const val HOST = "127.0.0.1"
13
+
14
+ /** Origin rule for androidx.webkit (exact scheme, host, and port; never a wildcard). */
15
+ val ORIGIN: String = "$SCHEME://$HOST:${Config.PORT}"
16
+
17
+ fun isTrustedOrigin(
18
+ scheme: String?,
19
+ host: String?,
20
+ port: Int,
21
+ userInfo: String?,
22
+ path: String?
23
+ ): Boolean =
24
+ isAppAuthority(scheme, host, port, userInfo) &&
25
+ (path.isNullOrEmpty() || path == "/")
26
+
27
+ /** Top-level navigation may only stay on the app origin (any path, query, or fragment). */
28
+ fun isNavigationAllowed(
29
+ scheme: String?,
30
+ host: String?,
31
+ port: Int,
32
+ userInfo: String?
33
+ ): Boolean = isAppAuthority(scheme, host, port, userInfo)
34
+
35
+ private fun isAppAuthority(
36
+ scheme: String?,
37
+ host: String?,
38
+ port: Int,
39
+ userInfo: String?
40
+ ): Boolean =
41
+ scheme == SCHEME &&
42
+ host == HOST &&
43
+ port == Config.PORT &&
44
+ userInfo == null
45
+ }
46
+
47
+ internal enum class NavigationDecision {
48
+ ALLOW,
49
+ BLOCK,
50
+ NIP55_SIGN_REQUEST,
51
+ REJECT_SUBFRAME_NIP55
52
+ }
53
+
54
+ /**
55
+ * shouldOverrideUrlLoading policy. `nostrsigner:` requests reach the signer
56
+ * only from the main frame; everything else must stay on the app origin.
57
+ */
58
+ internal fun navigationDecision(
59
+ scheme: String?,
60
+ host: String?,
61
+ port: Int,
62
+ userInfo: String?,
63
+ isForMainFrame: Boolean
64
+ ): NavigationDecision {
65
+ if (scheme.equals("nostrsigner", ignoreCase = true)) {
66
+ return if (isForMainFrame) {
67
+ NavigationDecision.NIP55_SIGN_REQUEST
68
+ } else {
69
+ NavigationDecision.REJECT_SUBFRAME_NIP55
70
+ }
71
+ }
72
+ return if (AppOrigin.isNavigationAllowed(scheme, host, port, userInfo)) {
73
+ NavigationDecision.ALLOW
74
+ } else {
75
+ NavigationDecision.BLOCK
76
+ }
77
+ }
@@ -1,22 +1,22 @@
1
1
  package com.pakstr.app
2
2
 
3
- import android.webkit.JavascriptInterface
4
-
3
+ /**
4
+ * Native side of the `window.pakstr` runtime methods. Not exposed to
5
+ * JavaScript directly: WebMessageRouter calls it only for messages from the
6
+ * app origin's top-level frame.
7
+ */
5
8
  class PakstrBridge(
6
9
  private val runtimeApiBaseSettings: RuntimeApiBaseSettings
7
10
  ) {
8
11
 
9
- @JavascriptInterface
10
12
  fun getNip55CallbackUrl(): String {
11
13
  return Nip55Callback.callbackPrefix
12
14
  }
13
15
 
14
- @JavascriptInterface
15
16
  fun setApiBaseUrl(url: String): Boolean {
16
17
  return runtimeApiBaseSettings.setOverride(url)
17
18
  }
18
19
 
19
- @JavascriptInterface
20
20
  fun getApiBaseUrl(): String? {
21
21
  return runtimeApiBaseSettings.current().url
22
22
  }
@@ -0,0 +1,89 @@
1
+ package com.pakstr.app
2
+
3
+ import android.webkit.WebView
4
+ import androidx.annotation.UiThread
5
+ import androidx.webkit.WebMessageCompat
6
+ import androidx.webkit.WebViewCompat
7
+ import androidx.webkit.WebViewFeature
8
+
9
+ /**
10
+ * Installs the JavaScript bridge with androidx.webkit, scoped to
11
+ * [AppOrigin.ORIGIN]: the message channel object exists only in frames of
12
+ * that origin, and the NIP-07 provider script runs there at document start,
13
+ * before any page script. WebMessageRouter additionally drops messages from
14
+ * subframes and other origins.
15
+ *
16
+ * Fails closed: if the WebView lacks either feature, or installation fails,
17
+ * nothing is exposed and the page has no `window.nostr` or `window.pakstr`.
18
+ */
19
+ internal object PakstrWebBridge {
20
+ const val JS_OBJECT_NAME = "__pakstrNative"
21
+
22
+ fun isSupported(): Boolean =
23
+ WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) &&
24
+ WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
25
+
26
+ /** Must run before the page loads. Returns true only if both parts were installed. */
27
+ @UiThread
28
+ fun install(
29
+ webView: WebView,
30
+ router: WebMessageRouter,
31
+ providerScript: String,
32
+ logError: (String) -> Unit
33
+ ): Boolean {
34
+ // Nested, positive checks: lint accepts feature-gated calls only inside such guards.
35
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
36
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
37
+ val allowedOrigins = setOf(AppOrigin.ORIGIN)
38
+
39
+ try {
40
+ WebViewCompat.addWebMessageListener(
41
+ webView,
42
+ JS_OBJECT_NAME,
43
+ allowedOrigins
44
+ ) { _, message, sourceOrigin, isMainFrame, replyProxy ->
45
+ router.onMessage(
46
+ MessageSource(
47
+ scheme = sourceOrigin.scheme,
48
+ host = sourceOrigin.host,
49
+ port = sourceOrigin.port,
50
+ userInfo = sourceOrigin.userInfo,
51
+ path = sourceOrigin.path,
52
+ isMainFrame = isMainFrame
53
+ ),
54
+ if (message.type == WebMessageCompat.TYPE_STRING) message.data else null
55
+ ) { response ->
56
+ try {
57
+ replyProxy.postMessage(response)
58
+ } catch (_: Exception) {
59
+ // The frame is gone; nobody is waiting for this reply.
60
+ }
61
+ }
62
+ }
63
+ } catch (error: Exception) {
64
+ logError("Unable to install bridge channel: ${error.javaClass.simpleName}")
65
+ return false
66
+ }
67
+
68
+ try {
69
+ WebViewCompat.addDocumentStartJavaScript(
70
+ webView,
71
+ providerScript,
72
+ allowedOrigins
73
+ )
74
+ } catch (error: Exception) {
75
+ try {
76
+ WebViewCompat.removeWebMessageListener(webView, JS_OBJECT_NAME)
77
+ } catch (_: Exception) {
78
+ // Best effort; the WebView is not loaded yet either way.
79
+ }
80
+ logError("Unable to install NIP-07 provider: ${error.javaClass.simpleName}")
81
+ return false
82
+ }
83
+ return true
84
+ }
85
+ }
86
+ logError("WebView lacks WEB_MESSAGE_LISTENER or DOCUMENT_START_SCRIPT; bridge disabled")
87
+ return false
88
+ }
89
+ }