pakstr 0.27.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.
@@ -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
- 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
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
- } else {
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 or connect to a NIP-46 remote signer.</string>
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
- <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,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.28.0",
4
4
  "description": "CLI for packaging Nostr web apps into Android APKs",
5
5
  "type": "commonjs",
6
6
  "main": "dist/index.js",