pakstr 0.20.0 → 0.21.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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +4 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +273 -0
  4. package/android-template/app/src/main/assets/www/index.html +0 -6
  5. package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +20 -4
  6. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +136 -12
  7. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Hash.kt +56 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Signer.kt +104 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Verifier.kt +20 -170
  10. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip44Crypto.kt +513 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip46ClientKey.kt +140 -0
  12. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventHasher.kt +3 -28
  13. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventSerializer.kt +74 -0
  14. package/android-template/app/src/main/java/com/pakstr/app/crypto/Secp256k1.kt +100 -0
  15. package/android-template/app/src/main/java/com/pakstr/app/signer/BunkerSigner.kt +32 -7
  16. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +36 -9
  17. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +240 -163
  18. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConfig.kt +175 -0
  19. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt +144 -0
  20. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Client.kt +79 -0
  21. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Models.kt +319 -0
  22. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingClient.kt +159 -0
  23. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingQrCode.kt +35 -0
  24. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingUri.kt +104 -0
  25. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Protocol.kt +695 -0
  26. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46RelayTransport.kt +489 -0
  27. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/RelayNip46Client.kt +403 -0
  28. package/android-template/app/src/main/java/com/pakstr/app/utils/BunkerPairingDialog.kt +82 -0
  29. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +78 -17
  30. package/android-template/app/src/main/res/layout/activity_layout.xml +11 -0
  31. package/android-template/app/src/main/res/layout/dialog_bunker_pairing.xml +75 -0
  32. package/android-template/app/src/main/res/layout/dialog_picker.xml +60 -31
  33. package/android-template/app/src/main/res/values/strings.xml +27 -0
  34. package/android-template/app/src/test/java/com/pakstr/app/LocalServerQueryKeepAliveTest.kt +118 -0
  35. package/android-template/app/src/test/java/com/pakstr/app/crypto/Bip340SignerTest.kt +133 -0
  36. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip44CryptoTest.kt +199 -0
  37. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyProtocolTest.kt +82 -0
  38. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyTest.kt +52 -0
  39. package/android-template/app/src/test/java/com/pakstr/app/crypto/NostrEventSerializerTest.kt +42 -0
  40. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeErrorTest.kt +32 -0
  41. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/BunkerConfigTest.kt +120 -0
  42. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ModelsTest.kt +111 -0
  43. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingClientTest.kt +271 -0
  44. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingQrCodeTest.kt +54 -0
  45. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingUriTest.kt +80 -0
  46. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ProtocolTest.kt +578 -0
  47. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46RelayTransportTest.kt +508 -0
  48. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt +678 -0
  49. package/android-template/gradle/libs.versions.toml +8 -0
  50. package/package.json +1 -1
