pakstr 0.28.0 → 0.29.1

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 (27) hide show
  1. package/README.md +10 -0
  2. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +65 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +78 -0
  4. package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +40 -6
  5. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +124 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip19Codec.kt +181 -0
  7. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +25 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountImportCandidate.kt +42 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountStore.kt +17 -2
  10. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +80 -4
  11. package/android-template/app/src/main/res/layout/activity_developer_tools.xml +15 -0
  12. package/android-template/app/src/main/res/layout/dialog_picker.xml +62 -0
  13. package/android-template/app/src/main/res/values/strings.xml +15 -2
  14. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
  15. package/dist/cli.js +13 -6
  16. package/dist/commands/artifactPublisher.js +125 -0
  17. package/dist/commands/build.js +16 -30
  18. package/dist/commands/publish.js +37 -3
  19. package/dist/commands/run.js +60 -29
  20. package/dist/core/apkSignature.js +197 -0
  21. package/dist/core/nsite.js +49 -0
  22. package/dist/runners/DockerGradleRunner.js +45 -24
  23. package/dist/runners/DockerSignRunner.js +58 -19
  24. package/dist/runners/LocalGradleRunner.js +7 -0
  25. package/dist/runners/process.js +51 -0
  26. package/dist/runners/timeouts.js +66 -0
  27. package/package.json +4 -3
package/README.md CHANGED
@@ -95,6 +95,16 @@ containers alike, because all of them are created the same way. Leave it unset o
95
95
  workstation, where Docker's default network is what you want - host networking is not
96
96
  available on Docker Desktop for macOS.
97
97
 
98
+ **Timeouts.** So a stalled build or an unresponsive Docker daemon fails instead of hanging
99
+ the job, the Gradle build and the builder image pull are stopped after 60 minutes, signing
100
+ after 15, file copies into and out of containers after 10, and other Docker calls after 2.
101
+ Containers left by a stopped or failed run are removed. If a cold build on a slow runner needs
102
+ longer, raise the build limit with `PAKSTR_BUILD_TIMEOUT_MINUTES` (`0` disables it):
103
+
104
+ ```bash
105
+ PAKSTR_BUILD_TIMEOUT_MINUTES=120 npx pakstr run
106
+ ```
107
+
98
108
  ---
99
109
 
100
110
  ## Documentation & License
@@ -22,6 +22,7 @@ import org.junit.After
22
22
  import org.junit.Assert.assertEquals
23
23
  import org.junit.Assert.assertFalse
24
24
  import org.junit.Assert.assertNull
25
+ import org.junit.Assert.assertThrows
25
26
  import org.junit.Assert.assertTrue
26
27
  import org.junit.Assert.fail
27
28
  import org.junit.Test
@@ -289,6 +290,64 @@ class SignerManagerTest {
289
290
  }
290
291
  }
291
292
 
