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.
- package/README.md +10 -0
- 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/dist/cli.js +13 -6
- package/dist/commands/artifactPublisher.js +125 -0
- package/dist/commands/build.js +16 -30
- package/dist/commands/publish.js +37 -3
- package/dist/commands/run.js +60 -29
- package/dist/core/apkSignature.js +197 -0
- package/dist/core/nsite.js +49 -0
- package/dist/runners/DockerGradleRunner.js +45 -24
- package/dist/runners/DockerSignRunner.js +58 -19
- package/dist/runners/LocalGradleRunner.js +7 -0
- package/dist/runners/process.js +51 -0
- package/dist/runners/timeouts.js +66 -0
- 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
|
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()
|