pakstr 0.26.0 → 0.28.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.
@@ -42,7 +42,9 @@ import com.pakstr.app.permissions.interfaces.PermissionHandler
42
42
  import com.pakstr.app.signer.SignerManager
43
43
  import com.pakstr.app.signer.SignerType
44
44
  import kotlinx.coroutines.CancellationException
45
+ import kotlinx.coroutines.Dispatchers
45
46
  import kotlinx.coroutines.launch
47
+ import kotlinx.coroutines.withContext
46
48
  import com.pakstr.app.utils.AppPreferences
47
49
  import com.pakstr.app.utils.BunkerPairingDialog
48
50
  import com.pakstr.app.utils.SignerPickerBottomSheet
@@ -61,6 +63,7 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
61
63
  private lateinit var fab: FloatingActionButton
62
64
  private lateinit var supportUnlockGestureLayout: SupportUnlockGestureLayout
63
65
  private var bunkerPairingDialog: AlertDialog? = null
66
+ private var signerSetupInProgress = false
64
67
 
65
68
  private val diagnosticsHandler = Handler(Looper.getMainLooper())
66
69
  private val diagnosticsExpiryCheck = Runnable {
@@ -128,12 +131,6 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
128
131
  }
129
132
 
130
133
  initSigner()
131
- if (
132
- savedInstanceState == null &&
133
- AppPreferences.getBoolean("isFirstRun", true)
134
- ) {
135
- container.post(::showSignerPicker)
136
- }
137
134
 
138
135
  ViewCompat.setOnApplyWindowInsetsListener(
139
136
 
@@ -269,8 +266,26 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
269
266
  SignerManager(
270
267
  this,
271
268
  amberLauncher,
272
- bunkerAuthChallengeHandler = ::openBunkerAuthChallenge
269
+ bunkerAuthChallengeHandler = ::openBunkerAuthChallenge,
270
+ signerPickerRequestHandler = ::requestSignerPicker
273
271
  )
272
+ signerManager.migrateLegacySignerConfiguration(
273
+ setupCompleted = !AppPreferences.getBoolean("isFirstRun", true)
274
+ )
275
+ }
276
+
277
+ private fun requestSignerPicker() {
278
+ runOnUiThread {
279
+ if (
280
+ isFinishing ||
281
+ isDestroyed ||
282
+ supportFragmentManager.isStateSaved
283
+ ) {
284
+ signerManager.cancelSignerSelection()
285
+ return@runOnUiThread
286
+ }
287
+ showSignerPicker()
288
+ }
274
289
  }
275
290
 
276
291
  private fun openBunkerAuthChallenge(value: String) {
@@ -408,6 +423,11 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
408
423
  }
409
424
 
410
425
  private fun showSignerPicker() {
426
+ if (
427
+ supportFragmentManager.findFragmentByTag(SIGNER_PICKER_TAG) != null
428
+ ) {
429
+ return
430
+ }
411
431
  SignerPickerBottomSheet(
412
432
  hasBunkerSession = signerManager.hasBunkerSession(),
413
433
  onAmberSelected = {
@@ -423,7 +443,14 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
423
443
  false
424
444
  }
425
445
  },
426
- onSharePairing = ::shareBunkerPairing,
446
+ onLocalSelected = {
447
+ signerSetupInProgress = true
448
+ createLocalAccount()
449
+ },
450
+ onSharePairing = { relayUrls ->
451
+ signerSetupInProgress = true
452
+ shareBunkerPairing(relayUrls)
453
+ },
427
454
  onBunkerLogout = {
428
455
  signerManager.logoutBunker()
429
456
  Toast.makeText(
@@ -431,13 +458,45 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
431
458
  R.string.signer_picker_disconnected,
432
459
  Toast.LENGTH_SHORT
433
460
  ).show()
461
+ },
462
+ onDismissed = {
463
+ if (!signerSetupInProgress) {
464
+ signerManager.cancelSignerSelection()
465
+ }
434
466
  }
435
467
  ).show(
436
468
  supportFragmentManager,
437
- "SignerPicker"
469
+ SIGNER_PICKER_TAG
438
470
  )
