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,271 @@
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 kotlinx.coroutines.CoroutineStart
7
+ import kotlinx.coroutines.async
8
+ import kotlinx.coroutines.runBlocking
9
+ import org.json.JSONArray
10
+ import org.json.JSONObject
11
+ import org.junit.After
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertNull
14
+ import org.junit.Assert.assertTrue
15
+ import org.junit.Before
16
+ import org.junit.Test
17
+
18
+ class Nip46PairingClientTest {
19
+ private lateinit var clientKey: Nip46ClientKey
20
+ private lateinit var signerKey: Nip46ClientKey
21
+ private lateinit var factory: FakeConnectionFactory
22
+ private lateinit var client: Nip46PairingClient
23
+
24
+ @Before
25
+ fun setup() {
26
+ clientKey = Nip46ClientKey.fromPrivateKey(privateKey(1))
27
+ signerKey = Nip46ClientKey.fromPrivateKey(privateKey(2))
28
+ factory = FakeConnectionFactory()
29
+ client =
30
+ Nip46PairingClient(
31
+ relayUrls = listOf(RELAY_URL),
32
+ clientKey = clientKey,
33
+ pairingSecret = PAIRING_SECRET,
34
+ connectionFactory = factory
35
+ )
36
+ factory.connection.open()
37
+ }
38
+
39
+ @After
40
+ fun teardown() {
41
+ client.close()
42
+ clientKey.close()
43
+ signerKey.close()
44
+ }
45
+
46
+ @Test
47
+ fun pairing_uri_is_not_exposed_before_subscription_readiness() = runBlocking {
48
+ recreateClientWithoutOpeningRelay()
49
+ val exposedUri = async(start = CoroutineStart.UNDISPATCHED) {
50
+ check(client.awaitRelayReady(1_000))
51
+ PAIRING_URI
52
+ }
53
+
54
+ assertTrue(!exposedUri.isCompleted)
55
+
56
+ factory.connection.open()
57
+
58
+ assertEquals(PAIRING_URI, exposedUri.await())
59
+ val messages = factory.connection.messages
60
+ assertEquals(1, messages.size)
61
+ assertEquals("REQ", JSONArray(messages.single()).getString(0))
62
+ }
63
+
64
+ @Test
65
+ fun response_after_readiness_completes_pairing() = runBlocking {
66
+ assertTrue(client.awaitRelayReady(100))
67
+
68
+ factory.connection.receiveEvent(responseEvent(PAIRING_SECRET))
69
+
70
+ assertEquals(
71
+ signerKey.publicKeyHex,
72
+ requireNotNull(client.awaitConnection(100)).remoteSignerPubkey
73
+ )
74
+ }
75
+
76
+ @Test
77
+ fun response_cannot_precede_readiness_when_uri_exposure_is_blocked() = runBlocking {
78
+ recreateClientWithoutOpeningRelay()
79
+ val exposedUri = async(start = CoroutineStart.UNDISPATCHED) {
80
+ check(client.awaitRelayReady(1_000))
81
+ PAIRING_URI
82
+ }
83
+
84
+ assertTrue(!exposedUri.isCompleted)
85
+ factory.connection.open()
86
+ assertEquals(PAIRING_URI, exposedUri.await())
87
+ factory.connection.receiveEvent(responseEvent(PAIRING_SECRET))
88
+
89
+ assertEquals(
90
+ signerKey.publicKeyHex,
91
+ requireNotNull(client.awaitConnection(100)).remoteSignerPubkey
92
+ )
93
+ }
94
+
95
+ @Test
96
+ fun relay_failure_does_not_leave_readiness_waiting_indefinitely() = runBlocking {
97
+ recreateClientWithoutOpeningRelay()
98
+ factory.connection.fail()
99
+
100
+ assertTrue(!client.awaitRelayReady(25))
101
+ }
102
+
103
+ @Test
104
+ fun matching_secret_completes_pairing() = runBlocking {
105
+ factory.connection.receiveEvent(
106
+ responseEvent(PAIRING_SECRET)
107
+ )
108
+
109
+ val config = requireNotNull(client.awaitConnection(100))
110
+ assertEquals(signerKey.publicKeyHex, config.remoteSignerPubkey)
111
+ assertEquals(listOf(RELAY_URL), config.relayUrls)
112
+ assertNull(config.pairingSecret)
113
+ }
114
+
115
+ @Test
116
+ fun amber_connect_response_with_short_id_completes_pairing() = runBlocking {
117
+ factory.connection.receiveEvent(
118
+ responseEvent(
119
+ result = PAIRING_SECRET,
120
+ responseId = AMBER_RESPONSE_ID
121
+ )
122
+ )
123
+
124
+ val config = requireNotNull(client.awaitConnection(100))
125
+ assertEquals(signerKey.publicKeyHex, config.remoteSignerPubkey)
126
+ }
127
+
128
+ @Test
129
+ fun ack_wrong_secret_and_invalid_event_are_ignored() = runBlocking {
130
+ factory.connection.receiveEvent(responseEvent("ack"))
131
+ factory.connection.receiveEvent(responseEvent("wrong-secret"))
132
+ factory.connection.receiveEvent(
133
+ JSONObject(responseEvent(PAIRING_SECRET))
134
+ .put("sig", "0".repeat(128))
135
+ .toString()
136
+ )
137
+
138
+ assertNull(client.awaitConnection(25))
139
+ }
140
+
141
+ @Test
142
+ fun close_is_idempotent_and_unblocks_waiter() = runBlocking {
143
+ client.close()
144
+ client.close()
145
+
146
+ assertTrue(factory.connection.closed)
147
+ assertTrue(factory.closed)
148
+ assertNull(client.awaitConnection(25))
149
+ }
150
+
151
+ private fun recreateClientWithoutOpeningRelay() {
152
+ client.close()
153
+ factory = FakeConnectionFactory()
154
+ client =
155
+ Nip46PairingClient(
156
+ relayUrls = listOf(RELAY_URL),
157
+ clientKey = clientKey,
158
+ pairingSecret = PAIRING_SECRET,
159
+ connectionFactory = factory
160
+ )
161
+ }
162
+
163
+ private fun responseEvent(
164
+ result: String,
165
+ responseId: String = REQUEST_ID
166
+ ): String {
167
+ val response =
168
+ JSONObject()
169
+ .put("id", responseId)
170
+ .put("result", result)
171
+ val encrypted =
172
+ signerKey.encryptNip44(
173
+ response.toString(),
174
+ clientKey.publicKeyHex
175
+ )
176
+ val event =
177
+ JSONObject()
178
+ .put("pubkey", signerKey.publicKeyHex)
179
+ .put("created_at", CREATED_AT)
180
+ .put("kind", 24133)
181
+ .put(
182
+ "tags",
183
+ JSONArray().put(
184
+ JSONArray()
185
+ .put("p")
186
+ .put(clientKey.publicKeyHex)
187
+ )
188
+ )
189
+ .put("content", encrypted)
190
+ NostrEventBuilder.addId(event)
191
+ val eventId = Bip340Hash.hexToBytes(event.getString("id"))
192
+ val signature = signerKey.sign(eventId)
193
+ event.put("sig", Bip340Hash.bytesToHex(signature))
194
+ eventId.fill(0)
195
+ signature.fill(0)
196
+ return event.toString()
197
+ }
198
+
199
+ private fun privateKey(value: Int): ByteArray {
200
+ return ByteArray(32).also { it[31] = value.toByte() }
201
+ }
202
+
203
+ private class FakeConnectionFactory : Nip46RelayConnectionFactory {
204
+ lateinit var connection: FakeConnection
205
+ private set
206
+ var closed = false
207
+ private set
208
+
209
+ override fun connect(
210
+ relayUrl: String,
211
+ listener: Nip46RelayConnectionListener
212
+ ): Nip46RelayConnection {
213
+ return FakeConnection(listener).also {
214
+ connection = it
215
+ }
216
+ }
217
+
218
+ override fun close() {
219
+ closed = true
220
+ }
221
+ }
222
+
223
+ private class FakeConnection(
224
+ private val listener: Nip46RelayConnectionListener
225
+ ) : Nip46RelayConnection {
226
+ private lateinit var subscriptionId: String
227
+ val messages = mutableListOf<String>()
228
+ var closed = false
229
+ private set
230
+
231
+ override fun send(message: String): Boolean {
232
+ messages += message
233
+ val frame = JSONArray(message)
234
+ if (frame.getString(0) == "REQ") {
235
+ subscriptionId = frame.getString(1)
236
+ }
237
+ return !closed
238
+ }
239
+
240
+ override fun close() {
241
+ closed = true
242
+ }
243
+
244
+ fun open() {
245
+ listener.onOpen()
246
+ }
247
+
248
+ fun fail() {
249
+ listener.onFailure()
250
+ }
251
+
252
+ fun receiveEvent(eventJson: String) {
253
+ listener.onMessage(
254
+ JSONArray()
255
+ .put("EVENT")
256
+ .put(subscriptionId)
257
+ .put(JSONObject(eventJson))
258
+ .toString()
259
+ )
260
+ }
261
+ }
262
+
263
+ private companion object {
264
+ const val RELAY_URL = "wss://relay.example"
265
+ const val PAIRING_SECRET = "pairing-secret-value"
266
+ const val PAIRING_URI = "nostrconnect://not-exposed-until-ready"
267
+ const val CREATED_AT = 1_700_000_000L
268
+ const val AMBER_RESPONSE_ID = "a1b2c3"
269
+ val REQUEST_ID = "a".repeat(64)
270
+ }
271
+ }
@@ -0,0 +1,54 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.google.zxing.BinaryBitmap
4
+ import com.google.zxing.RGBLuminanceSource
5
+ import com.google.zxing.common.HybridBinarizer
6
+ import com.google.zxing.qrcode.QRCodeReader
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertThrows
9
+ import org.junit.Test
10
+
11
+ class Nip46PairingQrCodeTest {
12
+
13
+ @Test
14
+ fun qr_code_decodes_to_exact_nostrconnect_uri() {
15
+ val uri =
16
+ "nostrconnect://${"a".repeat(64)}" +
17
+ "?relay=wss%3A%2F%2Frelay.example" +
18
+ "&perms=sign_event%2Cnip44_encrypt" +
19
+ "&name=Pakstr" +
20
+ "&secret=exact-pairing-secret"
21
+ val matrix = Nip46PairingQrCode.encode(uri, 512)
22
+ val pixels = IntArray(matrix.width * matrix.height) { index ->
23
+ val x = index % matrix.width
24
+ val y = index / matrix.width
25
+ if (matrix[x, y]) BLACK else WHITE
26
+ }
27
+ val bitmap = BinaryBitmap(
28
+ HybridBinarizer(
29
+ RGBLuminanceSource(
30
+ matrix.width,
31
+ matrix.height,
32
+ pixels
33
+ )
34
+ )
35
+ )
36
+
37
+ assertEquals(uri, QRCodeReader().decode(bitmap).text)
38
+ }
39
+
40
+ @Test
41
+ fun rejects_blank_payload_and_invalid_size() {
42
+ assertThrows(IllegalArgumentException::class.java) {
43
+ Nip46PairingQrCode.encode("", 512)
44
+ }
45
+ assertThrows(IllegalArgumentException::class.java) {
46
+ Nip46PairingQrCode.encode("nostrconnect://pairing", 0)
47
+ }
48
+ }
49
+
50
+ private companion object {
51
+ const val BLACK = -0x1000000
52
+ const val WHITE = -0x1
53
+ }
54
+ }
@@ -0,0 +1,80 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.pakstr.app.crypto.Nip46ClientKey
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+ import java.net.URI
9
+ import java.net.URLDecoder
10
+
11
+ class Nip46PairingUriTest {
12
+ @Test
13
+ fun generated_uri_is_canonical_and_contains_required_fields() {
14
+ val clientKey = Nip46ClientKey.fromPrivateKey(privateKey(1))
15
+ try {
16
+ val uri =
17
+ Nip46PairingUri.create(
18
+ clientPubkey = clientKey.publicKeyHex,
19
+ relayUrls = listOf(
20
+ "wss://relay-one.example/path",
21
+ "wss://relay-two.example"
22
+ ),
23
+ secret = "one-time-secret",
24
+ permissions = "sign_event,nip44_encrypt",
25
+ clientName = "Pakstr"
26
+ ).encode()
27
+ val parsed = URI(uri)
28
+ val parameters =
29
+ parsed.rawQuery.split("&").map { parameter ->
30
+ val parts = parameter.split("=", limit = 2)
31
+ URLDecoder.decode(parts[0], "UTF-8") to
32
+ URLDecoder.decode(parts[1], "UTF-8")
33
+ }
34
+
35
+ assertEquals("nostrconnect", parsed.scheme)
36
+ assertEquals(clientKey.publicKeyHex, parsed.host)
37
+ assertEquals(
38
+ listOf(
39
+ "wss://relay-one.example/path",
40
+ "wss://relay-two.example"
41
+ ),
42
+ parameters.filter { it.first == "relay" }.map { it.second }
43
+ )
44
+ assertEquals(
45
+ "one-time-secret",
46
+ parameters.single { it.first == "secret" }.second
47
+ )
48
+ assertTrue(uri.contains("name=Pakstr"))
49
+ assertFalse(
50
+ Nip46PairingUri.create(
51
+ clientKey.publicKeyHex,
52
+ listOf("wss://relay.example"),
53
+ "secret"
54
+ ).toString().contains("secret")
55
+ )
56
+ } finally {
57
+ clientKey.close()
58
+ }
59
+ }
60
+
61
+ @Test
62
+ fun generated_uri_rejects_insecure_relays() {
63
+ val clientKey = Nip46ClientKey.fromPrivateKey(privateKey(1))
64
+ try {
65
+ org.junit.Assert.assertThrows(IllegalArgumentException::class.java) {
66
+ Nip46PairingUri.create(
67
+ clientKey.publicKeyHex,
68
+ listOf("ws://relay.example"),
69
+ "secret"
70
+ )
71
+ }
72
+ } finally {
73
+ clientKey.close()
74
+ }
75
+ }
76
+
77
+ private fun privateKey(value: Int): ByteArray {
78
+ return ByteArray(32).also { it[31] = value.toByte() }
79
+ }
80
+ }