pakstr 0.19.3 → 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 (58) 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/dist/cli.js +8 -0
  51. package/dist/commands/init.js +16 -0
  52. package/dist/commands/nsite.js +38 -0
  53. package/dist/commands/publish.js +50 -2
  54. package/dist/commands/run.js +5 -5
  55. package/dist/core/blossom.js +2 -2
  56. package/dist/core/nsite.js +176 -0
  57. package/dist/core/pakstrConfig.js +85 -6
  58. package/package.json +1 -1
@@ -0,0 +1,678 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.pakstr.app.crypto.Bip340Hash
4
+ import com.pakstr.app.crypto.Nip46ClientKey
5
+ import com.pakstr.app.signer.NostrEventBuilder
6
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
7
+ import kotlinx.coroutines.async
8
+ import kotlinx.coroutines.cancelAndJoin
9
+ import kotlinx.coroutines.coroutineScope
10
+ import kotlinx.coroutines.delay
11
+ import kotlinx.coroutines.runBlocking
12
+ import kotlinx.coroutines.withTimeout
13
+ import org.json.JSONArray
14
+ import org.json.JSONObject
15
+ import org.junit.After
16
+ import org.junit.Assert.assertEquals
17
+ import org.junit.Assert.assertFalse
18
+ import org.junit.Assert.assertThrows
19
+ import org.junit.Assert.assertTrue
20
+ import org.junit.Before
21
+ import org.junit.Test
22
+ import java.util.concurrent.AbstractExecutorService
23
+ import java.util.concurrent.Callable
24
+ import java.util.concurrent.Executors
25
+ import java.util.concurrent.ScheduledFuture
26
+ import java.util.concurrent.TimeUnit
27
+
28
+ class RelayNip46ClientTest {
29
+ private lateinit var clientKey: Nip46ClientKey
30
+ private lateinit var communicationKey: Nip46ClientKey
31
+ private lateinit var userKey: Nip46ClientKey
32
+ private lateinit var config: BunkerConfig
33
+ private lateinit var protocol: Nip46Protocol
34
+ private lateinit var transport: FakeTransport
35
+ private lateinit var client: RelayNip46Client
36
+ private lateinit var requestIds: Iterator<String>
37
+
38
+ @Before
39
+ fun setup() {
40
+ clientKey = Nip46ClientKey.fromPrivateKey(privateKey(1))
41
+ communicationKey = Nip46ClientKey.fromPrivateKey(privateKey(2))
42
+ userKey = Nip46ClientKey.fromPrivateKey(privateKey(3))
43
+ requestIds = (1..32).map(::requestId).iterator()
44
+ config =
45
+ BunkerConfig.parse(
46
+ "bunker://${communicationKey.publicKeyHex}" +
47
+ "?relay=wss://relay.example&secret=$PAIRING_SECRET"
48
+ )
49
+ protocol =
50
+ Nip46Protocol(
51
+ clientKey,
52
+ config,
53
+ Nip46RequestIdGenerator { requestIds.next() },
54
+ clockSeconds = { CREATED_AT }
55
+ )
56
+ transport = FakeTransport()
57
+ client =
58
+ RelayNip46Client(
59
+ protocol = protocol,
60
+ transport = transport,
61
+ requestTimeoutMillis = 2_000,
62
+ activationHandler = {
63
+ config.clearPairingSecret()
64
+ }
65
+ )
66
+ }
67
+
68
+ @After
69
+ fun teardown() {
70
+ client.close()
71
+ clientKey.close()
72
+ communicationKey.close()
73
+ userKey.close()
74
+ }
75
+
76
+ @Test
77
+ fun connect_then_get_public_key_binds_distinct_user_identity() = runBlocking {
78
+ val request = async { client.getPublicKey() }
79
+
80
+ val connect = awaitRequest(Nip46Method.CONNECT)
81
+ assertEquals(communicationKey.publicKeyHex, connect.params[0])
82
+ assertEquals(PAIRING_SECRET, connect.params[1])
83
+ respond(connect.id, "ack")
84
+
85
+ val getPublicKey = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
86
+ respond(getPublicKey.id, userKey.publicKeyHex)
87
+
88
+ assertEquals(userKey.publicKeyHex, request.await())
89
+ assertFalse(userKey.publicKeyHex == communicationKey.publicKeyHex)
90
+ assertFalse(transport.publishedEvents.any { it.contains(PAIRING_SECRET) })
91
+ assertEquals(null, config.pairingSecret)
92
+ }
93
+
94
+ @Test
95
+ fun connect_advertises_permissions_and_client_metadata() = runBlocking {
96
+ val configuredClient =
97
+ RelayNip46Client(
98
+ protocol = protocol,
99
+ transport = transport,
100
+ requestTimeoutMillis = 2_000,
101
+ requestedPermissions = "sign_event,nip44_encrypt",
102
+ clientMetadataJson =
103
+ JSONObject()
104
+ .put("name", "Pakstr")
105
+ .toString()
106
+ )
107
+ try {
108
+ val activation = async { configuredClient.getPublicKey() }
109
+ val connect = awaitRequest(Nip46Method.CONNECT)
110
+
111
+ assertEquals(PAIRING_SECRET, connect.params[1])
112
+ assertEquals("sign_event,nip44_encrypt", connect.params[2])
113
+ assertEquals("Pakstr", JSONObject(connect.params[3]).getString("name"))
114
+
115
+ respond(connect.id, "ack")
116
+ val getPublicKey = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
117
+ respond(getPublicKey.id, userKey.publicKeyHex)
118
+ assertEquals(userKey.publicKeyHex, activation.await())
119
+ } finally {
120
+ configuredClient.close()
121
+ }
122
+ }
123
+
124
+ @Test
125
+ fun disconnect_is_best_effort_and_safe_around_close() {
126
+ client.disconnect()
127
+
128
+ val disconnect = transport.requests().single()
129
+ assertEquals(Nip46Method.DISCONNECT, disconnect.method)
130
+ assertTrue(disconnect.params.isEmpty())
131
+ assertTrue(transport.cancelledRequestIds.isEmpty())
132
+
133
+ client.close()
134
+ client.disconnect()
135
+ assertEquals(1, transport.publishedEvents.size)
136
+ }
137
+
138
+ @Test
139
+ fun disconnect_ignores_transport_failure() {
140
+ transport.publishError = IllegalStateException("relay unavailable")
141
+
142
+ client.disconnect()
143
+
144
+ assertTrue(transport.publishedEvents.isEmpty())
145
+ }
146
+
147
+ @Test
148
+ fun sign_event_uses_bound_user_identity() = runBlocking {
149
+ activate()
150
+ val unsigned =
151
+ JSONObject()
152
+ .put("kind", 1)
153
+ .put("created_at", CREATED_AT)
154
+ .put("tags", JSONArray())
155
+ .put("content", "message")
156
+ val request = async { client.signEvent(unsigned.toString()) }
157
+ val signRequest = awaitRequest(Nip46Method.SIGN_EVENT)
158
+ val signed = signUserEvent(JSONObject(signRequest.params.single()))
159
+
160
+ respond(signRequest.id, signed)
161
+
162
+ assertEquals(userKey.publicKeyHex, JSONObject(request.await()).getString("pubkey"))
163
+ }
164
+
165
+ @Test
166
+ fun nip44_operations_run_after_activation() = runBlocking {
167
+ activate()
168
+ val encrypted = async {
169
+ client.nip44Encrypt(userKey.publicKeyHex, "private message")
170
+ }
171
+ val encryptRequest = awaitRequest(Nip46Method.NIP44_ENCRYPT)
172
+ assertEquals(userKey.publicKeyHex, encryptRequest.params[0])
173
+ respond(encryptRequest.id, "encrypted-result")
174
+ assertEquals("encrypted-result", encrypted.await())
175
+
176
+ val decrypted = async {
177
+ client.nip44Decrypt(userKey.publicKeyHex, "encrypted-result")
178
+ }
179
+ val decryptRequest = awaitRequest(Nip46Method.NIP44_DECRYPT)
180
+ respond(decryptRequest.id, "decrypted-result")
181
+ assertEquals("decrypted-result", decrypted.await())
182
+ }
183
+
184
+ @Test
185
+ fun concurrent_activation_publishes_one_handshake() = runBlocking {
186
+ val first = async { client.getPublicKey() }
187
+ val second = async { client.getPublicKey() }
188
+
189
+ val connect = awaitRequest(Nip46Method.CONNECT)
190
+ assertEquals(1, transport.requests().count { it.method == Nip46Method.CONNECT })
191
+ respond(connect.id, "ack")
192
+
193
+ val getPublicKey = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
194
+ assertEquals(
195
+ 1,
196
+ transport.requests().count { it.method == Nip46Method.GET_PUBLIC_KEY }
197
+ )
198
+ respond(getPublicKey.id, userKey.publicKeyHex)
199
+
200
+ assertEquals(userKey.publicKeyHex, first.await())
201
+ assertEquals(userKey.publicKeyHex, second.await())
202
+ }
203
+
204
+ @Test
205
+ fun activation_retries_get_public_key_lost_during_amber_subscription_race() = runBlocking {
206
+ val compatibilityClient =
207
+ RelayNip46Client(
208
+ protocol = protocol,
209
+ transport = transport,
210
+ requestTimeoutMillis = 25,
211
+ connected = true
212
+ )
213
+ try {
214
+ val activation = async { compatibilityClient.getPublicKey() }
215
+ val dropped = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
216
+ val retried = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
217
+ assertFalse(dropped.id == retried.id)
218
+ respond(retried.id, userKey.publicKeyHex)
219
+
220
+ assertEquals(userKey.publicKeyHex, activation.await())
221
+ assertTrue(
222
+ transport.requests().none {
223
+ it.method == Nip46Method.CONNECT
224
+ }
225
+ )
226
+ assertEquals(
227
+ 2,
228
+ transport.requests().count {
229
+ it.method == Nip46Method.GET_PUBLIC_KEY
230
+ }
231
+ )
232
+ } finally {
233
+ compatibilityClient.close()
234
+ }
235
+ }
236
+
237
+ @Test
238
+ fun concurrent_requests_are_correlated_when_responses_arrive_out_of_order() = runBlocking {
239
+ activate()
240
+ val first = async {
241
+ client.nip44Encrypt(userKey.publicKeyHex, "first")
242
+ }
243
+ val firstRequest = awaitRequest(Nip46Method.NIP44_ENCRYPT)
244
+ val second = async {
245
+ client.nip44Encrypt(userKey.publicKeyHex, "second")
246
+ }
247
+ val secondRequest = awaitRequest(Nip46Method.NIP44_ENCRYPT)
248
+
249
+ assertFalse(firstRequest.id == secondRequest.id)
250
+ respond(secondRequest.id, "second-result")
251
+ respond(firstRequest.id, "first-result")
252
+
253
+ assertEquals("first-result", first.await())
254
+ assertEquals("second-result", second.await())
255
+ }
256
+
257
+ @Test
258
+ fun unsolicited_and_repeated_valid_responses_are_ignored() = runBlocking {
259
+ activate()
260
+ val request = async {
261
+ client.nip44Encrypt(userKey.publicKeyHex, "message")
262
+ }
263
+ val encryptRequest = awaitRequest(Nip46Method.NIP44_ENCRYPT)
264
+
265
+ respond(requestId(31), "unsolicited")
266
+ assertFalse(request.isCompleted)
267
+
268
+ respond(encryptRequest.id, "expected")
269
+ assertEquals("expected", request.await())
270
+ respond(encryptRequest.id, "repeated")
271
+
272
+ assertEquals(
273
+ 1,
274
+ transport.cancelledRequestIds.count { it == encryptRequest.id }
275
+ )
276
+ }
277
+
278
+ @Test
279
+ fun publish_failure_fails_request_and_cleans_up_pending_state() {
280
+ transport.publishError = IllegalStateException("relay unavailable")
281
+
282
+ assertThrows(SignerUnavailableException::class.java) {
283
+ runBlocking { client.getPublicKey() }
284
+ }
285
+
286
+ assertEquals(1, transport.cancelledRequestIds.size)
287
+ assertTrue(transport.publishedEvents.isEmpty())
288
+ }
289
+
290
+ @Test
291
+ fun restored_session_uses_persisted_user_identity_without_new_handshake() = runBlocking {
292
+ val restored =
293
+ RelayNip46Client(
294
+ protocol = protocol,
295
+ transport = transport,
296
+ requestTimeoutMillis = 2_000,
297
+ initialUserPubkey = userKey.publicKeyHex
298
+ )
299
+ try {
300
+ assertEquals(userKey.publicKeyHex, restored.getPublicKey())
301
+ assertTrue(transport.publishedEvents.isEmpty())
302
+ } finally {
303
+ restored.close()
304
+ }
305
+ }
306
+
307
+ @Test
308
+ fun invalid_remote_identity_is_ignored_until_timeout() {
309
+ val scheduler = Executors.newSingleThreadScheduledExecutor()
310
+ val shortClient =
311
+ RelayNip46Client(
312
+ protocol = protocol,
313
+ transport = transport,
314
+ requestTimeoutMillis = 25,
315
+ scheduler = scheduler
316
+ )
317
+ try {
318
+ assertThrows(Nip46RequestTimeoutException::class.java) {
319
+ runBlocking {
320
+ val request = async { shortClient.getPublicKey() }
321
+ val connect = awaitRequest(Nip46Method.CONNECT)
322
+ transport.deliver(
323
+ createResponseEvent(
324
+ connect.id,
325
+ "ack",
326
+ signingKey = userKey
327
+ )
328
+ )
329
+ request.await()
330
+ }
331
+ }
332
+ } finally {
333
+ shortClient.close()
334
+ }
335
+ }
336
+
337
+ @Test
338
+ fun auth_challenge_is_delivered_and_request_can_complete() = runBlocking {
339
+ var challenge: String? = null
340
+ val challengeClient =
341
+ RelayNip46Client(
342
+ protocol = protocol,
343
+ transport = transport,
344
+ requestTimeoutMillis = 2_000,
345
+ authChallengeHandler = { challenge = it }
346
+ )
347
+ try {
348
+ val request = async { challengeClient.getPublicKey() }
349
+ val connect = awaitRequest(Nip46Method.CONNECT)
350
+ respond(
351
+ connect.id,
352
+ result = "auth_url",
353
+ error = AUTH_URL
354
+ )
355
+ assertEquals(AUTH_URL, challenge)
356
+ assertFalse(request.isCompleted)
357
+
358
+ respond(connect.id, "ack")
359
+ val getPublicKey = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
360
+ respond(getPublicKey.id, userKey.publicKeyHex)
361
+ assertEquals(userKey.publicKeyHex, request.await())
362
+ } finally {
363
+ challengeClient.close()
364
+ }
365
+ }
366
+
367
+ @Test
368
+ fun auth_challenge_replaces_normal_timeout_with_extended_timeout() = runBlocking {
369
+ val scheduler = RecordingScheduler()
370
+ val challengeClient =
371
+ RelayNip46Client(
372
+ protocol = protocol,
373
+ transport = transport,
374
+ requestTimeoutMillis = 30_000,
375
+ scheduler = scheduler,
376
+ authChallengeHandler = { },
377
+ authChallengeTimeoutMillis = 300_000
378
+ )
379
+ try {
380
+ val request = async { challengeClient.getPublicKey() }
381
+ val connect = awaitRequest(Nip46Method.CONNECT)
382
+ assertEquals(listOf(30_000L), scheduler.delays)
383
+
384
+ respond(
385
+ connect.id,
386
+ result = "auth_url",
387
+ error = AUTH_URL
388
+ )
389
+
390
+ assertEquals(
391
+ listOf(30_000L, 300_000L),
392
+ scheduler.delays
393
+ )
394
+ assertTrue(scheduler.futures[0].cancelled)
395
+ assertFalse(scheduler.futures[1].cancelled)
396
+ request.cancelAndJoin()
397
+ } finally {
398
+ challengeClient.close()
399
+ }
400
+ }
401
+
402
+ @Test
403
+ fun normal_request_keeps_normal_timeout() = runBlocking {
404
+ val scheduler = RecordingScheduler()
405
+ val normalClient =
406
+ RelayNip46Client(
407
+ protocol = protocol,
408
+ transport = transport,
409
+ requestTimeoutMillis = 30_000,
410
+ scheduler = scheduler,
411
+ authChallengeTimeoutMillis = 300_000
412
+ )
413
+ try {
414
+ val request = async { normalClient.getPublicKey() }
415
+ awaitRequest(Nip46Method.CONNECT)
416
+
417
+ assertEquals(listOf(30_000L), scheduler.delays)
418
+ request.cancelAndJoin()
419
+ } finally {
420
+ normalClient.close()
421
+ }
422
+ }
423
+
424
+ @Test
425
+ fun auth_challenge_without_handler_fails_without_leaking_url() {
426
+ val error =
427
+ assertThrows(Nip46AuthChallengeException::class.java) {
428
+ runBlocking {
429
+ val request = async { client.getPublicKey() }
430
+ val connect = awaitRequest(Nip46Method.CONNECT)
431
+ respond(
432
+ connect.id,
433
+ result = "auth_url",
434
+ error = AUTH_URL
435
+ )
436
+ request.await()
437
+ }
438
+ }
439
+ assertFalse(error.message.orEmpty().contains(AUTH_URL))
440
+ assertFalse(error.toString().contains(PAIRING_SECRET))
441
+ }
442
+
443
+ @Test
444
+ fun cancellation_and_close_clean_up_session() {
445
+ runBlocking {
446
+ val request = async { client.getPublicKey() }
447
+ val connect = awaitRequest(Nip46Method.CONNECT)
448
+ request.cancelAndJoin()
449
+ assertTrue(transport.cancelledRequestIds.contains(connect.id))
450
+
451
+ client.close()
452
+ client.close()
453
+ assertTrue(transport.closed)
454
+ assertThrows(
455
+ com.pakstr.app.signer.exceptions.SignerUnavailableException::class.java
456
+ ) {
457
+ runBlocking { client.getPublicKey() }
458
+ }
459
+ }
460
+ }
461
+
462
+ private suspend fun activate() {
463
+ coroutineScope {
464
+ val request = async { client.getPublicKey() }
465
+ val connect = awaitRequest(Nip46Method.CONNECT)
466
+ respond(connect.id, "ack")
467
+ val getPublicKey = awaitRequest(Nip46Method.GET_PUBLIC_KEY)
468
+ respond(getPublicKey.id, userKey.publicKeyHex)
469
+ assertEquals(userKey.publicKeyHex, request.await())
470
+ }
471
+ }
472
+
473
+ private suspend fun awaitRequest(method: Nip46Method): Nip46Request {
474
+ return withTimeout(2_000) {
475
+ while (true) {
476
+ transport.requests()
477
+ .firstOrNull { it.method == method && it.id !in transport.observedIds }
478
+ ?.let { request ->
479
+ transport.observedIds += request.id
480
+ return@withTimeout request
481
+ }
482
+ kotlinx.coroutines.yield()
483
+ }
484
+ error("unreachable")
485
+ }
486
+ }
487
+
488
+ private fun FakeTransport.requests(): List<Nip46Request> {
489
+ return publishedEvents.map { eventJson ->
490
+ val encrypted = JSONObject(eventJson).getString("content")
491
+ Nip46Request.parse(
492
+ communicationKey.decryptNip44(
493
+ encrypted,
494
+ clientKey.publicKeyHex
495
+ )
496
+ )
497
+ }
498
+ }
499
+
500
+ private fun respond(
501
+ id: String,
502
+ result: String,
503
+ error: String? = null
504
+ ) {
505
+ transport.deliver(createResponseEvent(id, result, error))
506
+ }
507
+
508
+ private fun createResponseEvent(
509
+ id: String,
510
+ result: String,
511
+ error: String? = null,
512
+ signingKey: Nip46ClientKey = communicationKey
513
+ ): String {
514
+ val response = JSONObject().put("id", id).put("result", result)
515
+ error?.let { response.put("error", it) }
516
+ val encrypted =
517
+ communicationKey.encryptNip44(
518
+ response.toString(),
519
+ clientKey.publicKeyHex
520
+ )
521
+ val event =
522
+ JSONObject()
523
+ .put("pubkey", signingKey.publicKeyHex)
524
+ .put("created_at", CREATED_AT)
525
+ .put("kind", 24133)
526
+ .put(
527
+ "tags",
528
+ JSONArray().put(
529
+ JSONArray()
530
+ .put("p")
531
+ .put(clientKey.publicKeyHex)
532
+ )
533
+ )
534
+ .put("content", encrypted)
535
+ NostrEventBuilder.addId(event)
536
+ val eventId = Bip340Hash.hexToBytes(event.getString("id"))
537
+ val signature = signingKey.sign(eventId)
538
+ event.put("sig", Bip340Hash.bytesToHex(signature))
539
+ eventId.fill(0)
540
+ signature.fill(0)
541
+ return event.toString()
542
+ }
543
+
544
+ private fun signUserEvent(unsigned: JSONObject): String {
545
+ val event = JSONObject(unsigned.toString()).put("pubkey", userKey.publicKeyHex)
546
+ NostrEventBuilder.addId(event)
547
+ val eventId = Bip340Hash.hexToBytes(event.getString("id"))
548
+ val signature = userKey.sign(eventId)
549
+ event.put("sig", Bip340Hash.bytesToHex(signature))
550
+ eventId.fill(0)
551
+ signature.fill(0)
552
+ return event.toString()
553
+ }
554
+
555
+ private fun privateKey(value: Int): ByteArray {
556
+ return ByteArray(32).also { it[31] = value.toByte() }
557
+ }
558
+
559
+ private fun requestId(value: Int): String {
560
+ return "%064x".format(value)
561
+ }
562
+
563
+ private class RecordingScheduler : AbstractExecutorService(),
564
+ java.util.concurrent.ScheduledExecutorService {
565
+ val delays = mutableListOf<Long>()
566
+ val futures = mutableListOf<RecordingFuture>()
567
+ private var shutdown = false
568
+
569
+ override fun schedule(
570
+ command: Runnable,
571
+ delay: Long,
572
+ unit: TimeUnit
573
+ ): ScheduledFuture<*> {
574
+ delays += unit.toMillis(delay)
575
+ return RecordingFuture().also(futures::add)
576
+ }
577
+
578
+ override fun <V : Any?> schedule(
579
+ callable: Callable<V>,
580
+ delay: Long,
581
+ unit: TimeUnit
582
+ ): ScheduledFuture<V> = throw UnsupportedOperationException()
583
+
584
+ override fun scheduleAtFixedRate(
585
+ command: Runnable,
586
+ initialDelay: Long,
587
+ period: Long,
588
+ unit: TimeUnit
589
+ ): ScheduledFuture<*> = throw UnsupportedOperationException()
590
+
591
+ override fun scheduleWithFixedDelay(
592
+ command: Runnable,
593
+ initialDelay: Long,
594
+ delay: Long,
595
+ unit: TimeUnit
596
+ ): ScheduledFuture<*> = throw UnsupportedOperationException()
597
+
598
+ override fun shutdown() {
599
+ shutdown = true
600
+ }
601
+
602
+ override fun shutdownNow(): MutableList<Runnable> {
603
+ shutdown = true
604
+ return mutableListOf()
605
+ }
606
+
607
+ override fun isShutdown(): Boolean = shutdown
608
+
609
+ override fun isTerminated(): Boolean = shutdown
610
+
611
+ override fun awaitTermination(
612
+ timeout: Long,
613
+ unit: TimeUnit
614
+ ): Boolean = shutdown
615
+
616
+ override fun execute(command: Runnable) {
617
+ command.run()
618
+ }
619
+ }
620
+
621
+ private class RecordingFuture : ScheduledFuture<Unit> {
622
+ var cancelled = false
623
+ private set
624
+
625
+ override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
626
+ cancelled = true
627
+ return true
628
+ }
629
+
630
+ override fun isCancelled(): Boolean = cancelled
631
+
632
+ override fun isDone(): Boolean = cancelled
633
+
634
+ override fun get(): Unit = Unit
635
+
636
+ override fun get(timeout: Long, unit: TimeUnit): Unit = Unit
637
+
638
+ override fun getDelay(unit: TimeUnit): Long = 0
639
+
640
+ override fun compareTo(other: java.util.concurrent.Delayed): Int = 0
641
+ }
642
+
643
+ private class FakeTransport : Nip46Transport {
644
+ private lateinit var listener: (String) -> Unit
645
+ val publishedEvents = mutableListOf<String>()
646
+ val cancelledRequestIds = mutableListOf<String>()
647
+ val observedIds = mutableSetOf<String>()
648
+ var closed = false
649
+ var publishError: Exception? = null
650
+
651
+ override fun start(listener: (String) -> Unit) {
652
+ this.listener = listener
653
+ }
654
+
655
+ override fun publish(requestId: String, eventJson: String) {
656
+ publishError?.let { throw it }
657
+ publishedEvents += eventJson
658
+ }
659
+
660
+ override fun cancel(requestId: String) {
661
+ cancelledRequestIds += requestId
662
+ }
663
+
664
+ override fun close() {
665
+ closed = true
666
+ }
667
+
668
+ fun deliver(eventJson: String) {
669
+ listener(eventJson)
670
+ }
671
+ }
672
+
673
+ private companion object {
674
+ const val CREATED_AT = 1_700_000_000L
675
+ const val PAIRING_SECRET = "pairing-secret-value"
676
+ const val AUTH_URL = "https://signer.example/approve/session"
677
+ }
678
+ }
@@ -13,6 +13,10 @@ composeBom = "2024.09.00"
13
13
  nanohttpd = "2.3.1"
14
14
  activityKtx = "1.9.3"
15
15
  material = "1.14.0"
16
+ json = "20250517"
17
+ okhttp = "4.12.0"
18
+ securityCrypto = "1.1.0"
19
+ zxingCore = "3.5.3"
16
20
 
17
21
  [libraries]
18
22
  androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
@@ -34,6 +38,10 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
34
38
  nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
35
39
  androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
36
40
  material = { module = "com.google.android.material:material", version.ref = "material" }
41
+ json = { module = "org.json:json", version.ref = "json" }
42
+ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
43
+ androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
44
+ zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
37
45
 
38
46
  [plugins]
39
47
  android-application = { id = "com.android.application", version.ref = "agp" }