pakstr 0.30.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 +12 -2
- package/android-template/app/build.gradle.kts +1 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/PakstrWebBridgeTest.kt +153 -0
- package/android-template/app/src/main/assets/nip07.js +124 -74
- package/android-template/app/src/main/java/com/pakstr/app/AppOrigin.kt +77 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +5 -5
- package/android-template/app/src/main/java/com/pakstr/app/PakstrWebBridge.kt +89 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebMessageRouter.kt +186 -0
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +65 -49
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +69 -489
- package/android-template/app/src/test/java/com/pakstr/app/AppOriginTest.kt +114 -0
- package/android-template/app/src/test/java/com/pakstr/app/WebMessageRouterTest.kt +279 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeDispatchTest.kt +113 -0
- package/android-template/gradle/libs.versions.toml +2 -0
- package/desktop-runtime/README.md +108 -0
- package/desktop-runtime/dist/main.js +22211 -0
- package/desktop-runtime/dist/preload.js +53 -0
- package/dist/cli.js +17 -3
- package/dist/commands/build.js +1 -0
- package/dist/commands/buildDesktop.js +140 -0
- package/dist/desktop/common/artifacts.js +16 -0
- package/dist/desktop/common/asar.js +60 -0
- package/dist/desktop/common/electron.js +50 -0
- package/dist/desktop/common/files.js +30 -0
- package/dist/desktop/common/fuses.js +62 -0
- package/dist/desktop/common/packager.js +46 -0
- package/dist/desktop/linux.js +425 -0
- package/dist/desktop/macos.js +125 -0
- package/dist/desktop/stage.js +117 -0
- package/dist/desktop/windows.js +384 -0
- package/dist/desktop/zip.js +204 -0
- package/dist/shared/nip46Pairing.js +278 -0
- package/package.json +14 -3
|
@@ -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" }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Pakstr desktop runtime
|
|
2
|
+
|
|
3
|
+
Electron runtime that hosts a Pakstr web app on desktop, mirroring the Android
|
|
4
|
+
shell's contracts. `pakstr build --target macos-arm64|windows-x64|linux-x64`
|
|
5
|
+
stages this bundle with the app (`src/desktop/` in the CLI); the bundle ships
|
|
6
|
+
in the CLI's npm package. All targets package the same bundle; only the menu
|
|
7
|
+
differs (`src/appMenu.js`).
|
|
8
|
+
|
|
9
|
+
## Build
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm --prefix packages/cli ci
|
|
13
|
+
npm --prefix packages/cli run build # tsc, then bundles dist/main.js and dist/preload.js
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`npm --prefix packages/cli/desktop-runtime install` is only needed to run the
|
|
17
|
+
runtime unpackaged with the `electron` dev binary.
|
|
18
|
+
|
|
19
|
+
`dist/main.js` and `dist/preload.js` are self-contained: only `electron` and
|
|
20
|
+
Node built-ins are left external (the build fails otherwise), so a generated
|
|
21
|
+
app needs no `node_modules`. Third-party code and `src/shared` come from the
|
|
22
|
+
CLI package. The `debug` package is compiled out because applesauce logs
|
|
23
|
+
NIP-46 request params through it.
|
|
24
|
+
|
|
25
|
+
## Generated app layout
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
<appRoot>/package.json "main" -> the bundled main.js (preload.js beside it)
|
|
29
|
+
<appRoot>/pakstr-desktop.json { "appId": "com.example.app", "appName": "Example" }
|
|
30
|
+
<appRoot>/web/ staged web assets, incl. pakstr-runtime.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Behaviour
|
|
34
|
+
|
|
35
|
+
- The app is served from `pakstr://app` (standard, secure scheme) with the
|
|
36
|
+
Android SPA fallback. `/api/*` is proxied to the effective API base
|
|
37
|
+
(persisted override, then packaged `apiBase`, then disabled). API cookies
|
|
38
|
+
live in the session cookie jar and never reach the renderer, and neither
|
|
39
|
+
do `Set-Cookie` or `Location`. Redirects are not followed.
|
|
40
|
+
- NIP-98: `X-Forwarded-Host: app` / `X-Forwarded-Proto: pakstr` are added
|
|
41
|
+
only when the event's unique `u` tag is exactly the local request URL.
|
|
42
|
+
- `window.nostr` (plain and writable, like `nip07.js`) and the synchronous
|
|
43
|
+
`window.PakstrBridge.getApiBaseUrl()` / `setApiBaseUrl(url)` exist only in
|
|
44
|
+
the main window's `pakstr://app` top frame. The first `window.nostr` call
|
|
45
|
+
without a signer opens a `nostrconnect://` pairing prompt that uses the
|
|
46
|
+
strict `src/shared/nip46Pairing` module.
|
|
47
|
+
- All renderers are sandboxed with context isolation. Top-level navigation
|
|
48
|
+
stays on `pakstr://app`, `window.open` is denied, and web permissions are
|
|
49
|
+
denied except clipboard write and fullscreen. DevTools are available only
|
|
50
|
+
when unpackaged, and a packaged app exits if launched with a
|
|
51
|
+
remote-debugging or certificate-bypass switch. The runtime never disables
|
|
52
|
+
Chromium's sandbox.
|
|
53
|
+
- All data (cookies, storage, API base override) lives in
|
|
54
|
+
`<appData>/<appId>`. The Electron app name is also the appId, because it
|
|
55
|
+
names the keychain item holding the cookie-encryption key ("<name> Safe
|
|
56
|
+
Storage"): renaming `appName` keeps sessions. The macOS menu shows `appName`.
|
|
57
|
+
|
|
58
|
+
## Windows: not yet verified on a Windows machine
|
|
59
|
+
|
|
60
|
+
Windows packages are cross-built and checked structurally (executable
|
|
61
|
+
metadata, fuses, asar integrity, portable ZIP contents, NSIS output), but
|
|
62
|
+
nothing here runs them. Before relying on `windows-x64`, check on real Windows
|
|
63
|
+
10/11 x64:
|
|
64
|
+
|
|
65
|
+
- The installer runs without admin rights. The Start menu shortcut, the
|
|
66
|
+
Apps & features entry, and the finish-page launch work, and uninstalling
|
|
67
|
+
removes everything except `%APPDATA%\<appId>`.
|
|
68
|
+
- Reinstalling or upgrading over a running app prompts to close it, and an
|
|
69
|
+
upgrade that renames `appName` leaves no old `.exe` or shortcut behind.
|
|
70
|
+
- The portable ZIP runs after extraction, including from a path with spaces
|
|
71
|
+
or non-ASCII characters.
|
|
72
|
+
- The app starts with the asar-integrity and `OnlyLoadAppFromAsar` fuses on.
|
|
73
|
+
`pakstr://app` loads, `/api` proxying, cookies (DPAPI), `window.nostr`,
|
|
74
|
+
NIP-46 pairing, and signer auth links work, and a second launch focuses the
|
|
75
|
+
first window.
|
|
76
|
+
- Taskbar pinning: Electron's default AppUserModelID may not match the
|
|
77
|
+
installer's shortcut (it has no AUMID), so a pin from the Start menu can
|
|
78
|
+
show as a separate taskbar button.
|
|
79
|
+
- SmartScreen shows the unsigned-publisher warning; there is no Authenticode
|
|
80
|
+
signing yet.
|
|
81
|
+
|
|
82
|
+
## Linux: not yet verified on a Linux machine
|
|
83
|
+
|
|
84
|
+
Linux packages are cross-built and checked structurally (pinned AppImage
|
|
85
|
+
runtime, root-owned squashfs with exact modes, desktop entry, icon, portable
|
|
86
|
+
ZIP modes, fuses, asar hashes), but nothing here runs them. Electron enforces
|
|
87
|
+
embedded asar integrity on macOS and Windows only, so on Linux the read-only
|
|
88
|
+
AppImage filesystem and the build-time checks are what protect `app.asar`.
|
|
89
|
+
Before relying on `linux-x64`, check on real x64 distributions (e.g. Ubuntu
|
|
90
|
+
22.04 and 24.04, Fedora, Debian 12):
|
|
91
|
+
|
|
92
|
+
- The AppImage starts with FUSE 3 (`fusermount3`), and with
|
|
93
|
+
`--appimage-extract-and-run` without FUSE; the portable ZIP runs after
|
|
94
|
+
`unzip`, including from a path with spaces.
|
|
95
|
+
- Chromium's sandbox: with unprivileged user namespaces available the app
|
|
96
|
+
starts sandboxed. Where they are restricted (Ubuntu 23.10+ AppArmor) it
|
|
97
|
+
is expected to abort rather than run unsandboxed; record the message. The
|
|
98
|
+
runtime never passes `--no-sandbox`, and `chrome-sandbox` is not setuid.
|
|
99
|
+
- `pakstr://app` loads, `/api` proxying, `window.nostr`, NIP-46 pairing, and
|
|
100
|
+
signer auth links (`xdg-open`) work, and a second launch focuses the first
|
|
101
|
+
window.
|
|
102
|
+
- Cookies are encrypted with the desktop keyring (Secret Service / KWallet);
|
|
103
|
+
without one, Chromium falls back to a fixed key, so check which backend is
|
|
104
|
+
used.
|
|
105
|
+
- The window class / Wayland app id is the appId, so desktop integration
|
|
106
|
+
(AppImageLauncher, appimaged) groups the window with the desktop entry and
|
|
107
|
+
shows the icon. Required system libraries (GTK 3, NSS, libgbm, ALSA, ...)
|
|
108
|
+
are those of any Chromium build and are not bundled.
|