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.
Files changed (20) hide show
  1. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +266 -0
  2. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +272 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +165 -0
  4. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +211 -11
  5. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip19Codec.kt +181 -0
  6. package/android-template/app/src/main/java/com/pakstr/app/signer/LocalSigner.kt +100 -0
  7. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +6 -6
  8. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +135 -3
  9. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerType.kt +1 -0
  10. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountImportCandidate.kt +42 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/signer/local/LocalAccountStore.kt +260 -0
  12. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +98 -12
  13. package/android-template/app/src/main/res/layout/activity_developer_tools.xml +15 -0
  14. package/android-template/app/src/main/res/layout/dialog_picker.xml +80 -0
  15. package/android-template/app/src/main/res/values/strings.xml +16 -1
  16. package/android-template/app/src/main/res/xml/backup_rules.xml +4 -11
  17. package/android-template/app/src/main/res/xml/data_extraction_rules.xml +7 -13
  18. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip19CodecTest.kt +178 -0
  19. package/android-template/app/src/test/java/com/pakstr/app/signer/LocalSignerTest.kt +110 -0
  20. 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 updateBunkerControls() {
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
- updateBunkerControls()
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
- updateBunkerControls()
116
+ updateControls()
62
117
 
63
118
  confirm.setOnClickListener {
64
- if (radioGroup.checkedRadioButtonId == R.id.radio_option_two) {
65
- val value = uriInput.text?.toString().orEmpty().trim()
66
- if (!onBunkerSelected(value)) {
67
- uriContainer.error =
68
- getString(R.string.signer_picker_invalid_bunker)
69
- return@setOnClickListener
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
- } else {
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 or connect to a NIP-46 remote signer.</string>
71
+ <string name="signer_picker_subtitle">Use Amber, connect to a remote signer, or use an app-local account.</string>
70
72
  <string name="signer_picker_amber">Continue with Amber</string>
71
73
  <string name="signer_picker_bunker">Connect to remote signer (Bunker)</string>
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
- <include domain="sharedpref" path="."/>
11
- <exclude domain="sharedpref" path="device.xml"/>
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
- <!-- TODO: Use <include> and <exclude> to control what is backed up.
9
- <include .../>
10
- <exclude .../>
11
- -->
4
+ <exclude
5
+ domain="sharedpref"
6
+ path="local_signer_account.xml" />
12
7
  </cloud-backup>
13
- <!--
14
8
  <device-transfer>
15
- <include .../>
16
- <exclude .../>
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.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",
@@ -54,9 +54,9 @@
54
54
  "applesauce-core": "^6.2.0",
55
55
  "applesauce-signers": "^6.2.2",
56
56
  "fs-extra": "^11.3.6",
57
- "js-yaml": "^5.3.0",
57
+ "js-yaml": "^5.4.1",
58
58
  "node-fetch": "^3.3.2",
59
- "sharp": "^0.35.3",
59
+ "sharp": "^0.35.5",
60
60
  "unzipper": "^0.12.5"
61
61
  },
62
62
  "repository": {