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,144 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import androidx.security.crypto.EncryptedSharedPreferences
6
+ import androidx.security.crypto.MasterKey
7
+ import com.pakstr.app.crypto.Bip340Verifier
8
+ import com.pakstr.app.crypto.Nip46ClientKey
9
+
10
+ internal data class BunkerConnection(
11
+ val config: BunkerConfig,
12
+ val clientKey: Nip46ClientKey,
13
+ val userPubkey: String?,
14
+ val connected: Boolean
15
+ )
16
+
17
+ internal class BunkerConnectionStore(
18
+ context: Context
19
+ ) {
20
+ private val preferences =
21
+ EncryptedSharedPreferences.create(
22
+ context.applicationContext,
23
+ PREFERENCES_NAME,
24
+ MasterKey.Builder(context.applicationContext)
25
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
26
+ .build(),
27
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
28
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
29
+ )
30
+
31
+ fun savePending(
32
+ config: BunkerConfig,
33
+ clientKey: Nip46ClientKey
34
+ ) {
35
+ saveConnection(config, clientKey, null, false)
36
+ }
37
+
38
+ fun save(
39
+ config: BunkerConfig,
40
+ clientKey: Nip46ClientKey,
41
+ userPubkey: String?,
42
+ connected: Boolean = true
43
+ ) {
44
+ saveConnection(
45
+ config.withoutPairingSecret(),
46
+ clientKey,
47
+ userPubkey,
48
+ connected
49
+ )
50
+ }
51
+
52
+ private fun saveConnection(
53
+ config: BunkerConfig,
54
+ clientKey: Nip46ClientKey,
55
+ userPubkey: String?,
56
+ connected: Boolean
57
+ ) {
58
+ userPubkey?.let(::validateUserPubkey)
59
+ clientKey.withPrivateKey { privateKey ->
60
+ val encodedKey =
61
+ Base64.encodeToString(
62
+ privateKey,
63
+ Base64.NO_WRAP
64
+ )
65
+ val saved =
66
+ preferences.edit()
67
+ .putString(KEY_CONFIG, config.encode())
68
+ .putString(KEY_CLIENT_KEY, encodedKey)
69
+ .putBoolean(KEY_CONNECTED, connected)
70
+ .apply {
71
+ if (userPubkey == null) {
72
+ remove(KEY_USER_PUBKEY)
73
+ } else {
74
+ putString(KEY_USER_PUBKEY, userPubkey)
75
+ }
76
+ }
77
+ .commit()
78
+ check(saved) {
79
+ "Unable to store bunker connection"
80
+ }
81
+ }
82
+ }
83
+
84
+ fun restore(): BunkerConnection? {
85
+ val encodedConfig = preferences.getString(KEY_CONFIG, null)
86
+ val encodedKey = preferences.getString(KEY_CLIENT_KEY, null)
87
+ if (encodedConfig == null && encodedKey == null) {
88
+ return null
89
+ }
90
+ if (encodedConfig == null || encodedKey == null) {
91
+ clear()
92
+ return null
93
+ }
94
+
95
+ val privateKey =
96
+ try {
97
+ Base64.decode(encodedKey, Base64.NO_WRAP)
98
+ } catch (_: Exception) {
99
+ clear()
100
+ return null
101
+ }
102
+ return try {
103
+ val config = BunkerConfig.parse(encodedConfig)
104
+ val userPubkey = preferences.getString(KEY_USER_PUBKEY, null)
105
+ userPubkey?.let(::validateUserPubkey)
106
+ BunkerConnection(
107
+ config,
108
+ Nip46ClientKey.fromPrivateKey(privateKey),
109
+ userPubkey,
110
+ preferences.getBoolean(KEY_CONNECTED, false)
111
+ )
112
+ } catch (_: Exception) {
113
+ clear()
114
+ null
115
+ } finally {
116
+ privateKey.fill(0)
117
+ }
118
+ }
119
+
120
+ fun clear() {
121
+ if (!preferences.edit().clear().commit()) {
122
+ throw IllegalStateException(
123
+ "Unable to clear bunker connection"
124
+ )
125
+ }
126
+ }
127
+
128
+ private fun validateUserPubkey(value: String) {
129
+ require(
130
+ value.matches(Regex("[0-9a-f]{64}")) &&
131
+ Bip340Verifier.isValidXOnlyPubkey(value)
132
+ ) {
133
+ "Invalid user public key"
134
+ }
135
+ }
136
+
137
+ private companion object {
138
+ const val PREFERENCES_NAME = "bunker_connection"
139
+ const val KEY_CONFIG = "config"
140
+ const val KEY_CLIENT_KEY = "client_key"
141
+ const val KEY_USER_PUBKEY = "user_pubkey"
142
+ const val KEY_CONNECTED = "connected"
143
+ }
144
+ }
@@ -0,0 +1,79 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
4
+
5
+ interface Nip46Client {
6
+ suspend fun getPublicKey(): String
7
+
8
+ suspend fun signEvent(eventJson: String): String
9
+
10
+ suspend fun nip04Encrypt(
11
+ pubkey: String,
12
+ plaintext: String
13
+ ): String
14
+
15
+ suspend fun nip04Decrypt(
16
+ pubkey: String,
17
+ ciphertext: String
18
+ ): String
19
+
20
+ suspend fun nip44Encrypt(
21
+ pubkey: String,
22
+ plaintext: String
23
+ ): String
24
+
25
+ suspend fun nip44Decrypt(
26
+ pubkey: String,
27
+ ciphertext: String
28
+ ): String
29
+
30
+ fun disconnect() = Unit
31
+
32
+ fun close()
33
+ }
34
+
35
+ internal object UnavailableNip46Client : Nip46Client {
36
+ override suspend fun getPublicKey(): String {
37
+ throw unavailable()
38
+ }
39
+
40
+ override suspend fun signEvent(eventJson: String): String {
41
+ throw unavailable()
42
+ }
43
+
44
+ override suspend fun nip04Encrypt(
45
+ pubkey: String,
46
+ plaintext: String
47
+ ): String {
48
+ throw unavailable()
49
+ }
50
+
51
+ override suspend fun nip04Decrypt(
52
+ pubkey: String,
53
+ ciphertext: String
54
+ ): String {
55
+ throw unavailable()
56
+ }
57
+
58
+ override suspend fun nip44Encrypt(
59
+ pubkey: String,
60
+ plaintext: String
61
+ ): String {
62
+ throw unavailable()
63
+ }
64
+
65
+ override suspend fun nip44Decrypt(
66
+ pubkey: String,
67
+ ciphertext: String
68
+ ): String {
69
+ throw unavailable()
70
+ }
71
+
72
+ override fun close() = Unit
73
+
74
+ private fun unavailable(): SignerUnavailableException {
75
+ return SignerUnavailableException(
76
+ "Bunker signer is not connected"
77
+ )
78
+ }
79
+ }
@@ -0,0 +1,319 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+ import java.security.SecureRandom
6
+ import java.util.Arrays
7
+
8
+ internal enum class Nip46Method(
9
+ val wireName: String
10
+ ) {
11
+ CONNECT("connect"),
12
+ DISCONNECT("disconnect"),
13
+ GET_PUBLIC_KEY("get_public_key"),
14
+ SIGN_EVENT("sign_event"),
15
+ NIP04_ENCRYPT("nip04_encrypt"),
16
+ NIP04_DECRYPT("nip04_decrypt"),
17
+ NIP44_ENCRYPT("nip44_encrypt"),
18
+ NIP44_DECRYPT("nip44_decrypt");
19
+
20
+ companion object {
21
+ fun fromWireName(value: String): Nip46Method {
22
+ return entries.firstOrNull { it.wireName == value }
23
+ ?: throw Nip46ProtocolException(
24
+ "Unknown NIP-46 method"
25
+ )
26
+ }
27
+ }
28
+ }
29
+
30
+ internal class Nip46Request(
31
+ val id: String,
32
+ val method: Nip46Method,
33
+ params: List<String>
34
+ ) {
35
+ val params: List<String> = params.toList()
36
+
37
+ fun toJson(): String {
38
+ validateNip46RequestId(id)
39
+
40
+ return JSONObject()
41
+ .put("id", id)
42
+ .put("method", method.wireName)
43
+ .put("params", JSONArray(params))
44
+ .toString()
45
+ }
46
+
47
+ override fun toString(): String {
48
+ return "Nip46Request(" +
49
+ "id=${id.take(12)}..., " +
50
+ "method=${method.wireName}, " +
51
+ "paramCount=${params.size}" +
52
+ ")"
53
+ }
54
+
55
+ companion object {
56
+ fun parse(value: String): Nip46Request {
57
+ val json = parseJsonObject(value)
58
+ requireExactString(json, "id")
59
+ requireExactString(json, "method")
60
+ val params = requireArray(json, "params")
61
+ val parsedParams =
62
+ (0 until params.length()).map { index ->
63
+ val parameter = params.get(index)
64
+ if (parameter !is String) {
65
+ throw Nip46ProtocolException(
66
+ "Invalid NIP-46 request"
67
+ )
68
+ }
69
+ parameter
70
+ }
71
+
72
+ return Nip46Request(
73
+ id = json.getString("id")
74
+ .also(::validateNip46RequestId),
75
+ method = Nip46Method.fromWireName(
76
+ json.getString("method")
77
+ ),
78
+ params = parsedParams
79
+ )
80
+ }
81
+ }
82
+ }
83
+
84
+ internal class Nip46Response(
85
+ val id: String,
86
+ val result: String?,
87
+ val error: String?
88
+ ) {
89
+ override fun toString(): String {
90
+ return "Nip46Response(" +
91
+ "id=${id.take(12)}..., " +
92
+ "hasResult=${result != null}, " +
93
+ "hasError=${error != null}" +
94
+ ")"
95
+ }
96
+
97
+ companion object {
98
+ fun parse(value: String): Nip46Response {
99
+ val json = parseJsonObject(value)
100
+ val id = requireExactString(json, "id")
101
+ .also(::validateNip46RequestId)
102
+ val result = optionalString(json, "result")
103
+ val error = optionalString(json, "error")
104
+
105
+ if (error == null && result == null) {
106
+ throw Nip46ProtocolException(
107
+ "Invalid NIP-46 response"
108
+ )
109
+ }
110
+ if (error != null && error.isEmpty()) {
111
+ throw Nip46ProtocolException(
112
+ "Invalid NIP-46 response"
113
+ )
114
+ }
115
+
116
+ return Nip46Response(id, result, error)
117
+ }
118
+ }
119
+ }
120
+
121
+ internal fun interface Nip46RequestIdGenerator {
122
+ fun nextId(): String
123
+ }
124
+
125
+ internal object SecureNip46RequestIdGenerator : Nip46RequestIdGenerator {
126
+ private val secureRandom = SecureRandom()
127
+
128
+ override fun nextId(): String {
129
+ val bytes = ByteArray(32)
130
+ secureRandom.nextBytes(bytes)
131
+
132
+ return try {
133
+ bytes.joinToString("") { byte ->
134
+ "%02x".format(byte.toInt() and 0xff)
135
+ }
136
+ } finally {
137
+ Arrays.fill(bytes, 0)
138
+ }
139
+ }
140
+ }
141
+
142
+ internal class Nip46RequestContext(
143
+ val id: String,
144
+ val method: Nip46Method,
145
+ val expectedUserPubkey: String? = null,
146
+ val expectedConnectResult: String? = null,
147
+ unsignedEventFingerprint: ByteArray? = null
148
+ ) : AutoCloseable {
149
+ private var fingerprint =
150
+ unsignedEventFingerprint?.copyOf()
151
+ private var state = RequestState.PENDING
152
+
153
+ @Synchronized
154
+ internal fun beginResponse(
155
+ authChallenge: Boolean
156
+ ) {
157
+ when (state) {
158
+ RequestState.COMPLETED ->
159
+ throw Nip46ProtocolException(
160
+ "NIP-46 request is already completed"
161
+ )
162
+ RequestState.PENDING,
163
+ RequestState.AUTH_CHALLENGED -> {
164
+ state =
165
+ if (authChallenge) {
166
+ RequestState.AUTH_CHALLENGED
167
+ } else {
168
+ RequestState.COMPLETED
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ internal fun matchesUnsignedEvent(
175
+ candidate: ByteArray
176
+ ): Boolean {
177
+ val expected = fingerprint ?: return false
178
+ return java.security.MessageDigest.isEqual(
179
+ expected,
180
+ candidate
181
+ )
182
+ }
183
+
184
+ override fun close() {
185
+ fingerprint?.let { Arrays.fill(it, 0) }
186
+ fingerprint = null
187
+ }
188
+
189
+ override fun toString(): String {
190
+ return "Nip46RequestContext(" +
191
+ "id=${id.take(12)}..., " +
192
+ "method=${method.wireName}" +
193
+ ")"
194
+ }
195
+
196
+ private enum class RequestState {
197
+ PENDING,
198
+ AUTH_CHALLENGED,
199
+ COMPLETED
200
+ }
201
+ }
202
+
203
+ internal class Nip46OutboundRequest(
204
+ val eventJson: String,
205
+ val context: Nip46RequestContext
206
+ ) {
207
+ override fun toString(): String {
208
+ return "Nip46OutboundRequest(" +
209
+ "context=$context" +
210
+ ")"
211
+ }
212
+ }
213
+
214
+ internal class Nip46VerifiedResponse(
215
+ val response: Nip46Response
216
+ ) {
217
+ override fun toString(): String {
218
+ return "Nip46VerifiedResponse(response=$response)"
219
+ }
220
+ }
221
+
222
+ internal sealed interface Nip46ResponseOutcome {
223
+ class Success(
224
+ val result: String
225
+ ) : Nip46ResponseOutcome {
226
+ override fun toString(): String =
227
+ "Nip46ResponseOutcome.Success"
228
+ }
229
+
230
+ class RemoteError : Nip46ResponseOutcome {
231
+ override fun toString(): String =
232
+ "Nip46ResponseOutcome.RemoteError"
233
+ }
234
+
235
+ class AuthChallenge(
236
+ val url: String
237
+ ) : Nip46ResponseOutcome {
238
+ override fun toString(): String =
239
+ "Nip46ResponseOutcome.AuthChallenge"
240
+ }
241
+ }
242
+
243
+ internal class Nip46ProtocolLimits(
244
+ val maximumEventBytes: Int = 512 * 1024,
245
+ val maximumEncryptedContentLength: Int = 384 * 1024,
246
+ val maximumPayloadBytes: Int = 256 * 1024,
247
+ val maximumAuthUrlLength: Int = 4 * 1024
248
+ )
249
+
250
+ internal class Nip46ProtocolException(
251
+ message: String
252
+ ) : Exception(message)
253
+
254
+ internal fun validateNip46RequestId(value: String) {
255
+ if (!value.matches(Regex("[0-9a-f]{64}"))) {
256
+ throw Nip46ProtocolException(
257
+ "Invalid NIP-46 request ID"
258
+ )
259
+ }
260
+ }
261
+
262
+ private fun parseJsonObject(value: String): JSONObject {
263
+ return try {
264
+ JSONObject(value)
265
+ } catch (_: Exception) {
266
+ throw Nip46ProtocolException(
267
+ "Invalid NIP-46 JSON"
268
+ )
269
+ }
270
+ }
271
+
272
+ private fun requireExactString(
273
+ json: JSONObject,
274
+ key: String
275
+ ): String {
276
+ if (!json.has(key) || json.isNull(key)) {
277
+ throw Nip46ProtocolException(
278
+ "Invalid NIP-46 JSON"
279
+ )
280
+ }
281
+ val value = json.get(key)
282
+ if (value !is String) {
283
+ throw Nip46ProtocolException(
284
+ "Invalid NIP-46 JSON"
285
+ )
286
+ }
287
+ return value
288
+ }
289
+
290
+ private fun optionalString(
291
+ json: JSONObject,
292
+ key: String
293
+ ): String? {
294
+ if (!json.has(key) || json.isNull(key)) {
295
+ return null
296
+ }
297
+ val value = json.get(key)
298
+ if (value !is String) {
299
+ throw Nip46ProtocolException(
300
+ "Invalid NIP-46 JSON"
301
+ )
302
+ }
303
+ return value
304
+ }
305
+
306
+ private fun requireArray(
307
+ json: JSONObject,
308
+ key: String
309
+ ): JSONArray {
310
+ if (!json.has(key) || json.isNull(key)) {
311
+ throw Nip46ProtocolException(
312
+ "Invalid NIP-46 JSON"
313
+ )
314
+ }
315
+ return json.optJSONArray(key)
316
+ ?: throw Nip46ProtocolException(
317
+ "Invalid NIP-46 JSON"
318
+ )
319
+ }
@@ -0,0 +1,159 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import android.content.Context
4
+ import com.pakstr.app.crypto.Nip46ClientKey
5
+ import com.pakstr.app.crypto.NostrEventVerifier
6
+ import com.pakstr.app.debug.AppDebugLogger
7
+ import kotlinx.coroutines.Dispatchers
8
+ import kotlinx.coroutines.withContext
9
+ import org.json.JSONArray
10
+ import org.json.JSONObject
11
+ import java.security.SecureRandom
12
+ import java.util.concurrent.CountDownLatch
13
+ import java.util.concurrent.TimeUnit
14
+ import java.util.concurrent.atomic.AtomicBoolean
15
+
16
+ internal class Nip46PairingClient(
17
+ private val relayUrls: List<String>,
18
+ private val clientKey: Nip46ClientKey,
19
+ private val pairingSecret: String,
20
+ connectionFactory: Nip46RelayConnectionFactory,
21
+ diagnosticLogger: (String) -> Unit = {}
22
+ ) : AutoCloseable {
23
+ constructor(
24
+ context: Context,
25
+ relayUrls: List<String>,
26
+ clientKey: Nip46ClientKey,
27
+ pairingSecret: String
28
+ ) : this(
29
+ relayUrls,
30
+ clientKey,
31
+ pairingSecret,
32
+ OkHttpNip46RelayConnectionFactory { message ->
33
+ AppDebugLogger.log(context, DIAGNOSTIC_TAG, message)
34
+ },
35
+ { message -> AppDebugLogger.log(context, DIAGNOSTIC_TAG, message) }
36
+ )
37
+ private val transport =
38
+ Nip46RelayTransport(
39
+ relayUrls = relayUrls,
40
+ remoteSignerPubkey = null,
41
+ clientPubkey = clientKey.publicKeyHex,
42
+ connectionFactory = connectionFactory,
43
+ diagnosticLogger = diagnosticLogger
44
+ )
45
+ private val completed = CountDownLatch(1)
46
+ private val lock = Any()
47
+ private var remoteSignerPubkey: String? = null
48
+ private val closed = AtomicBoolean(false)
49
+
50
+ init {
51
+ transport.start(::receiveEvent)
52
+ }
53
+
54
+ suspend fun awaitRelayReady(
55
+ timeoutMillis: Long = DEFAULT_RELAY_READY_TIMEOUT_MILLIS
56
+ ): Boolean = transport.awaitReady(timeoutMillis)
57
+
58
+ suspend fun awaitConnection(
59
+ timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS
60
+ ): BunkerConfig? = withContext(Dispatchers.IO) {
61
+ completed.await(timeoutMillis, TimeUnit.MILLISECONDS)
62
+ val remote = synchronized(lock) { remoteSignerPubkey }
63
+ ?: return@withContext null
64
+ val query =
65
+ relayUrls.joinToString("&") { relay ->
66
+ "relay=" + java.net.URLEncoder.encode(
67
+ relay,
68
+ Charsets.UTF_8.name()
69
+ )
70
+ }
71
+ BunkerConfig.parse("bunker://$remote?$query")
72
+ }
73
+
74
+ override fun close() {
75
+ if (closed.compareAndSet(false, true)) {
76
+ completed.countDown()
77
+ transport.close()
78
+ }
79
+ }
80
+
81
+ private fun receiveEvent(eventJson: String) {
82
+ val event =
83
+ try {
84
+ JSONObject(eventJson)
85
+ } catch (_: Exception) {
86
+ return
87
+ }
88
+ val author = event.optString("pubkey")
89
+ if (!author.matches(Regex("[0-9a-f]{64}"))) {
90
+ return
91
+ }
92
+ if (
93
+ event.optInt("kind", -1) != EVENT_KIND ||
94
+ !hasRecipient(event.optJSONArray("tags")) ||
95
+ !NostrEventVerifier.verify(event)
96
+ ) {
97
+ return
98
+ }
99
+ val response =
100
+ try {
101
+ val decrypted =
102
+ clientKey.decryptNip44(
103
+ event.getString("content"),
104
+ author
105
+ )
106
+ JSONObject(decrypted)
107
+ } catch (_: Exception) {
108
+ return
109
+ }
110
+ val result = response.opt("result")
111
+ val error = response.opt("error")
112
+ if (
113
+ result !is String ||
114
+ result != pairingSecret ||
115
+ (error != null && error != JSONObject.NULL)
116
+ ) {
117
+ return
118
+ }
119
+ synchronized(lock) {
120
+ if (remoteSignerPubkey == null) {
121
+ remoteSignerPubkey = author
122
+ completed.countDown()
123
+ }
124
+ }
125
+ }
126
+
127
+ private fun hasRecipient(tags: JSONArray?): Boolean {
128
+ if (tags == null) {
129
+ return false
130
+ }
131
+ return (0 until tags.length()).any { index ->
132
+ val tag = tags.optJSONArray(index)
133
+ tag != null &&
134
+ tag.length() >= 2 &&
135
+ tag.optString(0) == "p" &&
136
+ tag.optString(1) == clientKey.publicKeyHex
137
+ }
138
+ }
139
+
140
+ companion object {
141
+ private const val DIAGNOSTIC_TAG = "NIP46"
142
+ private const val EVENT_KIND = 24133
143
+ private const val DEFAULT_RELAY_READY_TIMEOUT_MILLIS = 30_000L
144
+ private const val DEFAULT_TIMEOUT_MILLIS = 300_000L
145
+ private val secureRandom = SecureRandom()
146
+
147
+ fun generateSecret(): String {
148
+ val bytes = ByteArray(32)
149
+ secureRandom.nextBytes(bytes)
150
+ return try {
151
+ bytes.joinToString("") { byte ->
152
+ "%02x".format(byte.toInt() and 0xff)
153
+ }
154
+ } finally {
155
+ bytes.fill(0)
156
+ }
157
+ }
158
+ }
159
+ }