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
@@ -11,11 +11,18 @@ import com.pakstr.app.crypto.Nip46ClientKey
11
11
  import com.pakstr.app.signer.bunker.BunkerConfig
12
12
  import com.pakstr.app.signer.bunker.BunkerConnectionStore
13
13
  import com.pakstr.app.signer.bunker.Nip46Client
14
+ import com.pakstr.app.signer.exceptions.SignerUnavailableException
14
15
  import com.pakstr.app.signer.interfaces.NostrSigner
16
+ import com.pakstr.app.signer.local.LocalAccountStore
17
+ import kotlinx.coroutines.CoroutineStart
18
+ import kotlinx.coroutines.async
15
19
  import kotlinx.coroutines.runBlocking
20
+ import java.security.KeyStore
21
+ import org.junit.After
16
22
  import org.junit.Assert.assertEquals
17
23
  import org.junit.Assert.assertFalse
18
24
  import org.junit.Assert.assertNull
25
+ import org.junit.Assert.assertThrows
19
26
  import org.junit.Assert.assertTrue
20
27
  import org.junit.Assert.fail
21
28
  import org.junit.Test
@@ -24,6 +31,146 @@ import org.junit.runner.RunWith
24
31
  @RunWith(AndroidJUnit4::class)
25
32
  class SignerManagerTest {
26
33
 
34
+ private val context
35
+ get() = ApplicationProvider.getApplicationContext<Context>()
36
+
37
+ @After
38
+ fun clearSignerPreferences() {
39
+ context.getSharedPreferences("signer", Context.MODE_PRIVATE)
40
+ .edit()
41
+ .clear()
42
+ .commit()
43
+ }
44
+
45
+ @Test
46
+ fun fresh_manager_defaults_to_amber_but_is_not_configured() {
47
+ clearSignerPreferences()
48
+ val manager = SignerManager(context, NoOpLauncher())
49
+
50
+ assertEquals(SignerType.AMBER, manager.getActiveType())
51
+ assertFalse(manager.isSignerConfigured())
52
+ }
53
+
54
+ @Test
55
+ fun explicit_selection_persists_configuration_across_recreation() =
56
+ runBlocking {
57
+ clearSignerPreferences()
58
+ var pickerRequests = 0
59
+ val first = SignerManager(context, NoOpLauncher())
60
+ first.setActiveType(SignerType.FAKE)
61
+ val restored =
62
+ SignerManager(
63
+ context,
64
+ NoOpLauncher(),
65
+ signerPickerRequestHandler = { pickerRequests++ }
66
+ )
67
+
68
+ assertTrue(restored.isSignerConfigured())
69
+ assertEquals(SignerType.FAKE, restored.getActiveType())
70
+ assertTrue(restored.getConfiguredSigner() is FakeSigner)
71
+ assertEquals(0, pickerRequests)
72
+ }
73
+
74
+ @Test
75
+ fun local_account_recreation_reuses_existing_identity() = runBlocking {
76
+ clearLocalAccount(context)
77
+ val first = SignerManager(context, NoOpLauncher())
78
+ try {
79
+ first.createLocalAccount()
80
+ val firstPublicKey = first.getCurrentUserPubkey()
81
+ val restored = SignerManager(context, NoOpLauncher())
82
+ try {
83
+ assertTrue(restored.isSignerConfigured())
84
+ assertEquals(SignerType.LOCAL, restored.getActiveType())
85
+ assertEquals(firstPublicKey, restored.getCurrentUserPubkey())
86
+ } finally {
87
+ restored.close()
88
+ }
89
+ } finally {
90
+ first.close()
91
+ clearLocalAccount(context)
92
+ }
93
+ }
94
+
95
+ @Test
96
+ fun legacy_completed_setup_migrates_configuration() {
97
+ clearSignerPreferences()
98
+ val manager = SignerManager(context, NoOpLauncher())
99
+
100
+ manager.migrateLegacySignerConfiguration(setupCompleted = true)
101
+
102
+ assertTrue(manager.isSignerConfigured())
103
+ assertEquals(SignerType.AMBER, manager.getActiveType())
104
+ }
105
+
106
+ @Test
107
+ fun configured_signer_waits_for_one_picker_and_resumes_all_requests() =
108
+ runBlocking {
109
+ clearSignerPreferences()
110
+ var pickerRequests = 0
111
+ val manager =
112
+ SignerManager(
113
+ context,
114
+ NoOpLauncher(),
115
+ signerPickerRequestHandler = { pickerRequests++ }
116
+ )
117
+ val first =
118
+ async(start = CoroutineStart.UNDISPATCHED) {
119
+ manager.getConfiguredSigner()
120
+ }
121
+ val second =
122
+ async(start = CoroutineStart.UNDISPATCHED) {
123
+ manager.getConfiguredSigner()
124
+ }
125
+
126
+ assertEquals(1, pickerRequests)
127
+ assertFalse(first.isCompleted)
128
+ assertFalse(second.isCompleted)
129
+
130
+ manager.setActiveType(SignerType.FAKE)
131
+
132
+ assertTrue(first.await() is FakeSigner)
133
+ assertTrue(second.await() is FakeSigner)
134
+ }
135
+
136
+ @Test
137
+ fun dismissed_picker_returns_signer_unavailable() = runBlocking {
138
+ clearSignerPreferences()
139
+ val manager =
140
+ SignerManager(
141
+ context,
142
+ NoOpLauncher(),
143
+ signerPickerRequestHandler = {}
144
+ )
145
+ val request =
146
+ async(start = CoroutineStart.UNDISPATCHED) {
147
+ runCatching { manager.getConfiguredSigner() }.exceptionOrNull()
148
+ }
149
+
150
+ manager.cancelSignerSelection()
151
+
152
+ assertTrue(request.await() is SignerUnavailableException)
153
+ }
154
+
155
+ @Test
156
+ fun close_returns_signer_unavailable_to_pending_request() = runBlocking {
157
+ clearSignerPreferences()
158
+ val manager =
159
+ SignerManager(
160
+ context,
161
+ NoOpLauncher(),
162
+ signerPickerRequestHandler = {}
163
+ )
164
+ val request =
165
+ async(start = CoroutineStart.UNDISPATCHED) {
166
+ runCatching { manager.getConfiguredSigner() }.exceptionOrNull()
167
+ }
168
+
169
+ manager.close()
170
+
171
+ assertTrue(request.await() is SignerUnavailableException)
172
+ }
173
+
27
174
  @Test
28
175
  fun signer_change_clears_cached_user_pubkey() = runBlocking {
29
176
  val context =
@@ -102,6 +249,105 @@ class SignerManagerTest {
102
249
  }
103
250
  }
104
251
 
252
+ @Test
253
+ fun local_account_survives_manager_recreation() = runBlocking {
254
+ val context =
255
+ ApplicationProvider.getApplicationContext<Context>()
256
+ clearLocalAccount(context)
257
+ val first = SignerManager(context, NoOpLauncher())
258
+ val restored = SignerManager(context, NoOpLauncher())
259
+ try {
260
+ first.createLocalAccount()
261
+ val firstPublicKey = first.getCurrentUserPubkey()
262
+
263
+ assertEquals(SignerType.LOCAL, first.getActiveType())
264
+ assertEquals(SignerType.LOCAL, restored.getActiveType())
265
+ assertEquals(firstPublicKey, restored.getCurrentUserPubkey())
266
+ } finally {
267
+ first.close()
268
+ restored.close()
269
+ clearLocalAccount(context)
270
+ }
271
+ }
272
+
273
+ @Test
274
+ fun repeated_local_account_creation_reuses_identity() = runBlocking {
275
+ val context =
276
+ ApplicationProvider.getApplicationContext<Context>()
277
+ clearLocalAccount(context)
278
+ val manager = SignerManager(context, NoOpLauncher())
279
+ try {
280
+ manager.createLocalAccount()
281
+ val firstPublicKey = manager.getCurrentUserPubkey()
282
+
283
+ manager.createLocalAccount()
284
+
285
+ assertEquals(firstPublicKey, manager.getCurrentUserPubkey())
286
+ assertEquals(SignerType.LOCAL, manager.getActiveType())
287
+ } finally {
288
+ manager.close()
289
+ clearLocalAccount(context)
290
+ }
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
+
105
351
  @Test
106
352
  fun switching_from_bunker_disconnects_and_closes_client() {
107
353
  val context =
@@ -149,6 +395,20 @@ class SignerManagerTest {
149
395
  }
150
396
  }
151
397
 
398
+ private fun clearLocalAccount(context: Context) {
399
+ context.getSharedPreferences(
400
+ LocalAccountStore.PREFERENCES_NAME,
401
+ Context.MODE_PRIVATE
402
+ ).edit().clear().commit()
403
+ KeyStore.getInstance("AndroidKeyStore")
404
+ .apply { load(null) }
405
+ .deleteEntry(LocalAccountStore.KEY_ALIAS)
406
+ context.getSharedPreferences("signer", Context.MODE_PRIVATE)
407
+ .edit()
408
+ .clear()
409
+ .commit()
410
+ }
411
+
152
412
  private fun getPendingPairing(manager: SignerManager): Any? {
153
413
  return SignerManager::class.java
154
414
  .getDeclaredField("pendingPairing")
@@ -257,6 +517,12 @@ class SignerManagerTest {
257
517
  const val VALID_PUBKEY =
258
518
  "cb227f60cb2a6d982ecbfc51b9199d340afc00b1ef81b67d8e3778e097e70d2c"
259
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"
260
526
  }
261
527
 
262
528
  private class NoOpLauncher : ActivityResultLauncher<Intent>() {
@@ -0,0 +1,272 @@
1
+ package com.pakstr.app.signer.local
2
+
3
+ import android.content.Context
4
+ import android.util.Base64
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import androidx.test.ext.junit.runners.AndroidJUnit4
7
+ import org.junit.After
8
+ import org.junit.Assert.assertEquals
9
+ import org.junit.Assert.assertFalse
10
+ import org.junit.Assert.assertNotEquals
11
+ import org.junit.Assert.assertThrows
12
+ import org.junit.Assert.assertNull
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Before
15
+ import org.junit.Test
16
+ import org.junit.runner.RunWith
17
+ import java.security.KeyStore
18
+
19
+ @RunWith(AndroidJUnit4::class)
20
+ class LocalAccountStoreTest {
21
+ private lateinit var context: Context
22
+ private lateinit var store: LocalAccountStore
23
+
24
+ @Before
25
+ fun setup() {
26
+ context = ApplicationProvider.getApplicationContext()
27
+ clearState()
28
+ store =
29
+ LocalAccountStore(
30
+ context,
31
+ PREFERENCES_NAME,
32
+ KEY_ALIAS
33
+ )
34
+ }
35
+
36
+ @After
37
+ fun teardown() {
38
+ clearState()
39
+ }
40
+
41
+ @Test
42
+ fun account_survives_store_recreation() {
43
+ val createdPublicKey = store.createAccount()
44
+ val restored =
45
+ LocalAccountStore(
46
+ context,
47
+ PREFERENCES_NAME,
48
+ KEY_ALIAS
49
+ ).loadKey()
50
+
51
+ requireNotNull(restored).use { key ->
52
+ assertEquals(createdPublicKey, key.publicKeyHex)
53
+ }
54
+ }
55
+
56
+ @Test
57
+ fun repeated_creation_reuses_existing_account() {
58
+ val first = store.createAccount()
59
+ val second = store.createAccount()
60
+
61
+ assertEquals(first, second)
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
+
139
+ @Test
140
+ fun persisted_value_is_not_plaintext_private_key() {
141
+ store.createAccount()
142
+ val privateKey =
143
+ requireNotNull(store.loadKey()).use { key ->
144
+ key.withPrivateKey(ByteArray::copyOf)
145
+ }
146
+ val preferences = preferences()
147
+ val ciphertext =
148
+ requireNotNull(
149
+ preferences.getString(
150
+ LocalAccountStore.KEY_CIPHERTEXT,
151
+ null
152
+ )
153
+ )
154
+
155
+ try {
156
+ assertNotEquals(
157
+ Base64.encodeToString(privateKey, Base64.NO_WRAP),
158
+ ciphertext
159
+ )
160
+ assertFalse(
161
+ preferences.all.values.any { value ->
162
+ value == Base64.encodeToString(
163
+ privateKey,
164
+ Base64.NO_WRAP
165
+ )
166
+ }
167
+ )
168
+ } finally {
169
+ privateKey.fill(0)
170
+ }
171
+ }
172
+
173
+ @Test
174
+ fun incomplete_storage_fails_without_being_cleared() {
175
+ preferences().edit()
176
+ .putString(LocalAccountStore.KEY_IV, "AA==")
177
+ .commit()
178
+
179
+ assertThrows(LocalAccountStorageException::class.java) {
180
+ store.createAccount()
181
+ }
182
+ assertTrue(preferences().contains(LocalAccountStore.KEY_IV))
183
+ assertFalse(
184
+ preferences().contains(
185
+ LocalAccountStore.KEY_CIPHERTEXT
186
+ )
187
+ )
188
+ }
189
+
190
+ @Test
191
+ fun tampered_ciphertext_fails_without_replacement() {
192
+ store.createAccount()
193
+ val preferences = preferences()
194
+ val original =
195
+ requireNotNull(
196
+ preferences.getString(
197
+ LocalAccountStore.KEY_CIPHERTEXT,
198
+ null
199
+ )
200
+ )
201
+ val tampered = Base64.decode(original, Base64.NO_WRAP)
202
+ tampered[0] = (tampered[0].toInt() xor 1).toByte()
203
+ val encodedTampered =
204
+ Base64.encodeToString(tampered, Base64.NO_WRAP)
205
+ tampered.fill(0)
206
+ preferences.edit()
207
+ .putString(
208
+ LocalAccountStore.KEY_CIPHERTEXT,
209
+ encodedTampered
210
+ )
211
+ .commit()
212
+
213
+ assertThrows(LocalAccountStorageException::class.java) {
214
+ store.createAccount()
215
+ }
216
+ assertEquals(
217
+ encodedTampered,
218
+ preferences.getString(
219
+ LocalAccountStore.KEY_CIPHERTEXT,
220
+ null
221
+ )
222
+ )
223
+ }
224
+
225
+ @Test
226
+ fun missing_wrapping_key_does_not_generate_replacement_account() {
227
+ store.createAccount()
228
+ val ciphertext =
229
+ preferences().getString(
230
+ LocalAccountStore.KEY_CIPHERTEXT,
231
+ null
232
+ )
233
+ deleteKey()
234
+
235
+ assertThrows(LocalAccountStorageException::class.java) {
236
+ store.createAccount()
237
+ }
238
+ assertEquals(
239
+ ciphertext,
240
+ preferences().getString(
241
+ LocalAccountStore.KEY_CIPHERTEXT,
242
+ null
243
+ )
244
+ )
245
+ }
246
+
247
+ private fun preferences() =
248
+ context.getSharedPreferences(
249
+ PREFERENCES_NAME,
250
+ Context.MODE_PRIVATE
251
+ )
252
+
253
+ private fun clearState() {
254
+ if (::context.isInitialized) {
255
+ preferences().edit().clear().commit()
256
+ deleteKey()
257
+ }
258
+ }
259
+
260
+ private fun deleteKey() {
261
+ KeyStore.getInstance("AndroidKeyStore")
262
+ .apply { load(null) }
263
+ .deleteEntry(KEY_ALIAS)
264
+ }
265
+
266
+ private companion object {
267
+ const val PREFERENCES_NAME = "local_signer_account_test"
268
+ const val KEY_ALIAS = "pakstr_local_signer_wrapping_key_test"
269
+ const val PUBLIC_KEY_ONE =
270
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
271
+ }
272
+ }