pakstr 0.26.0 → 0.28.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 +201 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/local/LocalAccountStoreTest.kt +194 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/utils/SignerPickerBottomSheetTest.kt +131 -0
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +87 -11
- 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 +110 -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/LocalAccountStore.kt +245 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +19 -9
- package/android-template/app/src/main/res/layout/dialog_picker.xml +18 -0
- package/android-template/app/src/main/res/values/strings.xml +3 -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/signer/LocalSignerTest.kt +110 -0
- package/dist/commands/init.js +39 -3
- package/package.json +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
package com.pakstr.app.signer.local
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec
|
|
5
|
+
import android.security.keystore.KeyProperties
|
|
6
|
+
import android.util.Base64
|
|
7
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
8
|
+
import java.security.KeyStore
|
|
9
|
+
import javax.crypto.Cipher
|
|
10
|
+
import javax.crypto.KeyGenerator
|
|
11
|
+
import javax.crypto.SecretKey
|
|
12
|
+
import javax.crypto.spec.GCMParameterSpec
|
|
13
|
+
|
|
14
|
+
internal class LocalAccountStorageException(
|
|
15
|
+
message: String,
|
|
16
|
+
cause: Throwable? = null
|
|
17
|
+
) : Exception(message, cause)
|
|
18
|
+
|
|
19
|
+
internal class LocalAccountStore(
|
|
20
|
+
context: Context,
|
|
21
|
+
private val preferencesName: String = PREFERENCES_NAME,
|
|
22
|
+
private val keyAlias: String = KEY_ALIAS
|
|
23
|
+
) {
|
|
24
|
+
private val context = context.applicationContext
|
|
25
|
+
private val preferences =
|
|
26
|
+
this.context.getSharedPreferences(
|
|
27
|
+
preferencesName,
|
|
28
|
+
Context.MODE_PRIVATE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
fun createAccount(): String =
|
|
32
|
+
synchronized(STORAGE_LOCK) {
|
|
33
|
+
if (hasAccountLocked()) {
|
|
34
|
+
return@synchronized requireNotNull(
|
|
35
|
+
loadKeyLocked()
|
|
36
|
+
).use { key ->
|
|
37
|
+
key.publicKeyHex
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
val key = Nip46ClientKey.generate()
|
|
42
|
+
try {
|
|
43
|
+
key.withPrivateKey(::saveNewKey)
|
|
44
|
+
key.publicKeyHex
|
|
45
|
+
} finally {
|
|
46
|
+
key.close()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fun hasAccount(): Boolean =
|
|
51
|
+
synchronized(STORAGE_LOCK) {
|
|
52
|
+
hasAccountLocked()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun loadKey(): Nip46ClientKey? =
|
|
56
|
+
synchronized(STORAGE_LOCK) {
|
|
57
|
+
loadKeyLocked()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun hasAccountLocked(): Boolean {
|
|
61
|
+
val hasIv = preferences.contains(KEY_IV)
|
|
62
|
+
val hasCiphertext = preferences.contains(KEY_CIPHERTEXT)
|
|
63
|
+
if (hasIv != hasCiphertext) {
|
|
64
|
+
throw LocalAccountStorageException(
|
|
65
|
+
"Local account storage is incomplete"
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
return hasIv
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun loadKeyLocked(): Nip46ClientKey? {
|
|
72
|
+
if (!hasAccountLocked()) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
val encodedIv = preferences.getString(KEY_IV, null)
|
|
77
|
+
?: throw LocalAccountStorageException(
|
|
78
|
+
"Local account storage is incomplete"
|
|
79
|
+
)
|
|
80
|
+
val encodedCiphertext =
|
|
81
|
+
preferences.getString(KEY_CIPHERTEXT, null)
|
|
82
|
+
?: throw LocalAccountStorageException(
|
|
83
|
+
"Local account storage is incomplete"
|
|
84
|
+
)
|
|
85
|
+
val iv = decode(encodedIv)
|
|
86
|
+
val ciphertext = decode(encodedCiphertext)
|
|
87
|
+
var privateKey: ByteArray? = null
|
|
88
|
+
|
|
89
|
+
return try {
|
|
90
|
+
val wrappingKey = loadWrappingKey()
|
|
91
|
+
privateKey = decrypt(wrappingKey, iv, ciphertext)
|
|
92
|
+
Nip46ClientKey.fromPrivateKey(privateKey)
|
|
93
|
+
} catch (error: LocalAccountStorageException) {
|
|
94
|
+
throw error
|
|
95
|
+
} catch (error: Exception) {
|
|
96
|
+
throw LocalAccountStorageException(
|
|
97
|
+
"Unable to restore local account",
|
|
98
|
+
error
|
|
99
|
+
)
|
|
100
|
+
} finally {
|
|
101
|
+
iv.fill(0)
|
|
102
|
+
ciphertext.fill(0)
|
|
103
|
+
privateKey?.fill(0)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun saveNewKey(privateKey: ByteArray) {
|
|
108
|
+
check(!hasAccountLocked()) {
|
|
109
|
+
"Local account already exists"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
val wrappingKey = getOrCreateWrappingKey()
|
|
113
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
114
|
+
cipher.init(Cipher.ENCRYPT_MODE, wrappingKey)
|
|
115
|
+
cipher.updateAAD(AUTHENTICATED_DATA)
|
|
116
|
+
val ciphertext = cipher.doFinal(privateKey)
|
|
117
|
+
val iv = cipher.iv
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
val saved =
|
|
121
|
+
preferences.edit()
|
|
122
|
+
.putString(
|
|
123
|
+
KEY_IV,
|
|
124
|
+
Base64.encodeToString(iv, Base64.NO_WRAP)
|
|
125
|
+
)
|
|
126
|
+
.putString(
|
|
127
|
+
KEY_CIPHERTEXT,
|
|
128
|
+
Base64.encodeToString(
|
|
129
|
+
ciphertext,
|
|
130
|
+
Base64.NO_WRAP
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
.commit()
|
|
134
|
+
if (!saved) {
|
|
135
|
+
throw LocalAccountStorageException(
|
|
136
|
+
"Unable to store local account"
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
iv.fill(0)
|
|
141
|
+
ciphertext.fill(0)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun decrypt(
|
|
146
|
+
wrappingKey: SecretKey,
|
|
147
|
+
iv: ByteArray,
|
|
148
|
+
ciphertext: ByteArray
|
|
149
|
+
): ByteArray {
|
|
150
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
151
|
+
cipher.init(
|
|
152
|
+
Cipher.DECRYPT_MODE,
|
|
153
|
+
wrappingKey,
|
|
154
|
+
GCMParameterSpec(GCM_TAG_SIZE_BITS, iv)
|
|
155
|
+
)
|
|
156
|
+
cipher.updateAAD(AUTHENTICATED_DATA)
|
|
157
|
+
return cipher.doFinal(ciphertext)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun getOrCreateWrappingKey(): SecretKey {
|
|
161
|
+
val keyStore = loadKeyStore()
|
|
162
|
+
val existing = keyStore.getKey(keyAlias, null)
|
|
163
|
+
if (existing != null) {
|
|
164
|
+
return existing as? SecretKey
|
|
165
|
+
?: throw LocalAccountStorageException(
|
|
166
|
+
"Invalid local account wrapping key"
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return try {
|
|
171
|
+
val generator =
|
|
172
|
+
KeyGenerator.getInstance(
|
|
173
|
+
KeyProperties.KEY_ALGORITHM_AES,
|
|
174
|
+
ANDROID_KEY_STORE
|
|
175
|
+
)
|
|
176
|
+
generator.init(
|
|
177
|
+
KeyGenParameterSpec.Builder(
|
|
178
|
+
keyAlias,
|
|
179
|
+
KeyProperties.PURPOSE_ENCRYPT or
|
|
180
|
+
KeyProperties.PURPOSE_DECRYPT
|
|
181
|
+
)
|
|
182
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
183
|
+
.setEncryptionPaddings(
|
|
184
|
+
KeyProperties.ENCRYPTION_PADDING_NONE
|
|
185
|
+
)
|
|
186
|
+
.setKeySize(WRAPPING_KEY_SIZE_BITS)
|
|
187
|
+
.build()
|
|
188
|
+
)
|
|
189
|
+
generator.generateKey()
|
|
190
|
+
} catch (error: Exception) {
|
|
191
|
+
throw LocalAccountStorageException(
|
|
192
|
+
"Unable to create local account wrapping key",
|
|
193
|
+
error
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private fun loadWrappingKey(): SecretKey {
|
|
199
|
+
val key = loadKeyStore().getKey(keyAlias, null)
|
|
200
|
+
?: throw LocalAccountStorageException(
|
|
201
|
+
"Local account wrapping key is unavailable"
|
|
202
|
+
)
|
|
203
|
+
return key as? SecretKey
|
|
204
|
+
?: throw LocalAccountStorageException(
|
|
205
|
+
"Invalid local account wrapping key"
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private fun loadKeyStore(): KeyStore {
|
|
210
|
+
return try {
|
|
211
|
+
KeyStore.getInstance(ANDROID_KEY_STORE)
|
|
212
|
+
.apply { load(null) }
|
|
213
|
+
} catch (error: Exception) {
|
|
214
|
+
throw LocalAccountStorageException(
|
|
215
|
+
"Unable to access local account storage",
|
|
216
|
+
error
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private fun decode(value: String): ByteArray {
|
|
222
|
+
return try {
|
|
223
|
+
Base64.decode(value, Base64.NO_WRAP)
|
|
224
|
+
} catch (error: IllegalArgumentException) {
|
|
225
|
+
throw LocalAccountStorageException(
|
|
226
|
+
"Local account storage is invalid",
|
|
227
|
+
error
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
internal companion object {
|
|
233
|
+
const val PREFERENCES_NAME = "local_signer_account"
|
|
234
|
+
const val KEY_ALIAS = "pakstr_local_signer_wrapping_key_v1"
|
|
235
|
+
const val KEY_IV = "iv"
|
|
236
|
+
const val KEY_CIPHERTEXT = "ciphertext"
|
|
237
|
+
private val STORAGE_LOCK = Any()
|
|
238
|
+
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
|
239
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
240
|
+
private const val GCM_TAG_SIZE_BITS = 128
|
|
241
|
+
private const val WRAPPING_KEY_SIZE_BITS = 256
|
|
242
|
+
private val AUTHENTICATED_DATA =
|
|
243
|
+
"pakstr/local-account/v1".toByteArray(Charsets.UTF_8)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
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
|
|
@@ -15,8 +16,10 @@ class SignerPickerBottomSheet(
|
|
|
15
16
|
private val hasBunkerSession: Boolean,
|
|
16
17
|
private val onAmberSelected: () -> Unit,
|
|
17
18
|
private val onBunkerSelected: (String) -> Boolean,
|
|
19
|
+
private val onLocalSelected: () -> Unit,
|
|
18
20
|
private val onSharePairing: (List<String>) -> Unit,
|
|
19
|
-
private val onBunkerLogout: () -> Unit
|
|
21
|
+
private val onBunkerLogout: () -> Unit,
|
|
22
|
+
private val onDismissed: () -> Unit = {}
|
|
20
23
|
) : BottomSheetDialogFragment() {
|
|
21
24
|
|
|
22
25
|
override fun onCreateView(
|
|
@@ -61,15 +64,17 @@ class SignerPickerBottomSheet(
|
|
|
61
64
|
updateBunkerControls()
|
|
62
65
|
|
|
63
66
|
confirm.setOnClickListener {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
when (radioGroup.checkedRadioButtonId) {
|
|
68
|
+
R.id.radio_option_two -> {
|
|
69
|
+
val value = uriInput.text?.toString().orEmpty().trim()
|
|
70
|
+
if (!onBunkerSelected(value)) {
|
|
71
|
+
uriContainer.error =
|
|
72
|
+
getString(R.string.signer_picker_invalid_bunker)
|
|
73
|
+
return@setOnClickListener
|
|
74
|
+
}
|
|
70
75
|
}
|
|
71
|
-
|
|
72
|
-
onAmberSelected()
|
|
76
|
+
R.id.radio_option_three -> onLocalSelected()
|
|
77
|
+
else -> onAmberSelected()
|
|
73
78
|
}
|
|
74
79
|
dismiss()
|
|
75
80
|
}
|
|
@@ -98,4 +103,9 @@ class SignerPickerBottomSheet(
|
|
|
98
103
|
dismiss()
|
|
99
104
|
}
|
|
100
105
|
}
|
|
106
|
+
|
|
107
|
+
override fun onDismiss(dialog: DialogInterface) {
|
|
108
|
+
super.onDismiss(dialog)
|
|
109
|
+
onDismissed()
|
|
110
|
+
}
|
|
101
111
|
}
|
|
@@ -77,6 +77,24 @@
|
|
|
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
|
|
|
82
100
|
<com.google.android.material.textfield.TextInputLayout
|
|
@@ -66,9 +66,11 @@
|
|
|
66
66
|
|
|
67
67
|
<string name="signer_settings">Signer settings</string>
|
|
68
68
|
<string name="signer_picker_title">Choose a signer</string>
|
|
69
|
-
<string name="signer_picker_subtitle">Use Amber
|
|
69
|
+
<string name="signer_picker_subtitle">Use Amber, connect to a remote signer, or create an app-local account.</string>
|
|
70
70
|
<string name="signer_picker_amber">Continue with Amber</string>
|
|
71
71
|
<string name="signer_picker_bunker">Connect to remote signer (Bunker)</string>
|
|
72
|
+
<string name="signer_picker_local">Create app-local account</string>
|
|
73
|
+
<string name="signer_picker_local_failed">Unable to create the app-local account.</string>
|
|
72
74
|
<string name="signer_picker_bunker_hint">Paste a bunker:// connection</string>
|
|
73
75
|
<string name="signer_picker_share_pairing">Share a nostrconnect:// pairing</string>
|
|
74
76
|
<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,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/dist/commands/init.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.initCommand = initCommand;
|
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const crypto_1 = require("crypto");
|
|
10
|
+
const promises_1 = require("readline/promises");
|
|
10
11
|
const pakstrConfig_1 = require("../core/pakstrConfig");
|
|
11
12
|
const nostr_1 = require("../core/nostr");
|
|
12
13
|
/**
|
|
@@ -29,8 +30,9 @@ async function initCommand(options = {}) {
|
|
|
29
30
|
throw new Error(`${configPath} already exists. Use --force to overwrite, or remove it first.`);
|
|
30
31
|
}
|
|
31
32
|
const pkg = readPackageJson();
|
|
32
|
-
const { appId, sanitized: appIdSanitized } = inferAppId(pkg);
|
|
33
|
-
const
|
|
33
|
+
const { appId: inferredAppId, sanitized: appIdSanitized } = inferAppId(pkg);
|
|
34
|
+
const { appId, overridden: appIdOverridden } = await confirmAppId(inferredAppId, options);
|
|
35
|
+
const appName = inferAppName(pkg) ?? appNameFromAppId(inferredAppId);
|
|
34
36
|
const versionName = (pkg && typeof pkg.version === "string" ? pkg.version : "1.0.0");
|
|
35
37
|
const versionCode = 1;
|
|
36
38
|
const web = inferWebDir();
|
|
@@ -46,7 +48,7 @@ async function initCommand(options = {}) {
|
|
|
46
48
|
});
|
|
47
49
|
fs_1.default.writeFileSync(configPath, yaml, "utf8");
|
|
48
50
|
console.log(`✅ Wrote ${configPath}`);
|
|
49
|
-
if (appIdSanitized) {
|
|
51
|
+
if (appIdSanitized && !appIdOverridden) {
|
|
50
52
|
console.log(`⚠️ Your package.json name contains characters that aren't valid in an Android package`);
|
|
51
53
|
console.log(` name (e.g. hyphens). pakstr sanitized it to appId "${appId}". Edit`);
|
|
52
54
|
console.log(` ${pakstrConfig_1.PAKSTR_CONFIG_FILENAME} if you'd prefer a different value.`);
|
|
@@ -235,6 +237,40 @@ function escapeYamlScalar(value) {
|
|
|
235
237
|
}
|
|
236
238
|
return value;
|
|
237
239
|
}
|
|
240
|
+
async function confirmAppId(inferredAppId, options) {
|
|
241
|
+
const prompt = options.promptAppId ?? (isInteractive() ? promptForAppId : null);
|
|
242
|
+
if (!prompt)
|
|
243
|
+
return { appId: inferredAppId, overridden: false };
|
|
244
|
+
const response = await prompt(inferredAppId);
|
|
245
|
+
if (response === "")
|
|
246
|
+
return { appId: inferredAppId, overridden: false };
|
|
247
|
+
validateAppId(response);
|
|
248
|
+
return { appId: response, overridden: response !== inferredAppId };
|
|
249
|
+
}
|
|
250
|
+
function isInteractive() {
|
|
251
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
252
|
+
}
|
|
253
|
+
async function promptForAppId(defaultAppId) {
|
|
254
|
+
const readline = (0, promises_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
255
|
+
try {
|
|
256
|
+
return await readline.question(`App ID [${defaultAppId}]: `);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
readline.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function validateAppId(appId) {
|
|
263
|
+
if (appId !== appId.trim()) {
|
|
264
|
+
throw new Error("App ID must not have leading or trailing whitespace");
|
|
265
|
+
}
|
|
266
|
+
if (Buffer.byteLength(appId, "utf8") > 255) {
|
|
267
|
+
throw new Error("App ID must be ≤ 255 UTF-8 bytes");
|
|
268
|
+
}
|
|
269
|
+
if (!pakstrConfig_1.APP_ID_PATTERN.test(appId)) {
|
|
270
|
+
throw new Error("App ID must be a valid Android package name: dot-separated segments of" +
|
|
271
|
+
` lower-case letters, digits, and underscores (no hyphens). Got: "${appId}"`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
238
274
|
function readPackageJson() {
|
|
239
275
|
const p = path_1.default.join(process.cwd(), "package.json");
|
|
240
276
|
if (!fs_1.default.existsSync(p))
|