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.
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +65 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +78 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +40 -6
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +124 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip19Codec.kt +181 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +25 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountImportCandidate.kt +42 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountStore.kt +17 -2
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +80 -4
- package/android-template/app/src/main/res/layout/activity_developer_tools.xml +15 -0
- package/android-template/app/src/main/res/layout/dialog_picker.xml +62 -0
- package/android-template/app/src/main/res/values/strings.xml +15 -2
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
- package/package.json +3 -3
package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
100
|
+
nsecContainer.error = null
|
|
101
|
+
if (radioGroup.checkedRadioButtonId != R.id.radio_option_three) {
|
|
102
|
+
nsecInput.text?.clear()
|
|
103
|
+
}
|
|
104
|
+
updateControls()
|
|
63
105
|
}
|
|
64
|
-
|
|
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 ->
|
|
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
|
|
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">
|
|
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.
|
|
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.
|
|
57
|
+
"js-yaml": "^5.4.1",
|
|
58
58
|
"node-fetch": "^3.3.2",
|
|
59
|
-
"sharp": "^0.35.
|
|
59
|
+
"sharp": "^0.35.5",
|
|
60
60
|
"unzipper": "^0.12.5"
|
|
61
61
|
},
|
|
62
62
|
"repository": {
|