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
@@ -4,246 +4,323 @@ import android.content.Context
4
4
  import android.content.Intent
5
5
  import androidx.activity.result.ActivityResult
6
6
  import androidx.activity.result.ActivityResultLauncher
7
- import com.pakstr.app.signer.interfaces.NostrSigner
8
7
  import androidx.core.content.edit
8
+ import com.pakstr.app.crypto.Nip46ClientKey
9
9
  import com.pakstr.app.debug.AppDebugLogger
10
+ import com.pakstr.app.signer.bunker.BunkerConfig
11
+ import com.pakstr.app.signer.bunker.BunkerConnectionStore
12
+ import com.pakstr.app.signer.bunker.Nip46PairingClient
13
+ import com.pakstr.app.signer.bunker.Nip46PairingUri
14
+ import com.pakstr.app.signer.bunker.RelayNip46Client
10
15
  import com.pakstr.app.signer.exceptions.SignerUnavailableException
16
+ import com.pakstr.app.signer.interfaces.NostrSigner
17
+ import org.json.JSONObject
11
18
 
12
- /**
13
- * Manages Nostr signer selection and lifecycle.
14
- *
15
- * Provides a single access point for obtaining the active NostrSigner
16
- * implementation used by the application.
17
- *
18
- * Supported signer types:
19
- *
20
- * - Amber
21
- * External Android signer app using the nostrsigner URI scheme.
22
- *
23
- * - Fake
24
- * Test signer used for development and testing.
25
- *
26
- * - Bunker
27
- * Reserved for NIP-46 remote signer implementation.
28
- *
29
- * The selected signer type is persisted using SharedPreferences.
30
- * The signer instance is cached and recreated only when the user
31
- * changes the active signer.
32
- */
33
19
  class SignerManager(
34
20
  context: Context,
35
- private val amberLauncher: ActivityResultLauncher<Intent>
21
+ private val amberLauncher: ActivityResultLauncher<Intent>,
22
+ private val bunkerAuthChallengeHandler: ((String) -> Unit)? = null
36
23
  ) {
37
- private val context =
38
- context.applicationContext
39
-
24
+ private val context = context.applicationContext
40
25
  private val prefs =
41
26
  this.context.getSharedPreferences(
42
27
  "signer",
43
28
  Context.MODE_PRIVATE
44
29
  )
30
+ private val bunkerStore by lazy {
31
+ BunkerConnectionStore(this.context)
32
+ }
45
33
 
46
34
  private var activeSigner: NostrSigner? = null
47
-
48
35
  private var currentUserPubkey: String? = null
36
+ private var pendingPairing: PendingPairing? = null
49
37
 
50
38
  init {
51
-
52
39
  AppDebugLogger.log(
53
-
54
40
  context,
55
-
56
41
  "SIGNER",
57
-
58
42
  "SignerManager initialized. Active type: ${getActiveType()}"
59
-
60
43
  )
61
-
62
44
  }
63
45
 
64
46
  fun getActiveType(): SignerType? {
65
-
66
- val value =
67
- prefs.getString(
68
- "type",
69
- SignerType.AMBER.name
70
- )
71
-
47
+ val value = prefs.getString("type", SignerType.AMBER.name)
72
48
  return try {
73
49
  SignerType.valueOf(value.toString())
74
-
75
50
  } catch (_: IllegalArgumentException) {
76
51
  SignerType.AMBER
77
52
  }
78
53
  }
79
54
 
80
- suspend fun getCurrentUserPubkey(): String {
81
-
82
- currentUserPubkey?.let {
83
-
84
- return it
85
-
55
+ fun hasBunkerSession(): Boolean {
56
+ return try {
57
+ val connection = bunkerStore.restore() ?: return false
58
+ connection.clientKey.close()
59
+ true
60
+ } catch (_: Exception) {
61
+ false
86
62
  }
63
+ }
87
64
 
88
- val pk =
89
-
90
- getSigner()
91
-
92
- .getPublicKey()
93
-
94
- currentUserPubkey = pk
95
-
65
+ suspend fun getCurrentUserPubkey(): String {
66
+ currentUserPubkey?.let { return it }
67
+ val publicKey = getSigner().getPublicKey()
68
+ currentUserPubkey = publicKey
96
69
  AppDebugLogger.log(
97
-
98
70
  context,
99
-
100
71
  "SIGNER",
72
+ "Public key received: ${publicKey.take(12)}..."
73
+ )
74
+ return publicKey
75
+ }
101
76
 
102
- "Public key received: ${pk.take(12)}..."
77
+ suspend fun createBunkerPairingUri(
78
+ relayUrls: List<String>,
79
+ permissions: String? = null
80
+ ): String {
81
+ cancelBunkerPairing()
82
+ val clientKey = Nip46ClientKey.generate()
83
+ val secret = Nip46PairingClient.generateSecret()
84
+ var client: Nip46PairingClient? = null
85
+ var exposed = false
86
+ try {
87
+ val pairingUri =
88
+ Nip46PairingUri.create(
89
+ clientKey.publicKeyHex,
90
+ relayUrls,
91
+ secret,
92
+ permissions,
93
+ context.getString(com.pakstr.app.R.string.app_name)
94
+ ).encode()
95
+ client =
96
+ Nip46PairingClient(
97
+ context,
98
+ relayUrls,
99
+ clientKey,
100
+ secret
101
+ )
102
+ if (!client.awaitRelayReady()) {
103
+ throw SignerUnavailableException(
104
+ "Unable to subscribe for NIP-46 pairing responses"
105
+ )
106
+ }
107
+ synchronized(this) {
108
+ check(pendingPairing == null) {
109
+ "Another NIP-46 pairing attempt is active"
110
+ }
111
+ pendingPairing = PendingPairing(clientKey, client)
112
+ }
113
+ exposed = true
114
+ return pairingUri
115
+ } finally {
116
+ if (!exposed) {
117
+ client?.close()
118
+ clientKey.close()
119
+ }
120
+ }
121
+ }
103
122
 
104
- )
123
+ suspend fun finishBunkerPairing(): Boolean {
124
+ val pairing = pendingPairing ?: return false
125
+ try {
126
+ val config = pairing.client.awaitConnection()
127
+ ?: return false
128
+ bunkerStore.save(config, pairing.clientKey, null)
129
+ closeActiveSigner()
130
+ setActiveTypeWithoutClosing(SignerType.BUNKER)
131
+ return true
132
+ } finally {
133
+ pendingPairing = null
134
+ pairing.client.close()
135
+ pairing.clientKey.close()
136
+ }
137
+ }
105
138
 
106
- return pk
139
+ fun cancelBunkerPairing() {
140
+ val pairing = pendingPairing ?: return
141
+ pendingPairing = null
142
+ pairing.client.close()
143
+ pairing.clientKey.close()
144
+ }
107
145
 
146
+ fun connectBunker(connectionUri: String) {
147
+ val config = BunkerConfig.parse(connectionUri)
148
+ val clientKey = Nip46ClientKey.generate()
149
+ try {
150
+ bunkerStore.savePending(config, clientKey)
151
+ } catch (_: Exception) {
152
+ throw SignerUnavailableException(
153
+ "Unable to store bunker connection securely"
154
+ )
155
+ } finally {
156
+ clientKey.close()
157
+ }
158
+ closeActiveSigner()
159
+ setActiveTypeWithoutClosing(SignerType.BUNKER)
108
160
  }
109
161
 
110
- fun setActiveType(
111
- type: SignerType
112
- ) {
162
+ fun setActiveType(type: SignerType) {
113
163
  AppDebugLogger.log(
114
-
115
164
  context,
116
-
117
165
  "SIGNER",
118
-
119
166
  "Changing active signer to: ${type.name}"
120
-
121
167
  )
122
- prefs.edit {
123
- putString(
124
- "type",
125
- type.name
126
- )
127
- }
128
-
129
- activeSigner = null
168
+ closeActiveSigner()
169
+ setActiveTypeWithoutClosing(type)
130
170
  }
131
171
 
132
- /**
133
- * Returns the currently selected signer implementation.
134
- *
135
- * Creates the signer lazily on first request and reuses the same
136
- * instance until the signer type changes.
137
- */
138
- fun getSigner(): NostrSigner {
139
-
140
- activeSigner?.let {
141
- return it
172
+ fun logoutBunker() {
173
+ try {
174
+ (activeSigner as? BunkerSigner)?.disconnect()
175
+ } finally {
176
+ closeActiveSigner()
142
177
  }
178
+ try {
179
+ bunkerStore.clear()
180
+ } finally {
181
+ setActiveTypeWithoutClosing(SignerType.AMBER)
182
+ }
183
+ }
143
184
 
185
+ fun getSigner(): NostrSigner {
186
+ activeSigner?.let { return it }
144
187
  val activeType = getActiveType()
145
-
146
188
  AppDebugLogger.log(
147
-
148
189
  context,
149
-
150
190
  "SIGNER",
151
-
152
191
  "Creating signer instance: $activeType"
153
-
154
192
  )
155
193
 
156
194
  val signer =
157
- when (getActiveType()) {
158
-
159
- SignerType.AMBER -> {
160
- AppDebugLogger.log(
161
-
162
- context,
163
-
164
- "AMBER",
165
-
166
- "Creating Amber signer"
167
-
168
- )
169
- if (!isAmberInstalled()) {
170
- AppDebugLogger.error(
195
+ when (activeType) {
196
+ SignerType.AMBER -> createAmberSigner()
197
+ SignerType.BUNKER -> createBunkerSigner()
198
+ SignerType.FAKE -> FakeSigner()
199
+ null -> throw IllegalStateException("No signer selected")
200
+ }
201
+ activeSigner = signer
202
+ return signer
203
+ }
171
204
 
172
- context,
205
+ fun close() {
206
+ cancelBunkerPairing()
207
+ closeActiveSigner()
208
+ }
173
209
 
174
- "AMBER",
210
+ fun handleAmberResult(result: ActivityResult) {
211
+ AppDebugLogger.log(
212
+ context,
213
+ "AMBER",
214
+ "Activity result received: ${result.resultCode}"
215
+ )
216
+ (activeSigner as? AmberSigner)?.onResult(result)
217
+ }
175
218
 
176
- "Amber signer app is not installed"
219
+ private fun createAmberSigner(): AmberSigner {
220
+ AppDebugLogger.log(
221
+ context,
222
+ "AMBER",
223
+ "Creating Amber signer"
224
+ )
225
+ if (!isAmberInstalled()) {
226
+ AppDebugLogger.error(
227
+ context,
228
+ "AMBER",
229
+ "Amber signer app is not installed"
230
+ )
231
+ throw SignerUnavailableException(
232
+ "Amber signer application is not installed"
233
+ )
234
+ }
235
+ return AmberSigner(amberLauncher)
236
+ }
177
237
 
238
+ private fun createBunkerSigner(): BunkerSigner {
239
+ val connection =
240
+ try {
241
+ bunkerStore.restore()
242
+ } catch (_: Exception) {
243
+ null
244
+ } ?: throw SignerUnavailableException(
245
+ "Bunker connection is not configured"
246
+ )
247
+ currentUserPubkey = connection.userPubkey
248
+
249
+ val client =
250
+ try {
251
+ RelayNip46Client(
252
+ config = connection.config,
253
+ clientKey = connection.clientKey,
254
+ authChallengeHandler = bunkerAuthChallengeHandler,
255
+ initialUserPubkey = connection.userPubkey,
256
+ activationHandler = {
257
+ bunkerStore.save(
258
+ connection.config.withoutPairingSecret(),
259
+ connection.clientKey,
260
+ connection.userPubkey
178
261
  )
179
- throw SignerUnavailableException(
180
- "Amber signer application is not installed"
262
+ },
263
+ userPubkeyHandler = { userPubkey ->
264
+ bunkerStore.save(
265
+ connection.config.withoutPairingSecret(),
266
+ connection.clientKey,
267
+ userPubkey
181
268
  )
182
- }
183
-
184
- AmberSigner(
185
- amberLauncher
186
- )
187
- }
188
-
189
- SignerType.BUNKER -> {
190
- throw SignerUnavailableException(
191
- "Bunker signer is not implemented"
192
- )
193
- }
194
-
195
- SignerType.FAKE -> {
196
- FakeSigner()
197
- }
198
-
199
- null -> {
200
-
201
- throw IllegalStateException(
202
- "No signer selected"
203
- )
204
- }
269
+ currentUserPubkey = userPubkey
270
+ },
271
+ requestedPermissions = BUNKER_PERMISSIONS,
272
+ clientMetadataJson =
273
+ JSONObject()
274
+ .put(
275
+ "name",
276
+ context.getString(
277
+ com.pakstr.app.R.string.app_name
278
+ )
279
+ )
280
+ .toString(),
281
+ connected = connection.connected
282
+ )
283
+ } catch (_: Exception) {
284
+ connection.clientKey.close()
285
+ throw SignerUnavailableException(
286
+ "Unable to restore bunker connection"
287
+ )
205
288
  }
289
+ return BunkerSigner(context, client)
290
+ }
206
291
 
207
- activeSigner = signer
292
+ private fun setActiveTypeWithoutClosing(type: SignerType) {
293
+ prefs.edit {
294
+ putString("type", type.name)
295
+ }
296
+ }
208
297
 
209
- return signer
298
+ private fun closeActiveSigner() {
299
+ (activeSigner as? BunkerSigner)?.close()
300
+ activeSigner = null
301
+ currentUserPubkey = null
210
302
  }
211
303
 
212
- private fun isAmberInstalled(): Boolean {
304
+ private class PendingPairing(
305
+ val clientKey: Nip46ClientKey,
306
+ val client: Nip46PairingClient
307
+ )
213
308
 
309
+ private fun isAmberInstalled(): Boolean {
214
310
  return try {
215
-
216
- context.packageManager
217
- .getPackageInfo(
218
- "com.greenart7c3.nostrsigner",
219
- 0
220
- )
221
-
311
+ context.packageManager.getPackageInfo(
312
+ "com.greenart7c3.nostrsigner",
313
+ 0
314
+ )
222
315
  true
223
-
224
316
  } catch (_: Exception) {
225
-
226
317
  false
227
318
  }
228
319
  }
229
320
 
230
- /**
231
- * Forwards Android Activity results back to the active Amber signer.
232
- *
233
- * Required because Amber communication is based on the Android
234
- * Activity Result API.
235
- */
236
- fun handleAmberResult(result: ActivityResult) {
237
- AppDebugLogger.log(
238
-
239
- context,
240
-
241
- "AMBER",
242
-
243
- "Activity result received: ${result.resultCode}"
244
-
245
- )
246
- (activeSigner as? AmberSigner)
247
- ?.onResult(result)
321
+ private companion object {
322
+ const val BUNKER_PERMISSIONS =
323
+ "sign_event,nip04_encrypt,nip04_decrypt," +
324
+ "nip44_encrypt,nip44_decrypt"
248
325
  }
249
- }
326
+ }
@@ -0,0 +1,175 @@
1
+ package com.pakstr.app.signer.bunker
2
+
3
+ import com.pakstr.app.crypto.Bip340Verifier
4
+ import java.net.URI
5
+ import java.net.URLDecoder
6
+ import java.net.URLEncoder
7
+ import java.nio.charset.StandardCharsets
8
+
9
+ class BunkerConfig private constructor(
10
+ val remoteSignerPubkey: String,
11
+ relayUrls: List<String>,
12
+ pairingSecret: String?
13
+ ) {
14
+ val relayUrls: List<String> = relayUrls.toList()
15
+ private var pairingSecretValue: String? = pairingSecret
16
+ val pairingSecret: String?
17
+ get() = pairingSecretValue
18
+
19
+ internal fun clearPairingSecret() {
20
+ pairingSecretValue = null
21
+ }
22
+
23
+ internal fun withoutPairingSecret(): BunkerConfig {
24
+ return BunkerConfig(
25
+ remoteSignerPubkey,
26
+ relayUrls,
27
+ null
28
+ )
29
+ }
30
+
31
+ internal fun encode(): String {
32
+ val parameters = relayUrls.map { "relay" to it }.toMutableList()
33
+ pairingSecret?.let { parameters += "secret" to it }
34
+ return "bunker://$remoteSignerPubkey?" +
35
+ parameters.joinToString("&") { (name, value) ->
36
+ "${encodeParameter(name)}=${encodeParameter(value)}"
37
+ }
38
+ }
39
+
40
+ override fun toString(): String {
41
+ return "BunkerConfig(" +
42
+ "remoteSignerPubkey=${remoteSignerPubkey.take(12)}..., " +
43
+ "relayCount=${relayUrls.size}, " +
44
+ "hasPairingSecret=${pairingSecret != null}" +
45
+ ")"
46
+ }
47
+
48
+ companion object {
49
+ fun parse(value: String): BunkerConfig {
50
+ val uri = parseUri(value)
51
+
52
+ require(uri.scheme == "bunker") {
53
+ "Invalid bunker connection scheme"
54
+ }
55
+ require(uri.rawAuthority != null && uri.rawUserInfo == null) {
56
+ "Invalid bunker connection authority"
57
+ }
58
+ require(uri.rawPath.isNullOrEmpty()) {
59
+ "Invalid bunker connection path"
60
+ }
61
+ require(uri.rawFragment == null) {
62
+ "Invalid bunker connection fragment"
63
+ }
64
+
65
+ val remoteSignerPubkey = uri.host
66
+ require(
67
+ remoteSignerPubkey != null &&
68
+ remoteSignerPubkey.matches(Regex("[0-9a-f]{64}")) &&
69
+ Bip340Verifier.isValidXOnlyPubkey(remoteSignerPubkey)
70
+ ) {
71
+ "Invalid remote signer public key"
72
+ }
73
+
74
+ val parameters = parseQuery(uri.rawQuery)
75
+ val relayUrls =
76
+ parameters
77
+ .filter { it.first == "relay" }
78
+ .map { validateRelay(it.second) }
79
+
80
+ require(relayUrls.isNotEmpty()) {
81
+ "At least one relay is required"
82
+ }
83
+
84
+ val secrets =
85
+ parameters
86
+ .filter { it.first == "secret" }
87
+ .map { it.second }
88
+
89
+ require(secrets.size <= 1) {
90
+ "Multiple pairing secrets are not supported"
91
+ }
92
+
93
+ return BunkerConfig(
94
+ remoteSignerPubkey = remoteSignerPubkey,
95
+ relayUrls = relayUrls,
96
+ pairingSecret = secrets.singleOrNull()
97
+ )
98
+ }
99
+
100
+ private fun parseUri(value: String): URI {
101
+ require(value.isNotBlank() && value == value.trim()) {
102
+ "Invalid bunker connection"
103
+ }
104
+
105
+ return try {
106
+ URI(value)
107
+ } catch (_: Exception) {
108
+ throw IllegalArgumentException(
109
+ "Invalid bunker connection"
110
+ )
111
+ }
112
+ }
113
+
114
+ private fun parseQuery(
115
+ rawQuery: String?
116
+ ): List<Pair<String, String>> {
117
+ if (rawQuery.isNullOrEmpty()) {
118
+ return emptyList()
119
+ }
120
+
121
+ return rawQuery
122
+ .split("&")
123
+ .map { parameter ->
124
+ val parts = parameter.split("=", limit = 2)
125
+ require(parts.size == 2) {
126
+ "Invalid bunker connection parameter"
127
+ }
128
+
129
+ decode(parts[0]) to decode(parts[1])
130
+ }
131
+ }
132
+
133
+ private fun validateRelay(value: String): String {
134
+ val relay =
135
+ try {
136
+ URI(value)
137
+ } catch (_: Exception) {
138
+ throw IllegalArgumentException(
139
+ "Invalid relay URL"
140
+ )
141
+ }
142
+
143
+ require(
144
+ relay.scheme == "wss" &&
145
+ !relay.host.isNullOrBlank() &&
146
+ relay.rawUserInfo == null &&
147
+ relay.rawFragment == null
148
+ ) {
149
+ "Invalid relay URL"
150
+ }
151
+
152
+ return relay.toASCIIString()
153
+ }
154
+
155
+ private fun encodeParameter(value: String): String {
156
+ return URLEncoder.encode(
157
+ value,
158
+ StandardCharsets.UTF_8.name()
159
+ )
160
+ }
161
+
162
+ private fun decode(value: String): String {
163
+ return try {
164
+ URLDecoder.decode(
165
+ value,
166
+ StandardCharsets.UTF_8.name()
167
+ )
168
+ } catch (_: Exception) {
169
+ throw IllegalArgumentException(
170
+ "Invalid bunker connection parameter"
171
+ )
172
+ }
173
+ }
174
+ }
175
+ }