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.
@@ -0,0 +1,279 @@
1
+ package com.pakstr.app
2
+
3
+ import com.pakstr.app.signer.NostrBridge
4
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
5
+ import com.pakstr.app.signer.interfaces.NostrSigner
6
+ import kotlinx.coroutines.CoroutineScope
7
+ import kotlinx.coroutines.Dispatchers
8
+ import org.json.JSONObject
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Assert.assertNull
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Test
14
+
15
+ class WebMessageRouterTest {
16
+ private val trusted = MessageSource("http", "127.0.0.1", Config.PORT, null, null, isMainFrame = true)
17
+
18
+ @Test
19
+ fun requestFromTheAppTopFrameIsAnsweredToTheSender() {
20
+ val bridge = Harness()
21
+
22
+ val replies = bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
23
+
24
+ assertEquals(1, replies.size)
25
+ val reply = JSONObject(replies.single())
26
+ assertEquals(1, reply.getInt("v"))
27
+ assertEquals("id-1", reply.getString("id"))
28
+ assertTrue(reply.getBoolean("ok"))
29
+ assertEquals(PUBKEY, reply.getString("result"))
30
+ assertEquals(listOf("getPublicKey"), bridge.signer.calls)
31
+ }
32
+
33
+ @Test
34
+ fun subframesAreRejectedWithoutReachingTheSigner() {
35
+ val bridge = Harness()
36
+
37
+ val replies = bridge.send(trusted.copy(isMainFrame = false), request("id-1", "nostr.signEvent", EVENT))
38
+
39
+ assertTrue(replies.isEmpty())
40
+ assertTrue(bridge.signer.calls.isEmpty())
41
+ assertEquals(0, bridge.handled)
42
+ }
43
+
44
+ @Test
45
+ fun untrustedOriginsAreRejectedWithoutReachingAnyHandler() {
46
+ val untrusted = listOf(
47
+ trusted.copy(scheme = "https"),
48
+ trusted.copy(port = Config.PORT + 1),
49
+ trusted.copy(host = "localhost"),
50
+ trusted.copy(host = "evil.example"),
51
+ trusted.copy(userInfo = "user"),
52
+ trusted.copy(path = "/index.html"),
53
+ MessageSource(null, null, -1, null, null, isMainFrame = true)
54
+ )
55
+ for (source in untrusted) {
56
+ val bridge = Harness()
57
+ for (method in listOf("nostr.nip44_decrypt", "runtime.setApiBaseUrl", "runtime.getApiBaseUrl")) {
58
+ val replies = bridge.send(
59
+ source,
60
+ request("id-1", method, JSONObject().put("url", "https://evil.example"))
61
+ )
62
+ assertTrue("$source $method", replies.isEmpty())
63
+ }
64
+ assertEquals(source.toString(), 0, bridge.handled)
65
+ assertNull(bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
66
+ }
67
+ }
68
+
69
+ @Test
70
+ fun nonStringOversizedAndMalformedMessagesAreDropped() {
71
+ val bridge = Harness()
72
+ val oversized = request(
73
+ "id-1",
74
+ "nostr.nip44_encrypt",
75
+ JSONObject().put("pubkey", PUBKEY).put("plaintext", "x".repeat(WebMessageRouter.MAX_MESSAGE_BYTES))
76
+ )
77
+ val dropped = listOf(
78
+ null,
79
+ oversized,
80
+ "",
81
+ "not json",
82
+ "[1,2,3]",
83
+ JSONObject().put("id", "id-1").put("method", "nostr.getPublicKey").toString(),
84
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":2"),
85
+ request("id-1", "nostr.getPublicKey").replace("\"v\":1", "\"v\":\"1\""),
86
+ JSONObject().put("v", 1).put("method", "nostr.getPublicKey").toString(),
87
+ request("bad id", "nostr.getPublicKey"),
88
+ request("x".repeat(65), "nostr.getPublicKey"),
89
+ JSONObject().put("v", 1).put("id", 7).put("method", "nostr.getPublicKey").toString()
90
+ )
91
+ for (message in dropped) {
92
+ assertTrue(message?.take(80).toString(), bridge.send(trusted, message).isEmpty())
93
+ }
94
+ assertEquals(0, bridge.handled)
95
+ }
96
+
97
+ @Test
98
+ fun multiByteMessagesAreMeasuredInUtf8Bytes() {
99
+ val bridge = Harness()
100
+ // Under the limit in UTF-16 characters, over it in UTF-8 bytes.
101
+ val plaintext = "é".repeat(WebMessageRouter.MAX_MESSAGE_BYTES / 2)
102
+ val message = request("id-1", "nostr.nip44_encrypt", JSONObject().put("pubkey", PUBKEY).put("plaintext", plaintext))
103
+
104
+ assertTrue(message.length <= WebMessageRouter.MAX_MESSAGE_BYTES)
105
+ assertTrue(bridge.send(trusted, message).isEmpty())
106
+ }
107
+
108
+ @Test
109
+ fun invalidRequestsWithAValidIdGetAnErrorReply() {
110
+ val bridge = Harness()
111
+ val noMethod = JSONObject().put("v", 1).put("id", "id-1").toString()
112
+ val arrayParams = JSONObject().put("v", 1).put("id", "id-2").put("method", "nostr.signEvent")
113
+ .put("params", org.json.JSONArray().put(1)).toString()
114
+
115
+ for ((message, id) in listOf(noMethod to "id-1", arrayParams to "id-2")) {
116
+ val reply = JSONObject(bridge.send(trusted, message).single())
117
+ assertEquals(id, reply.getString("id"))
118
+ assertFalse(reply.getBoolean("ok"))
119
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
120
+ assertEquals("Invalid request", reply.getJSONObject("error").getString("message"))
121
+ }
122
+ assertEquals(0, bridge.handled)
123
+ }
124
+
125
+ @Test
126
+ fun unknownMethodsAreRejected() {
127
+ val bridge = Harness()
128
+ for (method in listOf("nostr.unknown", "nostr.", "runtime.reset", "AndroidBridge.call", "debug.unknown")) {
129
+ val reply = JSONObject(bridge.send(trusted, request("id-1", method)).single())
130
+ assertFalse(method, reply.getBoolean("ok"))
131
+ assertEquals("SIGNER_ERROR", reply.getJSONObject("error").getString("error"))
132
+ assertEquals("Unsupported method", reply.getJSONObject("error").getString("message"))
133
+ }
134
+ assertTrue(bridge.signer.calls.isEmpty())
135
+ }
136
+
137
+ @Test
138
+ fun signerFailuresKeepTheirErrorCodes() {
139
+ val unavailable = Harness(FakeSigner(failure = SignerUnavailableException("No signer is paired")))
140
+ val unavailableReply = JSONObject(unavailable.send(trusted, request("id-1", "nostr.getPublicKey")).single())
141
+ assertEquals("SIGNER_UNAVAILABLE", unavailableReply.getJSONObject("error").getString("error"))
142
+ assertEquals("No signer is paired", unavailableReply.getJSONObject("error").getString("message"))
143
+
144
+ val failing = Harness(FakeSigner(failure = IllegalStateException("rejected")))
145
+ val failingReply = JSONObject(failing.send(trusted, request("id-1", "nostr.signEvent", EVENT)).single())
146
+ assertFalse(failingReply.getBoolean("ok"))
147
+ assertEquals("SIGNER_ERROR", failingReply.getJSONObject("error").getString("error"))
148
+ assertEquals("rejected", failingReply.getJSONObject("error").getString("message"))
149
+ }
150
+
151
+ @Test
152
+ fun plaintextThatLooksLikeAnErrorObjectIsStillAResult() {
153
+ val plaintext = """{"error":"x","message":"y"}"""
154
+ val bridge = Harness(FakeSigner(decrypted = plaintext))
155
+
156
+ val reply = JSONObject(
157
+ bridge.send(
158
+ trusted,
159
+ request("id-1", "nostr.nip44_decrypt", JSONObject().put("pubkey", PUBKEY).put("ciphertext", "c"))
160
+ ).single()
161
+ )
162
+
163
+ assertTrue(reply.getBoolean("ok"))
164
+ assertEquals(plaintext, reply.getString("result"))
165
+ }
166
+
167
+ @Test
168
+ fun runtimeMethodsUseTheRuntimeApiBaseSettings() {
169
+ val bridge = Harness()
170
+
171
+ val set = JSONObject(
172
+ bridge.send(trusted, request("id-1", "runtime.setApiBaseUrl", JSONObject().put("url", "https://api.example.com/"))).single()
173
+ )
174
+ assertTrue(set.getBoolean("result"))
175
+ val get = JSONObject(bridge.send(trusted, request("id-2", "runtime.getApiBaseUrl")).single())
176
+ assertEquals("https://api.example.com", get.getString("result"))
177
+
178
+ for (params in listOf(JSONObject().put("url", "http://insecure.example.com"), JSONObject().put("url", 5), null)) {
179
+ val rejected = JSONObject(bridge.send(trusted, request("id-3", "runtime.setApiBaseUrl", params)).single())
180
+ assertTrue(rejected.getBoolean("ok"))
181
+ assertFalse(rejected.getBoolean("result"))
182
+ }
183
+ assertEquals("https://api.example.com", bridge.preferences.values[RuntimeApiBaseSettings.API_BASE_OVERRIDE_KEY])
184
+
185
+ val callback = JSONObject(bridge.send(trusted, request("id-4", "runtime.getNip55CallbackUrl")).single())
186
+ assertEquals(Nip55Callback.callbackPrefix, callback.getString("result"))
187
+ }
188
+
189
+ @Test
190
+ fun debugLogIsANotificationWithoutAReply() {
191
+ val bridge = Harness()
192
+ val message = JSONObject().put("v", 1).put("method", WebMessageRouter.DEBUG_LOG_METHOD)
193
+ .put("params", JSONObject().put("level", "WARN").put("message", "hello")).toString()
194
+
195
+ assertTrue(bridge.send(trusted, message).isEmpty())
196
+ assertEquals(listOf("WARN" to "hello"), bridge.debugLogs)
197
+
198
+ assertTrue(bridge.send(trusted.copy(isMainFrame = false), message).isEmpty())
199
+ assertEquals(1, bridge.debugLogs.size)
200
+ }
201
+
202
+ @Test
203
+ fun repliesArePostedThroughTheMainThreadHook() {
204
+ var posted = 0
205
+ val bridge = Harness(runOnMain = { block -> posted++; block() })
206
+
207
+ bridge.send(trusted, request("id-1", "nostr.getPublicKey"))
208
+ bridge.send(trusted, JSONObject().put("v", 1).put("id", "id-2").toString())
209
+
210
+ assertEquals(2, posted)
211
+ }
212
+
213
+ private fun request(id: String, method: String, params: JSONObject? = null): String =
214
+ JSONObject().put("v", 1).put("id", id).put("method", method)
215
+ .apply { if (params != null) put("params", params) }
216
+ .toString()
217
+
218
+ private class Harness(
219
+ val signer: FakeSigner = FakeSigner(),
220
+ runOnMain: (() -> Unit) -> Unit = { block -> block() }
221
+ ) {
222
+ val preferences = FakePreferences()
223
+ val debugLogs = mutableListOf<Pair<String, String>>()
224
+ var handled = 0
225
+ private val methods = PakstrBridgeMethods(
226
+ nostr = NostrBridge(signerProvider = { signer }),
227
+ runtime = PakstrBridge(RuntimeApiBaseSettings.createForTesting(preferences) { null }),
228
+ debugLog = { level, message -> debugLogs += level to message }
229
+ )
230
+ private val router = WebMessageRouter(
231
+ handler = { method, params -> handled++; methods.handle(method, params) },
232
+ scope = CoroutineScope(Dispatchers.Unconfined),
233
+ runOnMain = runOnMain
234
+ )
235
+
236
+ fun send(source: MessageSource, data: String?): List<String> {
237
+ val replies = mutableListOf<String>()
238
+ router.onMessage(source, data) { replies += it }
239
+ return replies
240
+ }
241
+ }
242
+
243
+ private class FakeSigner(
244
+ private val failure: Exception? = null,
245
+ private val decrypted: String = "plaintext"
246
+ ) : NostrSigner {
247
+ val calls = mutableListOf<String>()
248
+
249
+ private fun record(name: String) {
250
+ calls += name
251
+ failure?.let { throw it }
252
+ }
253
+
254
+ override suspend fun getPublicKey(): String = PUBKEY.also { record("getPublicKey") }
255
+ override suspend fun signEvent(eventJson: String): String = eventJson.also { record("signEvent") }
256
+ override suspend fun nip04Encrypt(pubkey: String, plaintext: String) = "c04".also { record("nip04Encrypt") }
257
+ override suspend fun nip04Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip04Decrypt") }
258
+ override suspend fun nip44Encrypt(pubkey: String, plaintext: String) = "c44".also { record("nip44Encrypt") }
259
+ override suspend fun nip44Decrypt(pubkey: String, ciphertext: String) = decrypted.also { record("nip44Decrypt") }
260
+ }
261
+
262
+ private class FakePreferences : RuntimeApiBaseSettings.Preferences {
263
+ val values = mutableMapOf<String, String>()
264
+ override fun getString(key: String): String? = values[key]
265
+ override fun putString(key: String, value: String): Boolean {
266
+ values[key] = value
267
+ return true
268
+ }
269
+ override fun remove(key: String): Boolean {
270
+ values.remove(key)
271
+ return true
272
+ }
273
+ }
274
+
275
+ private companion object {
276
+ val PUBKEY = "a".repeat(64)
277
+ val EVENT: JSONObject = JSONObject().put("kind", 1).put("created_at", 1).put("tags", org.json.JSONArray()).put("content", "hi")
278
+ }
279
+ }
@@ -0,0 +1,113 @@
1
+ package com.pakstr.app.signer
2
+
3
+ import com.pakstr.app.signer.interfaces.NostrSigner
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.assertThrows
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class NostrBridgeDispatchTest {
13
+ private val pubkey = "b".repeat(64)
14
+
15
+ @Test
16
+ fun each_method_reaches_the_matching_signer_call_with_its_arguments() {
17
+ val signer = RecordingSigner()
18
+ val bridge = NostrBridge(signerProvider = { signer })
19
+ val event = JSONObject()
20
+ .put("kind", 1)
21
+ .put("created_at", 1_700_000_000)
22
+ .put("tags", JSONArray().put(JSONArray().put("t").put("x")))
23
+ .put("content", "hi")
24
+
25
+ runBlocking {
26
+ assertEquals("pk", bridge.handle("getPublicKey", null))
27
+ assertEquals("signed", bridge.handle("signEvent", event))
28
+ assertEquals("c04", bridge.handle("nip04_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "p")))
29
+ assertEquals("p04", bridge.handle("nip04_decrypt", JSONObject().put("pubkey", pubkey).put("ciphertext", "c")))
30
+ assertEquals("c44", bridge.handle("nip44_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "p")))
31
+ assertEquals("p44", bridge.handle("nip44_decrypt", JSONObject().put("pubkey", pubkey).put("ciphertext", "c")))
32
+ }
33
+
34
+ assertEquals(
35
+ listOf(
36
+ listOf("getPublicKey"),
37
+ listOf("signEvent", event.toString()),
38
+ listOf("nip04Encrypt", pubkey, "p"),
39
+ listOf("nip04Decrypt", pubkey, "c"),
40
+ listOf("nip44Encrypt", pubkey, "p"),
41
+ listOf("nip44Decrypt", pubkey, "c")
42
+ ),
43
+ signer.calls
44
+ )
45
+ assertEquals(event.toString(), JSONObject(signer.calls[1][1]).toString())
46
+ }
47
+
48
+ @Test
49
+ fun invalid_arguments_fail_before_the_signer_is_requested() {
50
+ var requested = 0
51
+ val bridge = NostrBridge(signerProvider = { requested++; RecordingSigner() })
52
+
53
+ val cases = listOf(
54
+ Triple("signEvent", null, "Event payload is empty"),
55
+ Triple("nip04_encrypt", null, "NIP04 encrypt payload is empty"),
56
+ Triple("nip04_decrypt", null, "NIP04 decrypt payload is empty"),
57
+ Triple("nip04_encrypt", JSONObject().put("pubkey", pubkey), null),
58
+ Triple("nip44_encrypt", null, null),
59
+ Triple("nip44_decrypt", JSONObject().put("ciphertext", "c"), null)
60
+ )
61
+ for ((method, params, message) in cases) {
62
+ val error = assertThrows(Exception::class.java) {
63
+ runBlocking { bridge.handle(method, params) }
64
+ }
65
+ if (message != null) assertEquals(method, message, error.message)
66
+ }
67
+ assertEquals(0, requested)
68
+ }
69
+
70
+ @Test
71
+ fun unsupported_methods_are_rejected_without_a_signer() {
72
+ var requested = 0
73
+ val bridge = NostrBridge(signerProvider = { requested++; RecordingSigner() })
74
+
75
+ for (method in listOf("", "call", "debugLog", "getPublicKey ", "nip04Encrypt", "__proto__")) {
76
+ val error = assertThrows(UnsupportedBridgeMethodException::class.java) {
77
+ runBlocking { bridge.handle(method, null) }
78
+ }
79
+ assertEquals("Unsupported method", error.message)
80
+ }
81
+ assertEquals(0, requested)
82
+ }
83
+
84
+ @Test
85
+ fun failures_are_logged_and_rethrown_without_payloads() {
86
+ val logs = mutableListOf<String>()
87
+ val bridge = NostrBridge(
88
+ signerProvider = { throw IllegalStateException("boom") },
89
+ log = { logs += it },
90
+ logError = { message, _ -> logs += message }
91
+ )
92
+
93
+ assertThrows(IllegalStateException::class.java) {
94
+ runBlocking {
95
+ bridge.handle("nip44_encrypt", JSONObject().put("pubkey", pubkey).put("plaintext", "secret text"))
96
+ }
97
+ }
98
+
99
+ assertEquals(listOf("Request: nip44_encrypt", "nip44_encrypt failed"), logs)
100
+ assertTrue(logs.none { "secret text" in it })
101
+ }
102
+
103
+ private class RecordingSigner : NostrSigner {
104
+ val calls = mutableListOf<List<String>>()
105
+
106
+ override suspend fun getPublicKey(): String = "pk".also { calls += listOf("getPublicKey") }
107
+ override suspend fun signEvent(eventJson: String): String = "signed".also { calls += listOf("signEvent", eventJson) }
108
+ override suspend fun nip04Encrypt(pubkey: String, plaintext: String) = "c04".also { calls += listOf("nip04Encrypt", pubkey, plaintext) }
109
+ override suspend fun nip04Decrypt(pubkey: String, ciphertext: String) = "p04".also { calls += listOf("nip04Decrypt", pubkey, ciphertext) }
110
+ override suspend fun nip44Encrypt(pubkey: String, plaintext: String) = "c44".also { calls += listOf("nip44Encrypt", pubkey, plaintext) }
111
+ override suspend fun nip44Decrypt(pubkey: String, ciphertext: String) = "p44".also { calls += listOf("nip44Decrypt", pubkey, ciphertext) }
112
+ }
113
+ }
@@ -17,6 +17,7 @@ json = "20250517"
17
17
  okhttp = "4.12.0"
18
18
  securityCrypto = "1.1.0"
19
19
  zxingCore = "3.5.3"
20
+ webkit = "1.18.0"
20
21
 
21
22
  [libraries]
22
23
  androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -42,6 +43,7 @@ json = { module = "org.json:json", version.ref = "json" }
42
43
  okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
43
44
  androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
44
45
  zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
46
+ androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
45
47
 
46
48
  [plugins]
47
49
  android-application = { id = "com.android.application", version.ref = "agp" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.31.0",
3
+ "version": "1.0.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",