293
+ @Test
294
+ fun imported_local_account_survives_manager_recreation() = runBlocking {
295
+ clearLocalAccount(context)
296
+ val first = SignerManager(context, NoOpLauncher())
297
+ try {
298
+ val candidate = first.prepareLocalAccountImport(NSEC_ONE)
299
+ assertEquals(NPUB_ONE, candidate.npub)
300
+ first.importLocalAccount(candidate)
301
+
302
+ assertTrue(first.hasLocalAccount())
303
+ assertEquals(SignerType.LOCAL, first.getActiveType())
304
+ assertEquals(PUBLIC_KEY_ONE, first.getCurrentUserPubkey())
305
+ first.close()
306
+
307
+ val restored = SignerManager(context, NoOpLauncher())
308
+ try {
309
+ assertEquals(SignerType.LOCAL, restored.getActiveType())
310
+ assertEquals(PUBLIC_KEY_ONE, restored.getCurrentUserPubkey())
311
+ val event =
312
+ restored.getSigner().signEvent(
313
+ "{\"kind\":1,\"created_at\":1," +
314
+ "\"tags\":[],\"content\":\"imported\"}"
315
+ )
316
+ assertTrue(event.contains(PUBLIC_KEY_ONE))
317
+ } finally {
318
+ restored.close()
319
+ }
320
+ } finally {
321
+ first.close()
322
+ clearLocalAccount(context)
323
+ }
324
+ }
325
+
326
+ @Test
327
+ fun invalid_or_duplicate_import_does_not_change_signer() = runBlocking {
328
+ clearLocalAccount(context)
329
+ val manager = SignerManager(context, NoOpLauncher())
330
+ try {
331
+ manager.setActiveType(SignerType.FAKE)
332
+ assertThrows(IllegalArgumentException::class.java) {
333
+ manager.prepareLocalAccountImport("not-an-nsec")
334
+ }
335
+ assertEquals(SignerType.FAKE, manager.getActiveType())
336
+ assertFalse(manager.hasLocalAccount())
337
+
338
+ manager.createLocalAccount()
339
+ val publicKey = manager.getCurrentUserPubkey()
340
+ assertThrows(IllegalStateException::class.java) {
341
+ manager.prepareLocalAccountImport(NSEC_ONE)
342
+ }
343
+ assertEquals(SignerType.LOCAL, manager.getActiveType())
344
+ assertEquals(publicKey, manager.getCurrentUserPubkey())
345
+ } finally {
346
+ manager.close()
347
+ clearLocalAccount(context)
348
+ }
349
+ }
350
+
292
351
  @Test
