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.
- package/README.md +1 -1
- package/android-template/app/build.gradle.kts +4 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +273 -0
- package/android-template/app/src/main/assets/www/index.html +0 -6
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +20 -4
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +136 -12
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Hash.kt +56 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Signer.kt +104 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Verifier.kt +20 -170
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip44Crypto.kt +513 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip46ClientKey.kt +140 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventHasher.kt +3 -28
- package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventSerializer.kt +74 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Secp256k1.kt +100 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/BunkerSigner.kt +32 -7
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +36 -9
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +240 -163
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConfig.kt +175 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt +144 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Client.kt +79 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Models.kt +319 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingClient.kt +159 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingQrCode.kt +35 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingUri.kt +104 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Protocol.kt +695 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46RelayTransport.kt +489 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/RelayNip46Client.kt +403 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/BunkerPairingDialog.kt +82 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +78 -17
- package/android-template/app/src/main/res/layout/activity_layout.xml +11 -0
- package/android-template/app/src/main/res/layout/dialog_bunker_pairing.xml +75 -0
- package/android-template/app/src/main/res/layout/dialog_picker.xml +60 -31
- package/android-template/app/src/main/res/values/strings.xml +27 -0
- package/android-template/app/src/test/java/com/pakstr/app/LocalServerQueryKeepAliveTest.kt +118 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Bip340SignerTest.kt +133 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip44CryptoTest.kt +199 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyProtocolTest.kt +82 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyTest.kt +52 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/NostrEventSerializerTest.kt +42 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeErrorTest.kt +32 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/BunkerConfigTest.kt +120 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ModelsTest.kt +111 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingClientTest.kt +271 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingQrCodeTest.kt +54 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingUriTest.kt +80 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ProtocolTest.kt +578 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46RelayTransportTest.kt +508 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt +678 -0
- package/android-template/gradle/libs.versions.toml +8 -0
- package/package.json +1 -1
package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt
ADDED
|
@@ -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" }
|