@@ -0,0 +1,489 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import kotlinx.coroutines.CompletableDeferred
4
+ import kotlinx.coroutines.withTimeoutOrNull
5
+ import okhttp3.OkHttpClient
6
+ import okhttp3.Request
7
+ import okhttp3.Response
8
+ import okhttp3.WebSocket
9
+ import okhttp3.WebSocketListener
10
+ import org.json.JSONArray
11
+ import org.json.JSONObject
12
+ import java.util.LinkedHashMap
13
+ import java.util.concurrent.Executors
14
+ import java.util.concurrent.ScheduledExecutorService
15
+ import java.util.concurrent.ScheduledFuture
16
+ import java.util.concurrent.TimeUnit
17
+ import kotlin.math.min
18
+
19
+ internal interface Nip46RelayConnection {
20
+ fun send(message: String): Boolean
21
+
22
+ fun close()
23
+ }
24
+
25
+ internal fun interface Nip46RelayConnectionFactory : AutoCloseable {
26
+ fun connect(
27
+ relayUrl: String,
28
+ listener: Nip46RelayConnectionListener
29
+ ): Nip46RelayConnection
30
+
31
+ override fun close() = Unit
32
+ }
33
+
34
+ internal interface Nip46RelayConnectionListener {
35
+ fun onOpen()
36
+
37
+ fun onMessage(message: String)
38
+
39
+ fun onClosed()
40
+
41
+ fun onFailure()
42
+ }
43
+
44
+ internal class OkHttpNip46RelayConnectionFactory(
45
+ private val client: OkHttpClient =
46
+ OkHttpClient.Builder()
47
+ .pingInterval(30, TimeUnit.SECONDS)
48
+ .build(),
49
+ private val diagnosticLogger: (String) -> Unit = {}
50
+ ) : Nip46RelayConnectionFactory {
51
+ override fun connect(
52
+ relayUrl: String,
53
+ listener: Nip46RelayConnectionListener
54
+ ): Nip46RelayConnection {
55
+ val connection = OkHttpRelayConnection()
56
+ val socket =
57
+ client.newWebSocket(
58
+ Request.Builder()
59
+ .url(relayUrl)
60
+ .build(),
61
+ object : WebSocketListener() {
62
+ override fun onOpen(
63
+ webSocket: WebSocket,
64
+ response: Response
65
+ ) {
66
+ diagnosticLogger("WebSocket onOpen relay=$relayUrl")
67
+ connection.attach(webSocket)
68
+ listener.onOpen()
69
+ }
70
+
71
+ override fun onMessage(
72
+ webSocket: WebSocket,
73
+ text: String
74
+ ) {
75
+ listener.onMessage(text)
76
+ }
77
+
78
+ override fun onClosed(
79
+ webSocket: WebSocket,
80
+ code: Int,
81
+ reason: String
82
+ ) {
83
+ listener.onClosed()
84
+ }
85
+
86
+ override fun onFailure(
87
+ webSocket: WebSocket,
88
+ t: Throwable,
89
+ response: Response?
90
+ ) {
91
+ diagnosticLogger(
92
+ "WebSocket onFailure relay=$relayUrl " +
93
+ "exception=${t.javaClass.name}: ${t.message.orEmpty()} " +
94
+ "httpCode=${response?.code ?: "none"}"
95
+ )
96
+ listener.onFailure()
97
+ }
98
+ }
99
+ )
100
+ connection.attach(socket)
101
+ return connection
102
+ }
103
+
104
+ override fun close() {
105
+ client.dispatcher.executorService.shutdown()
106
+ client.connectionPool.evictAll()
107
+ }
108
+
109
+ private class OkHttpRelayConnection : Nip46RelayConnection {
110
+ @Volatile
111
+ private var socket: WebSocket? = null
112
+
113
+ fun attach(webSocket: WebSocket) {
114
+ socket = webSocket
115
+ }
116
+
117
+ override fun send(message: String): Boolean {
118
+ return socket?.send(message) == true
119
+ }
120
+
121
+ override fun close() {
122
+ socket?.close(NORMAL_CLOSURE, "")
123
+ socket = null
124
+ }
125
+ }
126
+
127
+ private companion object {
128
+ const val NORMAL_CLOSURE = 1000
129
+ }
130
+ }
131
+
132
+ internal interface Nip46Transport : AutoCloseable {
133
+ fun start(listener: (String) -> Unit)
134
+
135
+ fun publish(requestId: String, eventJson: String)
136
+
137
+ fun cancel(requestId: String)
138
+ }
139
+
140
+ internal class Nip46RelayTransport(
141
+ relayUrls: List<String>,
142
+ private val remoteSignerPubkey: String?,
143
+ private val clientPubkey: String,
144
+ private val connectionFactory: Nip46RelayConnectionFactory,
145
+ private val scheduler: ScheduledExecutorService =
146
+ Executors.newSingleThreadScheduledExecutor(),
147
+ private val reconnectBaseDelayMillis: Long = 1_000L,
148
+ private val reconnectMaximumDelayMillis: Long = 30_000L,
149
+ private val diagnosticLogger: (String) -> Unit = {}
150
+ ) : Nip46Transport {
151
+ private val lock = Any()
152
+ private val relays =
153
+ relayUrls.distinct().associateWith(::RelayState)
154
+ private val publications = LinkedHashMap<String, String>()
155
+ private val rememberedEventIds =
156
+ object : LinkedHashMap<String, Unit>(16, 0.75f, true) {
157
+ override fun removeEldestEntry(
158
+ eldest: MutableMap.MutableEntry<String, Unit>
159
+ ): Boolean {
160
+ return size > MAXIMUM_REMEMBERED_EVENT_IDS
161
+ }
162
+ }
163
+
164
+ private val readiness = CompletableDeferred<Boolean>()
165
+ private var eventListener: ((String) -> Unit)? = null
166
+ private var closed = false
167
+
168
+ override fun start(listener: (String) -> Unit) {
169
+ val relayStates =
170
+ synchronized(lock) {
171
+ check(!closed) { "NIP-46 relay transport is closed" }
172
+ eventListener = listener
173
+ relays.values.toList()
174
+ }
175
+ relayStates.forEach(::connect)
176
+ }
177
+
178
+ suspend fun awaitReady(timeoutMillis: Long): Boolean {
179
+ require(timeoutMillis > 0) { "Readiness timeout must be positive" }
180
+ val result = withTimeoutOrNull(timeoutMillis) {
181
+ readiness.await()
182
+ }
183
+ if (result == null) {
184
+ diagnosticLogger("Relay readiness timed out after ${timeoutMillis}ms")
185
+ }
186
+ return result ?: false
187
+ }
188
+
189
+ override fun publish(
190
+ requestId: String,
191
+ eventJson: String
192
+ ) {
193
+ val sockets =
194
+ synchronized(lock) {
195
+ check(!closed) { "NIP-46 relay transport is closed" }
196
+ publications[requestId] = eventJson
197
+ relays.values.mapNotNull { state ->
198
+ state.connection.takeIf { state.connected }
199
+ }
200
+ }
201
+ val message = publishMessage(eventJson)
202
+ sockets.forEach { connection ->
203
+ connection.send(message)
204
+ }
205
+ }
206
+
207
+ override fun cancel(requestId: String) {
208
+ synchronized(lock) {
209
+ publications.remove(requestId)
210
+ }
211
+ }
212
+
213
+ override fun close() {
214
+ val states =
215
+ synchronized(lock) {
216
+ if (closed) {
217
+ return
218
+ }
219
+ closed = true
220
+ readiness.complete(false)
221
+ eventListener = null
222
+ publications.clear()
223
+ rememberedEventIds.clear()
224
+ relays.values.toList()
225
+ }
226
+ states.forEach { state ->
227
+ state.reconnect?.cancel(false)
228
+ state.connection?.send(closeSubscriptionMessage(state.subscriptionId))
229
+ state.connection?.close()
230
+ }
231
+ scheduler.shutdownNow()
232
+ connectionFactory.close()
233
+ }
234
+
235
+ private fun connect(state: RelayState) {
236
+ val generation =
237
+ synchronized(lock) {
238
+ if (closed || state.connecting || state.connected) {
239
+ return
240
+ }
241
+ state.connecting = true
242
+ state.opened = false
243
+ state.reconnect = null
244
+ ++state.generation
245
+ }
246
+ diagnosticLogger(
247
+ "Relay connection attempt relay=${state.relayUrl} " +
248
+ "attempt=${state.reconnectAttempts + 1}"
249
+ )
250
+ val listener = listener(state, generation)
251
+ val connection =
252
+ try {
253
+ connectionFactory.connect(state.relayUrl, listener)
254
+ } catch (error: Exception) {
255
+ diagnosticLogger(
256
+ "Relay connection failed relay=${state.relayUrl} " +
257
+ "exception=${error.javaClass.name}: ${error.message.orEmpty()}"
258
+ )
259
+ synchronized(lock) {
260
+ if (state.generation == generation) {
261
+ state.connecting = false
262
+ }
263
+ }
264
+ scheduleReconnect(state)
265
+ return
266
+ }
267
+ synchronized(lock) {
268
+ if (closed || state.generation != generation) {
269
+ connection.close()
270
+ } else {
271
+ state.connection = connection
272
+ completeOpen(state, connection)
273
+ }
274
+ }
275
+ }
276
+
277
+ private fun listener(
278
+ state: RelayState,
279
+ generation: Long
280
+ ): Nip46RelayConnectionListener {
281
+ return object : Nip46RelayConnectionListener {
282
+ override fun onOpen() {
283
+ synchronized(lock) {
284
+ if (closed || state.generation != generation) {
285
+ return
286
+ }
287
+ state.opened = true
288
+ state.connection?.let { connection ->
289
+ completeOpen(state, connection)
290
+ }
291
+ }
292
+ }
293
+
294
+ override fun onMessage(message: String) {
295
+ receiveRelayMessage(state, generation, message)
296
+ }
297
+
298
+ override fun onClosed() {
299
+ disconnected(state, generation)
300
+ }
301
+
302
+ override fun onFailure() {
303
+ disconnected(state, generation)
304
+ }
305
+ }
306
+ }
307
+
308
+ private fun completeOpen(
309
+ state: RelayState,
310
+ connection: Nip46RelayConnection
311
+ ) {
312
+ if (!state.opened || state.connected) {
313
+ return
314
+ }
315
+ val subscribed =
316
+ try {
317
+ connection.send(subscriptionMessage(state.subscriptionId)).also { sent ->
318
+ diagnosticLogger(
319
+ "Pairing REQ send relay=${state.relayUrl} result=$sent"
320
+ )
321
+ }
322
+ } catch (error: Exception) {
323
+ diagnosticLogger(
324
+ "Pairing REQ send exception relay=${state.relayUrl} " +
325
+ "exception=${error.javaClass.name}: ${error.message.orEmpty()}"
326
+ )
327
+ false
328
+ }
329
+ if (!subscribed) {
330
+ disconnected(state, state.generation)
331
+ connection.close()
332
+ return
333
+ }
334
+ state.connecting = false
335
+ state.connected = true
336
+ state.reconnectAttempts = 0
337
+ readiness.complete(true)
338
+ publications.values.forEach { eventJson ->
339
+ connection.send(publishMessage(eventJson))
340
+ }
341
+ }
342
+
343
+ private fun receiveRelayMessage(
344
+ state: RelayState,
345
+ generation: Long,
346
+ message: String
347
+ ) {
348
+ val eventJson = parseEventMessage(message, state.subscriptionId)
349
+ ?: return
350
+ val eventId =
351
+ try {
352
+ JSONObject(eventJson).getString("id")
353
+ } catch (_: Exception) {
354
+ return
355
+ }
356
+ if (!eventId.matches(EVENT_ID_PATTERN)) {
357
+ return
358
+ }
359
+ val listener =
360
+ synchronized(lock) {
361
+ if (
362
+ closed ||
363
+ state.generation != generation ||
364
+ rememberedEventIds.put(eventId, Unit) != null
365
+ ) {
366
+ return
367
+ }
368
+ eventListener
369
+ }
370
+ listener?.invoke(eventJson)
371
+ }
372
+
373
+ private fun disconnected(
374
+ state: RelayState,
375
+ generation: Long
376
+ ) {
377
+ val shouldReconnect =
378
+ synchronized(lock) {
379
+ if (closed || state.generation != generation) {
380
+ false
381
+ } else {
382
+ state.connecting = false
383
+ state.opened = false
384
+ state.connected = false
385
+ state.connection = null
386
+ true
387
+ }
388
+ }
389
+ if (shouldReconnect) {
390
+ scheduleReconnect(state)
391
+ }
392
+ }
393
+
394
+ private fun scheduleReconnect(state: RelayState) {
395
+ synchronized(lock) {
396
+ if (closed || state.reconnect?.isDone == false) {
397
+ return
398
+ }
399
+ val exponent = min(state.reconnectAttempts, MAX_BACKOFF_EXPONENT)
400
+ val multiplier = 1L shl exponent
401
+ val delay = min(
402
+ reconnectMaximumDelayMillis,
403
+ reconnectBaseDelayMillis * multiplier
404
+ )
405
+ state.reconnectAttempts++
406
+ diagnosticLogger(
407
+ "Relay reconnect scheduled relay=${state.relayUrl} " +
408
+ "attempt=${state.reconnectAttempts + 1} delayMs=$delay"
409
+ )
410
+ state.reconnect =
411
+ scheduler.schedule(
412
+ { connect(state) },
413
+ delay,
414
+ TimeUnit.MILLISECONDS
415
+ )
416
+ }
417
+ }
418
+
419
+ private fun subscriptionMessage(subscriptionId: String): String {
420
+ return JSONArray()
421
+ .put("REQ")
422
+ .put(subscriptionId)
423
+ .put(
424
+ JSONObject()
425
+ .put("kinds", JSONArray().put(NIP46_EVENT_KIND))
426
+ .put("#p", JSONArray().put(clientPubkey))
427
+ .apply {
428
+ remoteSignerPubkey?.let { pubkey ->
429
+ put("authors", JSONArray().put(pubkey))
430
+ }
431
+ }
432
+ )
433
+ .toString()
434
+ }
435
+
436
+ private fun publishMessage(eventJson: String): String {
437
+ return JSONArray()
438
+ .put("EVENT")
439
+ .put(JSONObject(eventJson))
440
+ .toString()
441
+ }
442
+
443
+ private fun closeSubscriptionMessage(subscriptionId: String): String {
444
+ return JSONArray()
445
+ .put("CLOSE")
446
+ .put(subscriptionId)
447
+ .toString()
448
+ }
449
+
450
+ private fun parseEventMessage(
451
+ message: String,
452
+ subscriptionId: String
453
+ ): String? {
454
+ return try {
455
+ val frame = JSONArray(message)
456
+ if (
457
+ frame.length() != 3 ||
458
+ frame.optString(0) != "EVENT" ||
459
+ frame.optString(1) != subscriptionId
460
+ ) {
461
+ null
462
+ } else {
463
+ frame.optJSONObject(2)?.toString()
464
+ }
465
+ } catch (_: Exception) {
466
+ null
467
+ }
468
+ }
469
+
470
+ private class RelayState(
471
+ val relayUrl: String
472
+ ) {
473
+ val subscriptionId = "nip46-${relayUrl.hashCode().toUInt().toString(16)}"
474
+ var generation = 0L
475
+ var connection: Nip46RelayConnection? = null
476
+ var reconnect: ScheduledFuture<*>? = null
477
+ var reconnectAttempts = 0
478
+ var connecting = false
479
+ var opened = false
480
+ var connected = false
481
+ }
482
+
483
+ private companion object {
484
+ const val NIP46_EVENT_KIND = 24133
485
+ const val MAX_BACKOFF_EXPONENT = 20
486
+ const val MAXIMUM_REMEMBERED_EVENT_IDS = 2_048
487
+ val EVENT_ID_PATTERN = Regex("[0-9a-f]{64}")
488
+ }
489
+ }