293
352
  fun switching_from_bunker_disconnects_and_closes_client() {
294
353
  val context =
@@ -458,6 +517,12 @@ class SignerManagerTest {
458
517
  const val VALID_PUBKEY =
459
518
  "cb227f60cb2a6d982ecbfc51b9199d340afc00b1ef81b67d8e3778e097e70d2c"
460
519
  const val PAIRING_SECRET = "pending-pairing-secret"
520
+ const val NSEC_ONE =
521
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"
522
+ const val NPUB_ONE =
523
+ "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
524
+ const val PUBLIC_KEY_ONE =
525
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
461
526
  }
462
527
 
463
528
  private class NoOpLauncher : ActivityResultLauncher<Intent>() {
@@ -9,6 +9,7 @@ import org.junit.Assert.assertEquals
9
9
  import org.junit.Assert.assertFalse
10
10
  import org.junit.Assert.assertNotEquals
11
11
  import org.junit.Assert.assertThrows
12
+ import org.junit.Assert.assertNull
12
13
  import org.junit.Assert.assertTrue
13
14
  import org.junit.Before
14
15
  import org.junit.Test
@@ -60,6 +61,81 @@ class LocalAccountStoreTest {
60
61
  assertEquals(first, second)
61
62
  }
62
63
 
64
+ @Test
65
+ fun imported_account_survives_store_recreation() {
66
+ val privateKey = ByteArray(32).also { it[31] = 1 }
67
+
68
+ try {
69
+ val importedPublicKey = store.importAccount(privateKey)
70
+ val restored =
71
+ LocalAccountStore(
72
+ context,
73
+ PREFERENCES_NAME,
74
+ KEY_ALIAS
75
+ ).loadKey()
76
+
77
+ requireNotNull(restored).use { key ->
78
+ assertEquals(importedPublicKey, key.publicKeyHex)
79
+ assertEquals(PUBLIC_KEY_ONE, key.publicKeyHex)
80
+ }
81
+ assertFalse(
82
+ preferences().all.values.any { value ->
83
+ value.toString().contains(
84
+ Base64.encodeToString(
85
+ privateKey,
86
+ Base64.NO_WRAP
87
+ )
88
+ )
89
+ }
90
+ )
91
+ } finally {
92
+ privateKey.fill(0)
93
+ }
94
+ }
95
+
96
+ @Test
97
+ fun import_rejects_existing_account_without_changing_it() {
98
+ val existingPublicKey = store.createAccount()
99
+ val preferences = preferences()
100
+ val existingIv =
101
+ preferences.getString(LocalAccountStore.KEY_IV, null)
102
+ val existingCiphertext =
103
+ preferences.getString(LocalAccountStore.KEY_CIPHERTEXT, null)
104
+ val privateKey = ByteArray(32).also { it[31] = 1 }
105
+
106
+ try {
107
+ assertThrows(LocalAccountAlreadyExistsException::class.java) {
108
+ store.importAccount(privateKey)
109
+ }
110
+ assertEquals(existingIv, preferences.getString(LocalAccountStore.KEY_IV, null))
111
+ assertEquals(
112
+ existingCiphertext,
113
+ preferences.getString(LocalAccountStore.KEY_CIPHERTEXT, null)
114
+ )
115
+ requireNotNull(store.loadKey()).use { key ->
116
+ assertEquals(existingPublicKey, key.publicKeyHex)
117
+ }
118
+ } finally {
119
+ privateKey.fill(0)
120
+ }
121
+ }
122
+
123
+ @Test
124
+ fun invalid_import_persists_nothing() {
125
+ val privateKey = ByteArray(32)
126
+
127
+ assertThrows(IllegalArgumentException::class.java) {
128
+ store.importAccount(privateKey)
129
+ }
130
+ assertNull(preferences().getString(LocalAccountStore.KEY_IV, null))
131
+ assertNull(
132
+ preferences().getString(
133
+ LocalAccountStore.KEY_CIPHERTEXT,
134
+ null
135
+ )
136
+ )
137
+ }
138
+
63
139
  @Test
64
140
  fun persisted_value_is_not_plaintext_private_key() {
65
141
  store.createAccount()
@@ -190,5 +266,7 @@ class LocalAccountStoreTest {
190
266
  private companion object {
191
267
  const val PREFERENCES_NAME = "local_signer_account_test"
192
268
  const val KEY_ALIAS = "pakstr_local_signer_wrapping_key_test"
269
+ const val PUBLIC_KEY_ONE =
270
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
193
271
  }
194
272
  }
@@ -5,8 +5,10 @@ import androidx.test.core.app.ActivityScenario
5
5
  import androidx.test.core.app.ApplicationProvider
6
6
  import androidx.test.espresso.Espresso.onView
7
7
  import androidx.test.espresso.action.ViewActions.click
8
+ import androidx.test.espresso.action.ViewActions.replaceText
8
9
  import androidx.test.espresso.assertion.ViewAssertions.matches
9
10
  import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
11
+ import androidx.test.espresso.matcher.ViewMatchers.isChecked
10
12
  import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
11
13
  import androidx.test.espresso.matcher.ViewMatchers.withId
12
14
  import androidx.test.espresso.matcher.ViewMatchers.Visibility.GONE
@@ -75,16 +77,13 @@ class SignerPickerBottomSheetTest {
75
77
  @Test
76
78
  fun local_option_creates_and_selects_local_signer() {
77
79
  ActivityScenario.launch(MainActivity::class.java).use { scenario ->
78
- scenario.onActivity { activity ->
79
- MainActivity::class.java
80
- .getDeclaredMethod("showSignerPicker")
81
- .apply { isAccessible = true }
82
- .invoke(activity)
83
- }
80
+ showPicker(scenario)
84
81
 
85
82
  onView(withId(R.id.radio_option_three))
86
83
  .check(matches(isDisplayed()))
87
84
  .perform(click())
85
+ onView(withId(R.id.radio_local_create))
86
+ .check(matches(isChecked()))
88
87
  onView(withId(R.id.bunker_uri_container))
89
88
  .check(matches(withEffectiveVisibility(GONE)))
90
89
  onView(withId(R.id.button_share_pairing))
@@ -95,6 +94,36 @@ class SignerPickerBottomSheetTest {
95
94
  }
96
95
  }
97
96
 
97
+ @Test
98
+ fun local_import_can_be_cancelled_before_persistence() {
99
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
100
+ showPicker(scenario)
101
+
102
+ onView(withId(R.id.radio_option_three)).perform(click())
103
+ onView(withId(R.id.radio_local_import)).perform(click())
104
+ onView(withId(R.id.local_nsec_container))
105
+ .check(matches(isDisplayed()))
106
+ onView(withId(R.id.local_nsec)).perform(replaceText(NSEC_ONE))
107
+ onView(withId(R.id.button_confirm)).perform(click())
108
+ onView(withId(android.R.id.button2)).perform(click())
109
+
110
+ scenario.onActivity { activity ->
111
+ assertTrue(!signerManager(activity).hasLocalAccount())
112
+ }
113
+ }
114
+ }
115
+
116
+ private fun showPicker(
117
+ scenario: ActivityScenario<MainActivity>
118
+ ) {
119
+ scenario.onActivity { activity ->
120
+ MainActivity::class.java
121
+ .getDeclaredMethod("showSignerPicker")
122
+ .apply { isAccessible = true }
123
+ .invoke(activity)
124
+ }
125
+ }
126
+
98
127
  private fun waitForLocalSelection(
99
128
  scenario: ActivityScenario<MainActivity>
100
129
  ) {
@@ -128,4 +157,9 @@ class SignerPickerBottomSheetTest {
128
157
  .apply { load(null) }
129
158
  .deleteEntry(LocalAccountStore.KEY_ALIAS)
130
159
  }
160
+
161
+ private companion object {
162
+ const val NSEC_ONE =
163
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"
164
+ }
131
165
  }
@@ -9,6 +9,7 @@ import android.os.Handler
9
9
  import android.os.Looper
10
10
 
11
11
  import android.view.View
12
+ import android.view.WindowManager
12
13
  import android.webkit.PermissionRequest
13
14
  import android.webkit.WebView
14
15
  import android.widget.FrameLayout
@@ -41,6 +42,7 @@ import com.pakstr.app.permissions.interfaces.PermissionHandler
41
42
 
42
43
  import com.pakstr.app.signer.SignerManager
43
44
  import com.pakstr.app.signer.SignerType
45
+ import com.pakstr.app.signer.local.LocalAccountImportCandidate
44
46
  import kotlinx.coroutines.CancellationException
45
47
  import kotlinx.coroutines.Dispatchers
46
48
  import kotlinx.coroutines.launch
@@ -63,6 +65,8 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
63
65
  private lateinit var fab: FloatingActionButton
64
66
  private lateinit var supportUnlockGestureLayout: SupportUnlockGestureLayout
65
67
  private var bunkerPairingDialog: AlertDialog? = null
68
+ private var localImportDialog: AlertDialog? = null
69
+ private var localImportCandidate: LocalAccountImportCandidate? = null
66
70
  private var signerSetupInProgress = false
67
71
 
68
72
  private val diagnosticsHandler = Handler(Looper.getMainLooper())
@@ -375,6 +379,11 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
375
379
 
376
380
  bunkerPairingDialog?.dismiss()
377
381
  bunkerPairingDialog = null
382
+ localImportDialog?.setOnDismissListener(null)
383
+ localImportDialog?.dismiss()
384
+ localImportDialog = null
385
+ localImportCandidate?.close()
386
+ localImportCandidate = null
378
387
 
379
388
  if (::signerManager.isInitialized) {
380
389
  signerManager.close()
@@ -428,8 +437,21 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
428
437
  ) {
429
438
  return
430
439
  }
440
+ val hasLocalAccount =
441
+ try {
442
+ signerManager.hasLocalAccount()
443
+ } catch (_: Exception) {
444
+ Toast.makeText(
445
+ this,
446
+ R.string.signer_picker_local_status_failed,
447
+ Toast.LENGTH_LONG
448
+ ).show()
449
+ signerManager.cancelSignerSelection()
450
+ return
451
+ }
431
452
  SignerPickerBottomSheet(
432
453
  hasBunkerSession = signerManager.hasBunkerSession(),
454
+ hasLocalAccount = hasLocalAccount,
433
455
  onAmberSelected = {
434
456
  signerManager.setActiveType(SignerType.AMBER)
435
457
  handleSignerSetup()
@@ -447,6 +469,23 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
447
469
  signerSetupInProgress = true
448
470
  createLocalAccount()
449
471
  },
472
+ onLocalImportSelected = { nsec ->
473
+ val candidate =
474
+ try {
475
+ signerManager.prepareLocalAccountImport(nsec)
476
+ } catch (_: Exception) {
477
+ null
478
+ }
479
+ if (candidate == null) {
480
+ false
481
+ } else {
482
+ signerSetupInProgress = true
483
+ localImportCandidate?.close()
484
+ localImportCandidate = candidate
485
+ showLocalImportConfirmation(candidate)
486
+ true
487
+ }
488
+ },
450
489
  onSharePairing = { relayUrls ->
451
490
  signerSetupInProgress = true
452
491
  shareBunkerPairing(relayUrls)
@@ -497,6 +536,91 @@ class MainActivity : AppCompatActivity(), PermissionHandler {
497
536
  }
498
537
  }
499
538
 
539
+ private fun showLocalImportConfirmation(
540
+ candidate: LocalAccountImportCandidate
541
+ ) {
542
+ if (localImportCandidate !== candidate) {
543
+ return
544
+ }
545
+ if (isFinishing || isDestroyed) {
546
+ discardLocalAccountImport()
547
+ return
548
+ }
549
+ var confirmed = false
550
+ val dialog =
551
+ AlertDialog.Builder(this)
552
+ .setTitle(R.string.signer_picker_local_confirm_title)
553
+ .setMessage(
554
+ getString(
555
+ R.string.signer_picker_local_confirm_message,
556
+ candidate.npub
557
+ )
558
+ )
559
+ .setPositiveButton(
560
+ R.string.signer_picker_local_confirm
561
+ ) { _, _ ->
562
+ confirmed = true
563
+ confirmLocalAccountImport(candidate)
564
+ }
565
+ .setNegativeButton(
566
+ R.string.signer_picker_local_cancel,
567
+ null
568
+ )
569
+ .create()
570
+ dialog.setOnDismissListener {
571
+ if (!confirmed) {
572
+ discardLocalAccountImport()
573
+ }
574
+ if (localImportDialog === dialog) {
575
+ localImportDialog = null
576
+ }
577
+ }
578
+ localImportDialog = dialog
579
+ dialog.show()
580
+ dialog.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
581
+ }
582
+
583
+ private fun confirmLocalAccountImport(
584
+ candidate: LocalAccountImportCandidate
585
+ ) {
586
+ if (localImportCandidate !== candidate) {
587
+ return
588
+ }
589
+ localImportCandidate = null
590
+ lifecycleScope.launch {
591
+ val imported =
592
+ withContext(Dispatchers.IO) {
593
+ try {
594
+ signerManager.importLocalAccount(candidate)
595
+ true
596
+ } catch (cancelled: CancellationException) {
597
+ candidate.close()
598
+ throw cancelled
599
+ } catch (_: Exception) {
600
+ false
601
+ }
602
+ }
603
+ signerSetupInProgress = false
604
+ if (imported) {
605
+ handleSignerSetup()
606
+ } else {
607
+ signerManager.cancelSignerSelection()
608
+ Toast.makeText(
609
+ this@MainActivity,
610
+ R.string.signer_picker_local_import_failed,
611
+ Toast.LENGTH_LONG
612
+ ).show()
613
+ }
614
+ }
615
+ }
616
+
617
+ private fun discardLocalAccountImport() {
618
+ localImportCandidate?.close()
619
+ localImportCandidate = null
620
+ signerSetupInProgress = false
621
+ signerManager.cancelSignerSelection()
622
+ }
623
+
500
624
  private fun shareBunkerPairing(relayUrls: List<String>) {
501
625
  lifecycleScope.launch {
502
626
  val pairingUri =
@@ -0,0 +1,181 @@
1
+ package com.pakstr.app.crypto
2
+
3
+ internal class InvalidNsecException : IllegalArgumentException(
4
+ "Invalid nsec"
5
+ )
6
+
7
+ internal object Nip19Codec {
8
+ private const val NSEC_HRP = "nsec"
9
+ private const val NPUB_HRP = "npub"
10
+ private const val PAYLOAD_SIZE = 32
11
+ private const val CHECKSUM_SIZE = 6
12
+ private const val MAX_ENCODED_LENGTH = 90
13
+ private const val BECH32_CONSTANT = 1
14
+ private const val CHARSET =
15
+ "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
16
+ private val reverseCharset = IntArray(128) { -1 }.apply {
17
+ CHARSET.forEachIndexed { index, character ->
18
+ this[character.code] = index
19
+ }
20
+ }
21
+
22
+ fun decodeNsec(value: String): ByteArray {
23
+ if (
24
+ value.isEmpty() ||
25
+ value.length > MAX_ENCODED_LENGTH ||
26
+ value != value.lowercase()
27
+ ) {
28
+ throw InvalidNsecException()
29
+ }
30
+
31
+ val separator = value.lastIndexOf('1')
32
+ if (
33
+ separator < 1 ||
34
+ separator + CHECKSUM_SIZE + 1 > value.length
35
+ ) {
36
+ throw InvalidNsecException()
37
+ }
38
+
39
+ val hrp = value.substring(0, separator)
40
+ if (
41
+ hrp != NSEC_HRP ||
42
+ hrp.any { it.code !in 33..126 }
43
+ ) {
44
+ throw InvalidNsecException()
45
+ }
46
+
47
+ val data = IntArray(value.length - separator - 1)
48
+ value.substring(separator + 1).forEachIndexed { index, character ->
49
+ val decoded =
50
+ if (character.code < reverseCharset.size) {
51
+ reverseCharset[character.code]
52
+ } else {
53
+ -1
54
+ }
55
+ if (decoded < 0) {
56
+ throw InvalidNsecException()
57
+ }
58
+ data[index] = decoded
59
+ }
60
+ if (polymod(hrpExpand(hrp) + data) != BECH32_CONSTANT) {
61
+ throw InvalidNsecException()
62
+ }
63
+
64
+ val decoded = convertBits(
65
+ data.copyOf(data.size - CHECKSUM_SIZE),
66
+ fromBits = 5,
67
+ toBits = 8,
68
+ pad = false
69
+ )
70
+ if (decoded.size != PAYLOAD_SIZE) {
71
+ decoded.fill(0)
72
+ throw InvalidNsecException()
73
+ }
74
+ return ByteArray(decoded.size) { index ->
75
+ decoded[index].toByte()
76
+ }.also {
77
+ decoded.fill(0)
78
+ }
79
+ }
80
+
81
+ fun encodeNpub(publicKey: ByteArray): String {
82
+ require(publicKey.size == PAYLOAD_SIZE) {
83
+ "Public key must be exactly 32 bytes"
84
+ }
85
+ val data = convertBits(
86
+ IntArray(publicKey.size) { index ->
87
+ publicKey[index].toInt() and 0xff
88
+ },
89
+ fromBits = 8,
90
+ toBits = 5,
91
+ pad = true
92
+ )
93
+ val checksum = createChecksum(NPUB_HRP, data)
94
+ return buildString {
95
+ append(NPUB_HRP)
96
+ append('1')
97
+ (data + checksum).forEach { append(CHARSET[it]) }
98
+ }
99
+ }
100
+
101
+ private fun createChecksum(
102
+ hrp: String,
103
+ data: IntArray
104
+ ): IntArray {
105
+ val values = hrpExpand(hrp) + data + IntArray(CHECKSUM_SIZE)
106
+ val remainder = polymod(values) xor BECH32_CONSTANT
107
+ return IntArray(CHECKSUM_SIZE) { index ->
108
+ (remainder shr (5 * (CHECKSUM_SIZE - 1 - index))) and 31
109
+ }
110
+ }
111
+
112
+ private fun hrpExpand(hrp: String): IntArray {
113
+ val result = IntArray(hrp.length * 2 + 1)
114
+ hrp.forEachIndexed { index, character ->
115
+ result[index] = character.code shr 5
116
+ result[hrp.length + 1 + index] = character.code and 31
117
+ }
118
+ return result
119
+ }
120
+
121
+ private fun polymod(values: IntArray): Int {
122
+ var checksum = 1
123
+ val generators = intArrayOf(
124
+ 0x3b6a57b2,
125
+ 0x26508e6d,
126
+ 0x1ea119fa,
127
+ 0x3d4233dd,
128
+ 0x2a1462b3
129
+ )
130
+ values.forEach { value ->
131
+ val top = checksum ushr 25
132
+ checksum = (checksum and 0x1ffffff) shl 5 xor value
133
+ generators.forEachIndexed { index, generator ->
134
+ if ((top shr index) and 1 != 0) {
135
+ checksum = checksum xor generator
136
+ }
137
+ }
138
+ }
139
+ return checksum
140
+ }
141
+
142
+ private fun convertBits(
143
+ values: IntArray,
144
+ fromBits: Int,
145
+ toBits: Int,
146
+ pad: Boolean
147
+ ): IntArray {
148
+ var accumulator = 0
149
+ var bitCount = 0
150
+ val output = ArrayList<Int>()
151
+ val fromMask = (1 shl fromBits) - 1
152
+ val toMask = (1 shl toBits) - 1
153
+
154
+ values.forEach { value ->
155
+ if (value < 0 || value ushr fromBits != 0) {
156
+ throw InvalidNsecException()
157
+ }
158
+ accumulator =
159
+ ((accumulator and ((1 shl (fromBits + toBits - 1)) - 1))
160
+ shl fromBits) or (value and fromMask)
161
+ bitCount += fromBits
162
+ while (bitCount >= toBits) {
163
+ bitCount -= toBits
164
+ output += (accumulator ushr bitCount) and toMask
165
+ }
166
+ }
167
+
168
+ if (pad) {
169
+ if (bitCount > 0) {
170
+ output += (accumulator shl (toBits - bitCount)) and toMask
171
+ }
172
+ } else if (
173
+ bitCount >= fromBits ||
174
+ ((accumulator shl (toBits - bitCount)) and toMask) != 0
175
+ ) {
176
+ throw InvalidNsecException()
177
+ }
178
+
179
+ return output.toIntArray()
180
+ }
181
+ }
@@ -14,6 +14,8 @@ 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.LocalAccountAlreadyExistsException
18
+ import com.pakstr.app.signer.local.LocalAccountImportCandidate
17
19
  import com.pakstr.app.signer.local.LocalAccountStore
18
20
  import kotlinx.coroutines.CompletableDeferred
19
21
  import org.json.JSONObject
@@ -196,6 +198,29 @@ class SignerManager(
196
198
  setActiveTypeWithoutClosing(SignerType.LOCAL)
197
199
  }
198
200
 
201
+ fun hasLocalAccount(): Boolean = localAccountStore.hasAccount()
202
+
203
+ internal fun prepareLocalAccountImport(
204
+ nsec: String
205
+ ): LocalAccountImportCandidate {
206
+ if (localAccountStore.hasAccount()) {
207
+ throw LocalAccountAlreadyExistsException()
208
+ }
209
+ return LocalAccountImportCandidate.parse(nsec)
210
+ }
211
+
212
+ internal fun importLocalAccount(
213
+ candidate: LocalAccountImportCandidate
214
+ ) {
215
+ try {
216
+ candidate.withPrivateKey(localAccountStore::importAccount)
217
+ closeActiveSigner()
218
+ setActiveTypeWithoutClosing(SignerType.LOCAL)
219
+ } finally {
220
+ candidate.close()
221
+ }
222
+ }
223
+
199
224
  fun logoutBunker() {
200
225
  try {
201
226
  (activeSigner as? BunkerSigner)?.disconnect()