439
471
  }
440
472
 
473
+ private fun createLocalAccount() {
474
+ lifecycleScope.launch {
475
+ val created =
476
+ withContext(Dispatchers.IO) {
477
+ try {
478
+ signerManager.createLocalAccount()
479
+ true
480
+ } catch (cancelled: CancellationException) {
481
+ throw cancelled
482
+ } catch (_: Exception) {
483
+ false
484
+ }
485
+ }
486
+ signerSetupInProgress = false
487
+ if (created) {
488
+ handleSignerSetup()
489
+ } else {
490
+ signerManager.cancelSignerSelection()
491
+ Toast.makeText(
492
+ this@MainActivity,
493
+ R.string.signer_picker_local_failed,
494
+ Toast.LENGTH_LONG
495
+ ).show()
496
+ }
497
+ }
498
+ }
499
+
441
500
  private fun shareBunkerPairing(relayUrls: List<String>) {
442
501
  lifecycleScope.launch {
443
502
  val pairingUri =
@@ -446,6 +505,8 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
446
505
  } catch (cancelled: CancellationException) {
447
506
  throw cancelled
448
507
  } catch (_: Exception) {
508
+ signerSetupInProgress = false
509
+ signerManager.cancelSignerSelection()
449
510
  Toast.makeText(
450
511
  this@MainActivity,
451
512
  R.string.signer_picker_connection_failed,
@@ -469,7 +530,18 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
469
530
  R.string.signer_picker_pairing_waiting,
470
531
  Toast.LENGTH_LONG
471
532
  ).show()
472
- val connected = signerManager.finishBunkerPairing()
533
+ val connected =
534
+ try {
535
+ signerManager.finishBunkerPairing()
536
+ } catch (cancelled: CancellationException) {
537
+ throw cancelled
538
+ } catch (_: Exception) {
539
+ false
540
+ }
541
+ signerSetupInProgress = false
542
+ if (!connected) {
543
+ signerManager.cancelSignerSelection()
544
+ }
473
545
  if (pairingDialog.isShowing) {
474
546
  pairingDialog.dismiss()
475
547
  }
@@ -510,4 +582,8 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
510
582
  DebugReportShare.share(this)
511
583
 
512
584
  }
513
- }
585
+
586
+ private companion object {
587
+ const val SIGNER_PICKER_TAG = "SignerPicker"
588
+ }
589
+ }
@@ -0,0 +1,100 @@
1
+ package com.pakstr.app.signer
2
+
3
+ import com.pakstr.app.crypto.Bip340Hash
4
+ import com.pakstr.app.crypto.Nip46ClientKey
5
+ import com.pakstr.app.crypto.NostrEventVerifier
6
+ import com.pakstr.app.signer.interfaces.NostrSigner
7
+ import com.pakstr.app.signer.local.LocalAccountStore
8
+ import org.json.JSONObject
9
+
10
+ internal class LocalSigner(
11
+ private val keyLoader: () -> Nip46ClientKey
12
+ ) : NostrSigner {
13
+ constructor(store: LocalAccountStore) : this(
14
+ keyLoader = {
15
+ store.loadKey()
16
+ ?: throw IllegalStateException(
17
+ "Local account has not been created"
18
+ )
19
+ }
20
+ )
21
+ override suspend fun getPublicKey(): String {
22
+ return requireKey().use { key ->
23
+ key.publicKeyHex
24
+ }
25
+ }
26
+
27
+ override suspend fun signEvent(eventJson: String): String {
28
+ return requireKey().use { key ->
29
+ val event = JSONObject(eventJson)
30
+ val publicKey = key.publicKeyHex
31
+ val suppliedPublicKey = event.optString("pubkey")
32
+ require(
33
+ suppliedPublicKey.isBlank() ||
34
+ suppliedPublicKey == publicKey
35
+ ) {
36
+ "Event public key does not match local signer"
37
+ }
38
+ event.put("pubkey", publicKey)
39
+ NostrEventBuilder.addId(event)
40
+
41
+ val eventId =
42
+ Bip340Hash.hexToBytes(
43
+ event.getString("id")
44
+ )
45
+ val signature = key.sign(eventId)
46
+ try {
47
+ event.put(
48
+ "sig",
49
+ Bip340Hash.bytesToHex(signature)
50
+ )
51
+ } finally {
52
+ eventId.fill(0)
53
+ signature.fill(0)
54
+ }
55
+
56
+ check(NostrEventVerifier.verify(event)) {
57
+ "Generated signature failed verification"
58
+ }
59
+ event.toString()
60
+ }
61
+ }
62
+
63
+ override suspend fun nip04Encrypt(
64
+ pubkey: String,
65
+ plaintext: String
66
+ ): String {
67
+ throw UnsupportedOperationException(
68
+ "NIP-04 is not supported by the local signer"
69
+ )
70
+ }
71
+
72
+ override suspend fun nip04Decrypt(
73
+ pubkey: String,
74
+ ciphertext: String
75
+ ): String {
76
+ throw UnsupportedOperationException(
77
+ "NIP-04 is not supported by the local signer"
78
+ )
79
+ }
80
+
81
+ override suspend fun nip44Encrypt(
82
+ pubkey: String,
83
+ plaintext: String
84
+ ): String {
85
+ return requireKey().use { key ->
86
+ key.encryptNip44(plaintext, pubkey)
87
+ }
88
+ }
89
+
90
+ override suspend fun nip44Decrypt(
91
+ pubkey: String,
92
+ ciphertext: String
93
+ ): String {
94
+ return requireKey().use { key ->
95
+ key.decryptNip44(ciphertext, pubkey)
96
+ }
97
+ }
98
+
99
+ private fun requireKey() = keyLoader()
100
+ }
@@ -48,7 +48,7 @@ class NostrBridge(
48
48
  try {
49
49
  val pk =
50
50
  signerManager
51
- .getSigner()
51
+ .getConfiguredSigner()
52
52
  .getPublicKey()
53
53
  resolve(
54
54
  callbackId,
@@ -107,7 +107,7 @@ class NostrBridge(
107
107
 
108
108
  val result =
109
109
  signerManager
110
- .getSigner()
110
+ .getConfiguredSigner()
111
111
  .signEvent(
112
112
  payload
113
113
  )
@@ -180,7 +180,7 @@ class NostrBridge(
180
180
 
181
181
  val result =
182
182
  signerManager
183
- .getSigner()
183
+ .getConfiguredSigner()
184
184
  .nip04Encrypt(
185
185
  pubkey,
186
186
  plaintext
@@ -254,7 +254,7 @@ class NostrBridge(
254
254
 
255
255
  val result =
256
256
  signerManager
257
- .getSigner()
257
+ .getConfiguredSigner()
258
258
  .nip04Decrypt(
259
259
  pubkey,
260
260
  ciphertext
@@ -318,7 +318,7 @@ class NostrBridge(
318
318
 
319
319
  val result =
320
320
  signerManager
321
- .getSigner()
321
+ .getConfiguredSigner()
322
322
  .nip44Encrypt(
323
323
  json.getString("pubkey"),
324
324
  json.getString("plaintext")
@@ -373,7 +373,7 @@ class NostrBridge(
373
373
 
374
374
  val result =
375
375
  signerManager
376
- .getSigner()
376
+ .getConfiguredSigner()
377
377
  .nip44Decrypt(
378
378
  json.getString("pubkey"),
379
379
  json.getString("ciphertext")
@@ -14,12 +14,15 @@ import com.pakstr.app.signer.bunker.Nip46PairingUri
14
14
  import com.pakstr.app.signer.bunker.RelayNip46Client
15
15
  import com.pakstr.app.signer.exceptions.SignerUnavailableException
16
16
  import com.pakstr.app.signer.interfaces.NostrSigner
17
+ import com.pakstr.app.signer.local.LocalAccountStore
18
+ import kotlinx.coroutines.CompletableDeferred
17
19
  import org.json.JSONObject
18
20
 
19
21
  class SignerManager(
20
22
  context: Context,
21
23
  private val amberLauncher: ActivityResultLauncher<Intent>,
22
- private val bunkerAuthChallengeHandler: ((String) -> Unit)? = null
24
+ private val bunkerAuthChallengeHandler: ((String) -> Unit)? = null,
25
+ private val signerPickerRequestHandler: (() -> Unit)? = null
23
26
  ) {
24
27
  private val context = context.applicationContext
25
28
  private val prefs =
@@ -30,10 +33,14 @@ class SignerManager(
30
33
  private val bunkerStore by lazy {
31
34
  BunkerConnectionStore(this.context)
32
35
  }
36
+ private val localAccountStore by lazy {
37
+ LocalAccountStore(this.context)
38
+ }
33
39
 
34
40
  private var activeSigner: NostrSigner? = null
35
41
  private var currentUserPubkey: String? = null
36
42
  private var pendingPairing: PendingPairing? = null
43
+ private var pendingSignerSelection: CompletableDeferred<Boolean>? = null
37
44
 
38
45
  init {
39
46
  AppDebugLogger.log(
@@ -44,7 +51,7 @@ class SignerManager(
44
51
  }
45
52
 
46
53
  fun getActiveType(): SignerType? {
47
- val value = prefs.getString("type", SignerType.AMBER.name)
54
+ val value = prefs.getString(KEY_SIGNER_TYPE, SignerType.AMBER.name)
48
55
  return try {
49
56
  SignerType.valueOf(value.toString())
50
57
  } catch (_: IllegalArgumentException) {
@@ -52,6 +59,20 @@ class SignerManager(
52
59
  }
53
60
  }
54
61
 
62
+ fun isSignerConfigured(): Boolean =
63
+ prefs.getBoolean(KEY_SIGNER_CONFIGURED, false)
64
+
65
+ fun migrateLegacySignerConfiguration(setupCompleted: Boolean) {
66
+ if (prefs.contains(KEY_SIGNER_CONFIGURED)) {
67
+ return
68
+ }
69
+ if (setupCompleted || prefs.contains(KEY_SIGNER_TYPE)) {
70
+ prefs.edit {
71
+ putBoolean(KEY_SIGNER_CONFIGURED, true)
72
+ }
73
+ }
74
+ }
75
+
55
76
  fun hasBunkerSession(): Boolean {
56
77
  return try {
57
78
  val connection = bunkerStore.restore() ?: return false
@@ -169,6 +190,12 @@ class SignerManager(
169
190
  setActiveTypeWithoutClosing(type)
170
191
  }
171
192
 
193
+ fun createLocalAccount() {
194
+ localAccountStore.createAccount()
195
+ closeActiveSigner()
196
+ setActiveTypeWithoutClosing(SignerType.LOCAL)
197
+ }
198
+
172
199
  fun logoutBunker() {
173
200
  try {
174
201
  (activeSigner as? BunkerSigner)?.disconnect()
@@ -182,6 +209,56 @@ class SignerManager(
182
209
  }
183
210
  }
184
211
 
212
+ suspend fun getConfiguredSigner(): NostrSigner {
213
+ if (isSignerConfigured()) {
214
+ return getSigner()
215
+ }
216
+
217
+ var shouldRequestPicker = false
218
+ val selection =
219
+ synchronized(this) {
220
+ if (isSignerConfigured()) {
221
+ null
222
+ } else {
223
+ pendingSignerSelection
224
+ ?: CompletableDeferred<Boolean>().also {
225
+ pendingSignerSelection = it
226
+ shouldRequestPicker = true
227
+ }
228
+ }
229
+ }
230
+
231
+ if (selection == null) {
232
+ return getSigner()
233
+ }
234
+ if (shouldRequestPicker) {
235
+ val handler = signerPickerRequestHandler
236
+ if (handler == null) {
237
+ cancelSignerSelection()
238
+ } else {
239
+ try {
240
+ handler()
241
+ } catch (_: Exception) {
242
+ cancelSignerSelection()
243
+ }
244
+ }
245
+ }
246
+ if (!selection.await()) {
247
+ throw SignerUnavailableException("Signer selection was dismissed")
248
+ }
249
+ return getSigner()
250
+ }
251
+
252
+ fun cancelSignerSelection() {
253
+ val selection =
254
+ synchronized(this) {
255
+ pendingSignerSelection.also {
256
+ pendingSignerSelection = null
257
+ }
258
+ }
259
+ selection?.complete(false)
260
+ }
261
+
185
262
  fun getSigner(): NostrSigner {
186
263
  activeSigner?.let { return it }
187
264
  val activeType = getActiveType()
@@ -195,6 +272,7 @@ class SignerManager(
195
272
  when (activeType) {
196
273
  SignerType.AMBER -> createAmberSigner()
197
274
  SignerType.BUNKER -> createBunkerSigner()
275
+ SignerType.LOCAL -> createLocalSigner()
198
276
  SignerType.FAKE -> FakeSigner()
199
277
  null -> throw IllegalStateException("No signer selected")
200
278
  }
@@ -204,6 +282,7 @@ class SignerManager(
204
282
 
205
283
  fun close() {
206
284
  cancelBunkerPairing()
285
+ cancelSignerSelection()
207
286
  closeActiveSigner()
208
287
  }
209
288
 
@@ -289,10 +368,36 @@ class SignerManager(
289
368
  return BunkerSigner(context, client)
290
369
  }
291
370
 
371
+ private fun createLocalSigner(): LocalSigner {
372
+ try {
373
+ localAccountStore.loadKey()?.use { key ->
374
+ currentUserPubkey = key.publicKeyHex
375
+ } ?: throw SignerUnavailableException(
376
+ "Local account has not been created"
377
+ )
378
+ } catch (error: SignerUnavailableException) {
379
+ throw error
380
+ } catch (error: Exception) {
381
+ throw IllegalStateException(
382
+ "Unable to restore local account",
383
+ error
384
+ )
385
+ }
386
+ return LocalSigner(localAccountStore)
387
+ }
388
+
292
389
  private fun setActiveTypeWithoutClosing(type: SignerType) {
293
390
  prefs.edit {
294
- putString("type", type.name)
391
+ putString(KEY_SIGNER_TYPE, type.name)
392
+ putBoolean(KEY_SIGNER_CONFIGURED, true)
295
393
  }
394
+ val selection =
395
+ synchronized(this) {
396
+ pendingSignerSelection.also {
397
+ pendingSignerSelection = null
398
+ }
399
+ }
400
+ selection?.complete(true)
296
401
  }
297
402
 
298
403
  private fun closeActiveSigner() {
@@ -319,6 +424,8 @@ class SignerManager(
319
424
  }
320
425
 
321
426
  private companion object {
427
+ const val KEY_SIGNER_TYPE = "type"
428
+ const val KEY_SIGNER_CONFIGURED = "signer_configured"
322
429
  const val BUNKER_PERMISSIONS =
323
430
  "sign_event,nip04_encrypt,nip04_decrypt," +
324
431
  "nip44_encrypt,nip44_decrypt"
@@ -3,6 +3,7 @@ package com.pakstr.app.signer
3
3
  enum class SignerType {
4
4
  AMBER,
5
5
  BUNKER,
6
+ LOCAL,
6
7
  FAKE
7
8
 
8
9
  }