pakstr 0.28.0 → 0.29.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.
@@ -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()
@@ -0,0 +1,42 @@
1
+ package com.pakstr.app.signer.local
2
+
3
+ import com.pakstr.app.crypto.Nip19Codec
4
+ import com.pakstr.app.crypto.Nip46ClientKey
5
+
6
+ internal class LocalAccountImportCandidate private constructor(
7
+ private val key: Nip46ClientKey,
8
+ val npub: String
9
+ ) : AutoCloseable {
10
+
11
+ internal fun <T> withPrivateKey(
12
+ block: (ByteArray) -> T
13
+ ): T = key.withPrivateKey(block)
14
+
15
+ override fun close() {
16
+ key.close()
17
+ }
18
+
19
+ companion object {
20
+ fun parse(nsec: String): LocalAccountImportCandidate {
21
+ val privateKey = Nip19Codec.decodeNsec(nsec)
22
+ return try {
23
+ val key = Nip46ClientKey.fromPrivateKey(privateKey)
24
+ try {
25
+ val publicKey = key.publicKey
26
+ val npub =
27
+ try {
28
+ Nip19Codec.encodeNpub(publicKey)
29
+ } finally {
30
+ publicKey.fill(0)
31
+ }
32
+ LocalAccountImportCandidate(key, npub)
33
+ } catch (error: Exception) {
34
+ key.close()
35
+ throw error
36
+ }
37
+ } finally {
38
+ privateKey.fill(0)
39
+ }
40
+ }
41
+ }
42
+ }
@@ -16,6 +16,9 @@ internal class LocalAccountStorageException(
16
16
  cause: Throwable? = null
17
17
  ) : Exception(message, cause)
18
18
 
19
+ internal class LocalAccountAlreadyExistsException :
20
+ IllegalStateException("Local account already exists")
21
+
19
22
  internal class LocalAccountStore(
20
23
  context: Context,
21
24
  private val preferencesName: String = PREFERENCES_NAME,
@@ -47,6 +50,18 @@ internal class LocalAccountStore(
47
50
  }
48
51
  }
49
52
 
53
+ fun importAccount(privateKey: ByteArray): String =
54
+ synchronized(STORAGE_LOCK) {
55
+ if (hasAccountLocked()) {
56
+ throw LocalAccountAlreadyExistsException()
57
+ }
58
+
59
+ Nip46ClientKey.fromPrivateKey(privateKey).use { key ->
60
+ key.withPrivateKey(::saveNewKey)
61
+ key.publicKeyHex
62
+ }
63
+ }
64
+
50
65
  fun hasAccount(): Boolean =
