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
package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt
CHANGED
|
@@ -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
|
+
}
|