pakstr 0.27.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 +266 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +272 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +165 -0
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +211 -11
- 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/LocalSigner.kt +100 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +6 -6
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +135 -3
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerType.kt +1 -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 +260 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +98 -12
- 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 +80 -0
- package/android-template/app/src/main/res/values/strings.xml +16 -1
- package/android-template/app/src/main/res/xml/backup_rules.xml +4 -11
- package/android-template/app/src/main/res/xml/data_extraction_rules.xml +7 -13
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/LocalSignerTest.kt +110 -0
- package/package.json +3 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
package com.pakstr.app.utils
|
|
2
2
|
|
|
3
|
+
import android.content.DialogInterface
|
|
3
4
|
import android.os.Bundle
|
|
4
5
|
import android.view.LayoutInflater
|
|
5
6
|
import android.view.View
|
|
6
7
|
import android.view.ViewGroup
|
|
8
|
+
import android.view.WindowManager
|
|
7
9
|
import android.widget.Button
|
|
8
10
|
import android.widget.RadioGroup
|
|
9
11
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
|
@@ -13,10 +15,14 @@ import com.pakstr.app.R
|
|
|
13
15
|
|
|
14
16
|
class SignerPickerBottomSheet(
|
|
15
17
|
private val hasBunkerSession: Boolean,
|
|
18
|
+
private val hasLocalAccount: Boolean,
|
|
16
19
|
private val onAmberSelected: () -> Unit,
|
|
17
20
|
private val onBunkerSelected: (String) -> Boolean,
|
|
21
|
+
private val onLocalSelected: () -> Unit,
|
|
22
|
+
private val onLocalImportSelected: (String) -> Boolean,
|
|
18
23
|
private val onSharePairing: (List<String>) -> Unit,
|
|
19
|
-
private val onBunkerLogout: () -> Unit
|
|
24
|
+
private val onBunkerLogout: () -> Unit,
|
|
25
|
+
private val onDismissed: () -> Unit = {}
|
|
20
26
|
) : BottomSheetDialogFragment() {
|
|
21
27
|
|
|
22
28
|
override fun onCreateView(
|
|
@@ -31,6 +37,11 @@ class SignerPickerBottomSheet(
|
|
|
31
37
|
)
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
override fun onStart() {
|
|
41
|
+
super.onStart()
|
|
42
|
+
dialog?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
override fun onViewCreated(
|
|
35
46
|
view: View,
|
|
36
47
|
savedInstanceState: Bundle?
|
|
@@ -39,6 +50,11 @@ class SignerPickerBottomSheet(
|
|
|
39
50
|
val radioGroup = view.findViewById<RadioGroup>(R.id.options_radio_group)
|
|
40
51
|
val uriContainer = view.findViewById<TextInputLayout>(R.id.bunker_uri_container)
|
|
41
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)
|
|
42
58
|
val confirm = view.findViewById<Button>(R.id.button_confirm)
|
|
43
59
|
val sharePairing = view.findViewById<Button>(R.id.button_share_pairing)
|
|
44
60
|
val logout = view.findViewById<Button>(R.id.button_logout_bunker)
|
|
@@ -46,30 +62,88 @@ class SignerPickerBottomSheet(
|
|
|
46
62
|
logout.visibility =
|
|
47
63
|
if (hasBunkerSession) View.VISIBLE else View.GONE
|
|
48
64
|
|
|
49
|
-
fun
|
|
65
|
+
fun updateControls() {
|
|
50
66
|
val bunkerSelected =
|
|
51
67
|
radioGroup.checkedRadioButtonId == R.id.radio_option_two
|
|
68
|
+
val localSelected =
|
|
69
|
+
radioGroup.checkedRadioButtonId == R.id.radio_option_three
|
|
52
70
|
uriContainer.visibility =
|
|
53
71
|
if (bunkerSelected) View.VISIBLE else View.GONE
|
|
54
72
|
sharePairing.visibility =
|
|
55
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
|
+
}
|
|
56
97
|
}
|
|
57
98
|
radioGroup.setOnCheckedChangeListener { _, _ ->
|
|
58
99
|
uriContainer.error = null
|
|
59
|
-
|
|
100
|
+
nsecContainer.error = null
|
|
101
|
+
if (radioGroup.checkedRadioButtonId != R.id.radio_option_three) {
|
|
102
|
+
nsecInput.text?.clear()
|
|
103
|
+
}
|
|
104
|
+
updateControls()
|
|
105
|
+
}
|
|
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()
|
|
60
115
|
}
|
|
61
|
-
|
|
116
|
+
updateControls()
|
|
62
117
|
|
|
63
118
|
confirm.setOnClickListener {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
119
|
+
when (radioGroup.checkedRadioButtonId) {
|
|
120
|
+
R.id.radio_option_two -> {
|
|
121
|
+
val value = uriInput.text?.toString().orEmpty().trim()
|
|
122
|
+
if (!onBunkerSelected(value)) {
|
|
123
|
+
uriContainer.error =
|
|
124
|
+
getString(R.string.signer_picker_invalid_bunker)
|
|
125
|
+
return@setOnClickListener
|
|
126
|
+
}
|
|
127
|
+
}
|
|
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
|
+
}
|
|
70
145
|
}
|
|
71
|
-
|
|
72
|
-
onAmberSelected()
|
|
146
|
+
else -> onAmberSelected()
|
|
73
147
|
}
|
|
74
148
|
dismiss()
|
|
75
149
|
}
|
|
@@ -98,4 +172,16 @@ class SignerPickerBottomSheet(
|
|
|
98
172
|
dismiss()
|
|
99
173
|
}
|
|
100
174
|
}
|
|
175
|
+
|
|
176
|
+
override fun onDestroyView() {
|
|
177
|
+
view?.findViewById<TextInputEditText>(R.id.local_nsec)
|
|
178
|
+
?.text
|
|
179
|
+
?.clear()
|
|
180
|
+
super.onDestroyView()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
override fun onDismiss(dialog: DialogInterface) {
|
|
184
|
+
super.onDismiss(dialog)
|
|
185
|
+
onDismissed()
|
|
186
|
+
}
|
|
101
187
|
}
|
|
@@ -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
|
|
|
@@ -77,8 +77,88 @@
|
|
|
77
77
|
android:text="@string/signer_picker_bunker"
|
|
78
78
|
android:textColor="#1F2937"
|
|
79
79
|
android:textSize="16sp" />
|
|
80
|
+
|
|
81
|
+
<RadioButton
|
|
82
|
+
android:id="@+id/radio_option_three"
|
|
83
|
+
android:layout_width="match_parent"
|
|
84
|
+
android:layout_height="64dp"
|
|
85
|
+
android:layout_marginBottom="12dp"
|
|
86
|
+
android:background="@drawable/selector_radio_card"
|
|
87
|
+
android:button="@null"
|
|
88
|
+
android:drawableStart="@drawable/ic_menu_browser"
|
|
89
|
+
android:drawableEnd="@drawable/selector_radio_checkmark"
|
|
90
|
+
android:drawablePadding="16dp"
|
|
91
|
+
android:drawableTint="@drawable/selector_radio_tint"
|
|
92
|
+
android:fontFamily="sans-serif-medium"
|
|
93
|
+
android:paddingStart="20dp"
|
|
94
|
+
android:paddingEnd="20dp"
|
|
95
|
+
android:text="@string/signer_picker_local"
|
|
96
|
+
android:textColor="#1F2937"
|
|
97
|
+
android:textSize="16sp" />
|
|
80
98
|
</RadioGroup>
|
|
81
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
|
+
|
|
82
162
|
<com.google.android.material.textfield.TextInputLayout
|
|
83
163
|
android:id="@+id/bunker_uri_container"
|
|
84
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,9 +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
|
|
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>
|
|
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>
|
|
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>
|
|
72
87
|
<string name="signer_picker_bunker_hint">Paste a bunker:// connection</string>
|
|
73
88
|
<string name="signer_picker_share_pairing">Share a nostrconnect:// pairing</string>
|
|
74
89
|
<string name="signer_picker_logout_bunker">Disconnect remote signer</string>
|
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"
|
|
2
|
-
Sample backup rules file; uncomment and customize as necessary.
|
|
3
|
-
See https://developer.android.com/guide/topics/data/autobackup
|
|
4
|
-
for details.
|
|
5
|
-
Note: This file is ignored for devices older than API 31
|
|
6
|
-
See https://developer.android.com/about/versions/12/backup-restore
|
|
7
|
-
-->
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
8
2
|
<full-backup-content>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
-->
|
|
3
|
+
<exclude
|
|
4
|
+
domain="sharedpref"
|
|
5
|
+
path="local_signer_account.xml" />
|
|
13
6
|
</full-backup-content>
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="utf-8"
|
|
2
|
-
Sample data extraction rules file; uncomment and customize as necessary.
|
|
3
|
-
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
|
4
|
-
for details.
|
|
5
|
-
-->
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
6
2
|
<data-extraction-rules>
|
|
7
3
|
<cloud-backup>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-->
|
|
4
|
+
<exclude
|
|
5
|
+
domain="sharedpref"
|
|
6
|
+
path="local_signer_account.xml" />
|
|
12
7
|
</cloud-backup>
|
|
13
|
-
<!--
|
|
14
8
|
<device-transfer>
|
|
15
|
-
<
|
|
16
|
-
|
|
9
|
+
<exclude
|
|
10
|
+
domain="sharedpref"
|
|
11
|
+
path="local_signer_account.xml" />
|
|
17
12
|
</device-transfer>
|
|
18
|
-
-->
|
|
19
13
|
</data-extraction-rules>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
package com.pakstr.app.signer
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.crypto.Bip340Hash
|
|
4
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
5
|
+
import com.pakstr.app.crypto.NostrEventVerifier
|
|
6
|
+
import kotlinx.coroutines.runBlocking
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import org.junit.Assert.assertEquals
|
|
10
|
+
import org.junit.Assert.assertFalse
|
|
11
|
+
import org.junit.Assert.assertThrows
|
|
12
|
+
import org.junit.Assert.assertTrue
|
|
13
|
+
import org.junit.Test
|
|
14
|
+
|
|
15
|
+
class LocalSignerTest {
|
|
16
|
+
@Test
|
|
17
|
+
fun returns_same_public_key_after_key_reload() = runBlocking {
|
|
18
|
+
val privateKey = privateKey(3)
|
|
19
|
+
val expected =
|
|
20
|
+
Nip46ClientKey.fromPrivateKey(privateKey).use { key ->
|
|
21
|
+
key.publicKeyHex
|
|
22
|
+
}
|
|
23
|
+
val signer = signer(privateKey)
|
|
24
|
+
|
|
25
|
+
assertEquals(expected, signer.getPublicKey())
|
|
26
|
+
assertEquals(expected, signer.getPublicKey())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun signs_event_with_valid_bip340_signature() = runBlocking {
|
|
31
|
+
val privateKey = privateKey(7)
|
|
32
|
+
val signer = signer(privateKey)
|
|
33
|
+
val signed = JSONObject(signer.signEvent(unsignedEvent().toString()))
|
|
34
|
+
|
|
35
|
+
assertEquals(signer.getPublicKey(), signed.getString("pubkey"))
|
|
36
|
+
assertEquals(64, signed.getString("id").length)
|
|
37
|
+
assertEquals(128, signed.getString("sig").length)
|
|
38
|
+
assertTrue(NostrEventVerifier.verify(signed))
|
|
39
|
+
assertFalse(signed.toString().contains(Bip340Hash.bytesToHex(privateKey)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun rejects_event_for_different_public_key() {
|
|
44
|
+
val signer = signer(privateKey(9))
|
|
45
|
+
val event =
|
|
46
|
+
unsignedEvent()
|
|
47
|
+
.put("pubkey", "a".repeat(64))
|
|
48
|
+
|
|
49
|
+
assertThrows(IllegalArgumentException::class.java) {
|
|
50
|
+
runBlocking {
|
|
51
|
+
signer.signEvent(event.toString())
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test
|
|
57
|
+
fun nip44_round_trip_uses_local_key() = runBlocking {
|
|
58
|
+
val alice = signer(privateKey(11))
|
|
59
|
+
val bob = signer(privateKey(12))
|
|
60
|
+
val ciphertext =
|
|
61
|
+
alice.nip44Encrypt(
|
|
62
|
+
bob.getPublicKey(),
|
|
63
|
+
"local secret"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
assertEquals(
|
|
67
|
+
"local secret",
|
|
68
|
+
bob.nip44Decrypt(
|
|
69
|
+
alice.getPublicKey(),
|
|
70
|
+
ciphertext
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun nip04_is_explicitly_unsupported() {
|
|
77
|
+
val signer = signer(privateKey(13))
|
|
78
|
+
|
|
79
|
+
assertThrows(UnsupportedOperationException::class.java) {
|
|
80
|
+
runBlocking {
|
|
81
|
+
signer.nip04Encrypt("a".repeat(64), "plaintext")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
assertThrows(UnsupportedOperationException::class.java) {
|
|
85
|
+
runBlocking {
|
|
86
|
+
signer.nip04Decrypt("a".repeat(64), "ciphertext")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun signer(privateKey: ByteArray): LocalSigner {
|
|
92
|
+
return LocalSigner {
|
|
93
|
+
Nip46ClientKey.fromPrivateKey(privateKey)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private fun unsignedEvent(): JSONObject {
|
|
98
|
+
return JSONObject()
|
|
99
|
+
.put("created_at", 1_700_000_000)
|
|
100
|
+
.put("kind", 1)
|
|
101
|
+
.put("tags", JSONArray())
|
|
102
|
+
.put("content", "hello from local signer")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun privateKey(value: Int): ByteArray {
|
|
106
|
+
return ByteArray(32).also {
|
|
107
|
+
it[31] = value.toByte()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
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": {
|