51
66
  synchronized(STORAGE_LOCK) {
52
67
  hasAccountLocked()
@@ -105,8 +120,8 @@ internal class LocalAccountStore(
105
120
  }
106
121
 
107
122
  private fun saveNewKey(privateKey: ByteArray) {
108
- check(!hasAccountLocked()) {
109
- "Local account already exists"
123
+ if (hasAccountLocked()) {
124
+ throw LocalAccountAlreadyExistsException()
110
125
  }
111
126
 
112
127
  val wrappingKey = getOrCreateWrappingKey()
@@ -5,6 +5,7 @@ import android.os.Bundle
5
5
  import android.view.LayoutInflater
6
6
  import android.view.View
7
7
  import android.view.ViewGroup
8
+ import android.view.WindowManager
8
9
  import android.widget.Button
9
10
  import android.widget.RadioGroup
10
11
  import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@@ -14,9 +15,11 @@ import com.pakstr.app.R
14
15
 
15
16
  class SignerPickerBottomSheet(
16
17
  private val hasBunkerSession: Boolean,
18
+ private val hasLocalAccount: Boolean,
17
19
  private val onAmberSelected: () -> Unit,
18
20
  private val onBunkerSelected: (String) -> Boolean,
19
21
  private val onLocalSelected: () -> Unit,
22
+ private val onLocalImportSelected: (String) -> Boolean,
20
23
  private val onSharePairing: (List<String>) -> Unit,
21
24
  private val onBunkerLogout: () -> Unit,
22
25
  private val onDismissed: () -> Unit = {}
@@ -34,6 +37,11 @@ class SignerPickerBottomSheet(
34
37
  )
35
38
  }
36
39
 
40
+ override fun onStart() {
41
+ super.onStart()
42
+ dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
43
+ }
44
+
37
45
  override fun onViewCreated(
38
46
  view: View,
39
47
  savedInstanceState: Bundle?
@@ -42,6 +50,11 @@ class SignerPickerBottomSheet(
42
50
  val radioGroup = view.findViewById<RadioGroup>(R.id.options_radio_group)
43
51
  val uriContainer = view.findViewById<TextInputLayout>(R.id.bunker_uri_container)
44
52
  val uriInput = view.findViewById<TextInputEditText>(R.id.bunker_uri)
53
+ val localActions = view.findViewById<View>(R.id.local_account_actions)
54
+ val localExisting = view.findViewById<View>(R.id.local_account_existing)
55
+ val localActionGroup = view.findViewById<RadioGroup>(R.id.local_action_radio_group)
56
+ val nsecContainer = view.findViewById<TextInputLayout>(R.id.local_nsec_container)
57
+ val nsecInput = view.findViewById<TextInputEditText>(R.id.local_nsec)
45
58
  val confirm = view.findViewById<Button>(R.id.button_confirm)
46
59
  val sharePairing = view.findViewById<Button>(R.id.button_share_pairing)
47
60
  val logout = view.findViewById<Button>(R.id.button_logout_bunker)
@@ -49,19 +62,58 @@ class SignerPickerBottomSheet(
49
62
  logout.visibility =
50
63
  if (hasBunkerSession) View.VISIBLE else View.GONE
51
64
 
52
- fun updateBunkerControls() {
65
+ fun updateControls() {
53
66
  val bunkerSelected =
54
67
  radioGroup.checkedRadioButtonId == R.id.radio_option_two
68
+ val localSelected =
69
+ radioGroup.checkedRadioButtonId == R.id.radio_option_three
55
70
  uriContainer.visibility =
56
71
  if (bunkerSelected) View.VISIBLE else View.GONE
57
72
  sharePairing.visibility =
58
73
  if (bunkerSelected) View.VISIBLE else View.GONE
74
+ localActions.visibility =
75
+ if (localSelected && !hasLocalAccount) {
76
+ View.VISIBLE
77
+ } else {
78
+ View.GONE
79
+ }
80
+ localExisting.visibility =
81
+ if (localSelected && hasLocalAccount) {
82
+ View.VISIBLE
83
+ } else {
84
+ View.GONE
85
+ }
86
+ nsecContainer.visibility =
87
+ if (
88
+ localSelected &&
89
+ !hasLocalAccount &&
90
+ localActionGroup.checkedRadioButtonId ==
91
+ R.id.radio_local_import
92
+ ) {
93
+ View.VISIBLE
94
+ } else {
95
+ View.GONE
96
+ }
59
97
  }
60
98
  radioGroup.setOnCheckedChangeListener { _, _ ->
61
99
  uriContainer.error = null
62
- updateBunkerControls()
100
+ nsecContainer.error = null
101
+ if (radioGroup.checkedRadioButtonId != R.id.radio_option_three) {
102
+ nsecInput.text?.clear()
103
+ }
104
+ updateControls()
63
105
  }
64
- updateBunkerControls()
106
+ localActionGroup.setOnCheckedChangeListener { _, _ ->
107
+ nsecContainer.error = null
108
+ if (
109
+ localActionGroup.checkedRadioButtonId !=
110
+ R.id.radio_local_import
111
+ ) {
112
+ nsecInput.text?.clear()
113
+ }
114
+ updateControls()
115
+ }
116
+ updateControls()
65
117
 
66
118
  confirm.setOnClickListener {
67
119
  when (radioGroup.checkedRadioButtonId) {
@@ -73,7 +125,24 @@ class SignerPickerBottomSheet(
73
125
  return@setOnClickListener
74
126
  }
75
127
  }
76
- R.id.radio_option_three -> onLocalSelected()
128
+ R.id.radio_option_three -> {
129
+ val importing =
130
+ !hasLocalAccount &&
131
+ localActionGroup.checkedRadioButtonId ==
132
+ R.id.radio_local_import
133
+ if (importing) {
134
+ val value =
135
+ nsecInput.text?.toString().orEmpty().trim()
136
+ if (!onLocalImportSelected(value)) {
137
+ nsecContainer.error =
138
+ getString(R.string.signer_picker_local_invalid)
139
+ return@setOnClickListener
140
+ }
141
+ nsecInput.text?.clear()
142
+ } else {
143
+ onLocalSelected()
144
+ }
145
+ }
77
146
  else -> onAmberSelected()
78
147
  }
79
148
  dismiss()
@@ -104,6 +173,13 @@ class SignerPickerBottomSheet(
104
173
  }
105
174
  }
106
175
 
176
+ override fun onDestroyView() {
177
+ view?.findViewById<TextInputEditText>(R.id.local_nsec)
178
+ ?.text
179
+ ?.clear()
180
+ super.onDestroyView()
181
+ }
182
+
107
183
  override fun onDismiss(dialog: DialogInterface) {
108
184
  super.onDismiss(dialog)
109
185
  onDismissed()
@@ -208,6 +208,21 @@
208
208
  android:layout_height="wrap_content"
209
209
  android:textColor="?android:attr/textColorSecondary" />
210
210
  </LinearLayout>
211
+
212
+ <TextView
213
+ style="@style/TextAppearance.MaterialComponents.Subtitle2"
214
+ android:layout_width="match_parent"
215
+ android:layout_height="wrap_content"
216
+ android:layout_marginTop="12dp"
217
+ android:text="@string/developer_tools_chrome_webview_inspection" />
218
+
219
+ <TextView
220
+ style="@style/TextAppearance.MaterialComponents.Caption"
221
+ android:layout_width="match_parent"
222
+ android:layout_height="wrap_content"
223
+ android:layout_marginTop="4dp"
224
+ android:text="@string/developer_tools_chrome_webview_inspection_note"
225
+ android:textColor="?android:attr/textColorSecondary" />
211
226
  </LinearLayout>
212
227
  </com.google.android.material.card.MaterialCardView>
213
228
 
@@ -97,6 +97,68 @@
97
97
  android:textSize="16sp" />
98
98
  </RadioGroup>
99
99
 
100
+ <LinearLayout
101
+ android:id="@+id/local_account_actions"
102
+ android:layout_width="match_parent"
103
+ android:layout_height="wrap_content"
104
+ android:layout_marginBottom="12dp"
105
+ android:orientation="vertical"
106
+ android:paddingStart="16dp"
107
+ android:paddingEnd="16dp"
108
+ android:visibility="gone">
109
+
110
+ <RadioGroup
111
+ android:id="@+id/local_action_radio_group"
112
+ android:layout_width="match_parent"
113
+ android:layout_height="wrap_content"
114
+ android:orientation="vertical">
115
+
116
+ <RadioButton
117
+ android:id="@+id/radio_local_create"
118
+ android:layout_width="match_parent"
119
+ android:layout_height="wrap_content"
120
+ android:checked="true"
121
+ android:text="@string/signer_picker_local_create" />
122
+
123
+ <RadioButton
124
+ android:id="@+id/radio_local_import"
125
+ android:layout_width="match_parent"
126
+ android:layout_height="wrap_content"
127
+ android:text="@string/signer_picker_local_import" />
128
+ </RadioGroup>
129
+
130
+ <com.google.android.material.textfield.TextInputLayout
131
+ android:id="@+id/local_nsec_container"
132
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
133
+ android:layout_width="match_parent"
134
+ android:layout_height="wrap_content"
135
+ android:layout_marginTop="8dp"
136
+ android:hint="@string/signer_picker_local_nsec_hint"
137
+ android:visibility="gone"
138
+ app:endIconMode="password_toggle">
139
+
140
+ <com.google.android.material.textfield.TextInputEditText
141
+ android:id="@+id/local_nsec"
142
+ android:layout_width="match_parent"
143
+ android:layout_height="wrap_content"
144
+ android:importantForAutofill="no"
145
+ android:inputType="textPassword|textNoSuggestions"
146
+ android:maxLines="1"
147
+ android:saveEnabled="false" />
148
+ </com.google.android.material.textfield.TextInputLayout>
149
+ </LinearLayout>
150
+
151
+ <TextView
152
+ android:id="@+id/local_account_existing"
153
+ android:layout_width="match_parent"
154
+ android:layout_height="wrap_content"
155
+ android:layout_marginBottom="12dp"
156
+ android:paddingStart="16dp"
157
+ android:paddingEnd="16dp"
158
+ android:text="@string/signer_picker_local_existing"
159
+ android:textColor="#4B5563"
160
+ android:visibility="gone" />
161
+
100
162
  <com.google.android.material.textfield.TextInputLayout
101
163
  android:id="@+id/bunker_uri_container"
102
164
  style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
@@ -32,6 +32,8 @@
32
32
  <string name="developer_tools_api_base_save_failed">Unable to persist the API Base URL setting.</string>
33
33
  <string name="developer_tools_api_base_invalid">Enter an absolute HTTPS URL without credentials, query, or fragment.</string>
34
34
  <string name="developer_tools_webview_debug">WebView Debug</string>
35
+ <string name="developer_tools_chrome_webview_inspection">Chrome WebView Inspection</string>
36
+ <string name="developer_tools_chrome_webview_inspection_note">To inspect this app in Chrome DevTools, enable Developer options → USB debugging on your Android device. This is required even when connecting over Wi-Fi.</string>
35
37
  <string name="developer_tools_enabled">Enabled</string>
36
38
  <string name="developer_tools_disabled">Disabled</string>
37
39
 
@@ -66,11 +68,22 @@
66
68
 
67
69
  <string name="signer_settings">Signer settings</string>
68
70
  <string name="signer_picker_title">Choose a signer</string>
69
- <string name="signer_picker_subtitle">Use Amber, connect to a remote signer, or create an app-local account.</string>
71
+ <string name="signer_picker_subtitle">Use Amber, connect to a remote signer, or use an app-local account.</string>
70
72
  <string name="signer_picker_amber">Continue with Amber</string>
71
73
  <string name="signer_picker_bunker">Connect to remote signer (Bunker)</string>
72
- <string name="signer_picker_local">Create app-local account</string>
74
+ <string name="signer_picker_local">App-local account</string>
75
+ <string name="signer_picker_local_create">Create new account</string>
76
+ <string name="signer_picker_local_import">Import existing account</string>
77
+ <string name="signer_picker_local_nsec_hint">Enter your nsec</string>
78
+ <string name="signer_picker_local_existing">Use the existing app-local account on this device.</string>
79
+ <string name="signer_picker_local_invalid">Enter a valid lowercase nsec.</string>
80
+ <string name="signer_picker_local_confirm_title">Confirm account import</string>
81
+ <string name="signer_picker_local_confirm_message">Import this account?\n\n%1$s</string>
82
+ <string name="signer_picker_local_confirm">Import</string>
83
+ <string name="signer_picker_local_cancel">Cancel</string>
73
84
  <string name="signer_picker_local_failed">Unable to create the app-local account.</string>
85
+ <string name="signer_picker_local_status_failed">Unable to check the app-local account.</string>
86
+ <string name="signer_picker_local_import_failed">Unable to import the app-local account.</string>
74
87
  <string name="signer_picker_bunker_hint">Paste a bunker:// connection</string>
75
88
  <string name="signer_picker_share_pairing">Share a nostrconnect:// pairing</string>
76
89
  <string name="signer_picker_logout_bunker">Disconnect remote signer</string>
@@ -0,0 +1,178 @@
1
+ package com.pakstr.app.crypto
2
+
3
+ import com.pakstr.app.signer.local.LocalAccountImportCandidate
4
+ import org.junit.Assert.assertArrayEquals
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertThrows
8
+ import org.junit.Test
9
+
10
+ class Nip19CodecTest {
11
+
12
+ @Test
13
+ fun decodes_known_nsec_vector() {
14
+ val decoded = Nip19Codec.decodeNsec(NSEC_ONE)
15
+
16
+ assertArrayEquals(
17
+ ByteArray(32).also { it[31] = 1 },
18
+ decoded
19
+ )
20
+ decoded.fill(0)
21
+ }
22
+
23
+ @Test
24
+ fun encodes_known_npub_vector() {
25
+ val publicKey = Bip340Hash.hexToBytes(PUBLIC_KEY_ONE)
26
+
27
+ try {
28
+ assertEquals(NPUB_ONE, Nip19Codec.encodeNpub(publicKey))
29
+ } finally {
30
+ publicKey.fill(0)
31
+ }
32
+ }
33
+
34
+ @Test
35
+ fun import_candidate_derives_expected_npub_and_closes_key() {
36
+ val candidate = LocalAccountImportCandidate.parse(NSEC_ONE)
37
+
38
+ assertEquals(NPUB_ONE, candidate.npub)
39
+ assertFalse(candidate.toString().contains(NSEC_ONE))
40
+ candidate.close()
41
+ assertThrows(IllegalStateException::class.java) {
42
+ candidate.withPrivateKey { }
43
+ }
44
+ }
45
+
46
+ @Test
47
+ fun rejects_noncanonical_or_corrupt_nsec() {
48
+ val invalid = listOf(
49
+ NSEC_ONE.uppercase(),
50
+ "Nsec" + NSEC_ONE.substring(4),
51
+ "xsec" + NSEC_ONE.substring(4),
52
+ NSEC_ONE.dropLast(1) + "q",
53
+ NSEC_ONE.replaceRange(8, 9, "i"),
54
+ NSEC_ONE.dropLast(1),
55
+ NSEC_ONE + "q"
56
+ )
57
+
58
+ invalid.forEach { value ->
59
+ assertThrows(InvalidNsecException::class.java) {
60
+ Nip19Codec.decodeNsec(value)
61
+ }
62
+ }
63
+ }
64
+
65
+ @Test
66
+ fun rejects_invalid_private_scalars() {
67
+ val zero = encodeNsecForTest(ByteArray(32))
68
+ val order = Bip340Hash.hexToBytes(SECP256K1_ORDER)
69
+ val aboveOrder = order.copyOf().also {
70
+ it[it.lastIndex] = (it.last().toInt() + 1).toByte()
71
+ }
72
+
73
+ try {
74
+ assertThrows(IllegalArgumentException::class.java) {
75
+ LocalAccountImportCandidate.parse(zero)
76
+ }
77
+ assertThrows(IllegalArgumentException::class.java) {
78
+ LocalAccountImportCandidate.parse(
79
+ encodeNsecForTest(order)
80
+ )
81
+ }
82
+ assertThrows(IllegalArgumentException::class.java) {
83
+ LocalAccountImportCandidate.parse(
84
+ encodeNsecForTest(aboveOrder)
85
+ )
86
+ }
87
+ } finally {
88
+ order.fill(0)
89
+ aboveOrder.fill(0)
90
+ }
91
+ }
92
+
93
+ @Test
94
+ fun rejects_wrong_payload_lengths_and_accepts_leading_zero() {
95
+ assertThrows(InvalidNsecException::class.java) {
96
+ Nip19Codec.decodeNsec(encodeNsecForTest(ByteArray(31)))
97
+ }
98
+ assertThrows(InvalidNsecException::class.java) {
99
+ Nip19Codec.decodeNsec(encodeNsecForTest(ByteArray(33)))
100
+ }
101
+
102
+ LocalAccountImportCandidate.parse(NSEC_ONE).use { candidate ->
103
+ assertEquals(NPUB_ONE, candidate.npub)
104
+ }
105
+ }
106
+
107
+ private fun encodeNsecForTest(bytes: ByteArray): String {
108
+ val data = convertBits(bytes)
109
+ val values = hrpExpand("nsec") + data + IntArray(6)
110
+ val checksum = polymod(values) xor 1
111
+ val check = IntArray(6) { index ->
112
+ (checksum shr (5 * (5 - index))) and 31
113
+ }
114
+ return "nsec1" + (data + check).joinToString("") {
115
+ CHARSET[it].toString()
116
+ }
117
+ }
118
+
119
+ private fun convertBits(bytes: ByteArray): IntArray {
120
+ var accumulator = 0
121
+ var bits = 0
122
+ val output = ArrayList<Int>()
123
+ bytes.forEach { byte ->
124
+ accumulator = (accumulator shl 8) or (byte.toInt() and 0xff)
125
+ bits += 8
126
+ while (bits >= 5) {
127
+ bits -= 5
128
+ output += (accumulator shr bits) and 31
129
+ }
130
+ }
131
+ if (bits > 0) {
132
+ output += (accumulator shl (5 - bits)) and 31
133
+ }
134
+ return output.toIntArray()
135
+ }
136
+
137
+ private fun hrpExpand(hrp: String): IntArray =
138
+ IntArray(hrp.length * 2 + 1).also { result ->
139
+ hrp.forEachIndexed { index, character ->
140
+ result[index] = character.code shr 5
141
+ result[hrp.length + 1 + index] = character.code and 31
142
+ }
143
+ }
144
+
145
+ private fun polymod(values: IntArray): Int {
146
+ var checksum = 1
147
+ val generators = intArrayOf(
148
+ 0x3b6a57b2,
149
+ 0x26508e6d,
150
+ 0x1ea119fa,
151
+ 0x3d4233dd,
152
+ 0x2a1462b3
153
+ )
154
+ values.forEach { value ->
155
+ val top = checksum ushr 25
156
+ checksum = (checksum and 0x1ffffff) shl 5 xor value
157
+ generators.forEachIndexed { index, generator ->
158
+ if ((top shr index) and 1 != 0) {
159
+ checksum = checksum xor generator
160
+ }
161
+ }
162
+ }
163
+ return checksum
164
+ }
165
+
166
+ private companion object {
167
+ const val CHARSET =
168
+ "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
169
+ const val NSEC_ONE =
170
+ "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"
171
+ const val PUBLIC_KEY_ONE =
172
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
173
+ const val NPUB_ONE =
174
+ "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
175
+ const val SECP256K1_ORDER =
176
+ "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"
177
+ }
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pakstr",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -54,9 +54,9 @@
54
54
  "applesauce-core": "^6.2.0",
55
55
  "applesauce-signers": "^6.2.2",
56
56
  "fs-extra": "^11.3.6",
57
- "js-yaml": "^5.3.0",
57
+ "js-yaml": "^5.4.1",
58
58
  "node-fetch": "^3.3.2",
59
- "sharp": "^0.35.3",
59
+ "sharp": "^0.35.5",
60
60
  "unzipper": "^0.12.5"
61
61
  },
62
62
  "repository": {