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
|
@@ -0,0 +1,100 @@
|
|
|
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 com.pakstr.app.signer.interfaces.NostrSigner
|
|
7
|
+
import com.pakstr.app.signer.local.LocalAccountStore
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
internal class LocalSigner(
|
|
11
|
+
private val keyLoader: () -> Nip46ClientKey
|
|
12
|
+
) : NostrSigner {
|
|
13
|
+
constructor(store: LocalAccountStore) : this(
|
|
14
|
+
keyLoader = {
|
|
15
|
+
store.loadKey()
|
|
16
|
+
?: throw IllegalStateException(
|
|
17
|
+
"Local account has not been created"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
override suspend fun getPublicKey(): String {
|
|
22
|
+
return requireKey().use { key ->
|
|
23
|
+
key.publicKeyHex
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override suspend fun signEvent(eventJson: String): String {
|
|
28
|
+
return requireKey().use { key ->
|
|
29
|
+
val event = JSONObject(eventJson)
|
|
30
|
+
val publicKey = key.publicKeyHex
|
|
31
|
+
val suppliedPublicKey = event.optString("pubkey")
|
|
32
|
+
require(
|
|
33
|
+
suppliedPublicKey.isBlank() ||
|
|
34
|
+
suppliedPublicKey == publicKey
|
|
35
|
+
) {
|
|
36
|
+
"Event public key does not match local signer"
|
|
37
|
+
}
|
|
38
|
+
event.put("pubkey", publicKey)
|
|
39
|
+
NostrEventBuilder.addId(event)
|
|
40
|
+
|
|
41
|
+
val eventId =
|
|
42
|
+
Bip340Hash.hexToBytes(
|
|
43
|
+
event.getString("id")
|
|
44
|
+
)
|
|
45
|
+
val signature = key.sign(eventId)
|
|
46
|
+
try {
|
|
47
|
+
event.put(
|
|
48
|
+
"sig",
|
|
49
|
+
Bip340Hash.bytesToHex(signature)
|
|
50
|
+
)
|
|
51
|
+
} finally {
|
|
52
|
+
eventId.fill(0)
|
|
53
|
+
signature.fill(0)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
check(NostrEventVerifier.verify(event)) {
|
|
57
|
+
"Generated signature failed verification"
|
|
58
|
+
}
|
|
59
|
+
event.toString()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override suspend fun nip04Encrypt(
|
|
64
|
+
pubkey: String,
|
|
65
|
+
plaintext: String
|
|
66
|
+
): String {
|
|
67
|
+
throw UnsupportedOperationException(
|
|
68
|
+
"NIP-04 is not supported by the local signer"
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override suspend fun nip04Decrypt(
|
|
73
|
+
pubkey: String,
|
|
74
|
+
ciphertext: String
|
|
75
|
+
): String {
|
|
76
|
+
throw UnsupportedOperationException(
|
|
77
|
+
"NIP-04 is not supported by the local signer"
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override suspend fun nip44Encrypt(
|
|
82
|
+
pubkey: String,
|
|
83
|
+
plaintext: String
|
|
84
|
+
): String {
|
|
85
|
+
return requireKey().use { key ->
|
|
86
|
+
key.encryptNip44(plaintext, pubkey)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override suspend fun nip44Decrypt(
|
|
91
|
+
pubkey: String,
|
|
92
|
+
ciphertext: String
|
|
93
|
+
): String {
|
|
94
|
+
return requireKey().use { key ->
|
|
95
|
+
key.decryptNip44(ciphertext, pubkey)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private fun requireKey() = keyLoader()
|
|
100
|
+
}
|
|
@@ -48,7 +48,7 @@ class NostrBridge(
|
|
|
48
48
|
try {
|
|
49
49
|
val pk =
|
|
50
50
|
signerManager
|
|
51
|
-
.
|
|
51
|
+
.getConfiguredSigner()
|
|
52
52
|
.getPublicKey()
|
|
53
53
|
resolve(
|
|
54
54
|
callbackId,
|
|
@@ -107,7 +107,7 @@ class NostrBridge(
|
|
|
107
107
|
|
|
108
108
|
val result =
|
|
109
109
|
signerManager
|
|
110
|
-
.
|
|
110
|
+
.getConfiguredSigner()
|
|
111
111
|
.signEvent(
|
|
112
112
|
payload
|
|
113
113
|
)
|
|
@@ -180,7 +180,7 @@ class NostrBridge(
|
|
|
180
180
|
|
|
181
181
|
val result =
|
|
182
182
|
signerManager
|
|
183
|
-
.
|
|
183
|
+
.getConfiguredSigner()
|
|
184
184
|
.nip04Encrypt(
|
|
185
185
|
pubkey,
|
|
186
186
|
plaintext
|
|
@@ -254,7 +254,7 @@ class NostrBridge(
|
|
|
254
254
|
|
|
255
255
|
val result =
|
|
256
256
|
signerManager
|
|
257
|
-
.
|
|
257
|
+
.getConfiguredSigner()
|
|
258
258
|
.nip04Decrypt(
|
|
259
259
|
pubkey,
|
|
260
260
|
ciphertext
|
|
@@ -318,7 +318,7 @@ class NostrBridge(
|
|
|
318
318
|
|
|
319
319
|
val result =
|
|
320
320
|
signerManager
|
|
321
|
-
.
|
|
321
|
+
.getConfiguredSigner()
|
|
322
322
|
.nip44Encrypt(
|
|
323
323
|
json.getString("pubkey"),
|
|
324
324
|
json.getString("plaintext")
|
|
@@ -373,7 +373,7 @@ class NostrBridge(
|
|
|
373
373
|
|
|
374
374
|
val result =
|
|
375
375
|
signerManager
|
|
376
|
-
.
|
|
376
|
+
.getConfiguredSigner()
|
|
377
377
|
.nip44Decrypt(
|
|
378
378
|
json.getString("pubkey"),
|
|
379
379
|
json.getString("ciphertext")
|
|
@@ -14,12 +14,17 @@ import com.pakstr.app.signer.bunker.Nip46PairingUri
|
|
|
14
14
|
import com.pakstr.app.signer.bunker.RelayNip46Client
|
|
15
15
|
import com.pakstr.app.signer.exceptions.SignerUnavailableException
|
|
16
16
|
import com.pakstr.app.signer.interfaces.NostrSigner
|
|
17
|
+
import com.pakstr.app.signer.local.LocalAccountAlreadyExistsException
|
|
18
|
+
import com.pakstr.app.signer.local.LocalAccountImportCandidate
|
|
19
|
+
import com.pakstr.app.signer.local.LocalAccountStore
|
|
20
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
17
21
|
import org.json.JSONObject
|
|
18
22
|
|
|
19
23
|
class SignerManager(
|
|
20
24
|
context: Context,
|
|
21
25
|
private val amberLauncher: ActivityResultLauncher<Intent>,
|
|
22
|
-
private val bunkerAuthChallengeHandler: ((String) -> Unit)? = null
|
|
26
|
+
private val bunkerAuthChallengeHandler: ((String) -> Unit)? = null,
|
|
27
|
+
private val signerPickerRequestHandler: (() -> Unit)? = null
|
|
23
28
|
) {
|
|
24
29
|
private val context = context.applicationContext
|
|
25
30
|
private val prefs =
|
|
@@ -30,10 +35,14 @@ class SignerManager(
|
|
|
30
35
|
private val bunkerStore by lazy {
|
|
31
36
|
BunkerConnectionStore(this.context)
|
|
32
37
|
}
|
|
38
|
+
private val localAccountStore by lazy {
|
|
39
|
+
LocalAccountStore(this.context)
|
|
40
|
+
}
|
|
33
41
|
|
|
34
42
|
private var activeSigner: NostrSigner? = null
|
|
35
43
|
private var currentUserPubkey: String? = null
|
|
36
44
|
private var pendingPairing: PendingPairing? = null
|
|
45
|
+
private var pendingSignerSelection: CompletableDeferred<Boolean>? = null
|
|
37
46
|
|
|
38
47
|
init {
|
|
39
48
|
AppDebugLogger.log(
|
|
@@ -44,7 +53,7 @@ class SignerManager(
|
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
fun getActiveType(): SignerType? {
|
|
47
|
-
val value = prefs.getString(
|
|
56
|
+
val value = prefs.getString(KEY_SIGNER_TYPE, SignerType.AMBER.name)
|
|
48
57
|
return try {
|
|
49
58
|
SignerType.valueOf(value.toString())
|
|
50
59
|
} catch (_: IllegalArgumentException) {
|
|
@@ -52,6 +61,20 @@ class SignerManager(
|
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
fun isSignerConfigured(): Boolean =
|
|
65
|
+
prefs.getBoolean(KEY_SIGNER_CONFIGURED, false)
|
|
66
|
+
|
|
67
|
+
fun migrateLegacySignerConfiguration(setupCompleted: Boolean) {
|
|
68
|
+
if (prefs.contains(KEY_SIGNER_CONFIGURED)) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (setupCompleted || prefs.contains(KEY_SIGNER_TYPE)) {
|
|
72
|
+
prefs.edit {
|
|
73
|
+
putBoolean(KEY_SIGNER_CONFIGURED, true)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
fun hasBunkerSession(): Boolean {
|
|
56
79
|
return try {
|
|
57
80
|
val connection = bunkerStore.restore() ?: return false
|
|
@@ -169,6 +192,35 @@ class SignerManager(
|
|
|
169
192
|
setActiveTypeWithoutClosing(type)
|
|
170
193
|
}
|
|
171
194
|
|
|
195
|
+
fun createLocalAccount() {
|
|
196
|
+
localAccountStore.createAccount()
|
|
197
|
+
closeActiveSigner()
|
|
198
|
+
setActiveTypeWithoutClosing(SignerType.LOCAL)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fun hasLocalAccount(): Boolean = localAccountStore.hasAccount()
|
|
202
|
+
|
|
203
|
+
internal fun prepareLocalAccountImport(
|
|
204
|
+
nsec: String
|
|
205
|
+
): LocalAccountImportCandidate {
|
|
206
|
+
if (localAccountStore.hasAccount()) {
|
|
207
|
+
throw LocalAccountAlreadyExistsException()
|
|
208
|
+
}
|
|
209
|
+
return LocalAccountImportCandidate.parse(nsec)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
internal fun importLocalAccount(
|
|
213
|
+
candidate: LocalAccountImportCandidate
|
|
214
|
+
) {
|
|
215
|
+
try {
|
|
216
|
+
candidate.withPrivateKey(localAccountStore::importAccount)
|
|
217
|
+
closeActiveSigner()
|
|
218
|
+
setActiveTypeWithoutClosing(SignerType.LOCAL)
|
|
219
|
+
} finally {
|
|
220
|
+
candidate.close()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
172
224
|
fun logoutBunker() {
|
|
173
225
|
try {
|
|
174
226
|
(activeSigner as? BunkerSigner)?.disconnect()
|
|
@@ -182,6 +234,56 @@ class SignerManager(
|
|
|
182
234
|
}
|
|
183
235
|
}
|
|
184
236
|
|
|
237
|
+
suspend fun getConfiguredSigner(): NostrSigner {
|
|
238
|
+
if (isSignerConfigured()) {
|
|
239
|
+
return getSigner()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
var shouldRequestPicker = false
|
|
243
|
+
val selection =
|
|
244
|
+
synchronized(this) {
|
|
245
|
+
if (isSignerConfigured()) {
|
|
246
|
+
null
|
|
247
|
+
} else {
|
|
248
|
+
pendingSignerSelection
|
|
249
|
+
?: CompletableDeferred<Boolean>().also {
|
|
250
|
+
pendingSignerSelection = it
|
|
251
|
+
shouldRequestPicker = true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (selection == null) {
|
|
257
|
+
return getSigner()
|
|
258
|
+
}
|
|
259
|
+
if (shouldRequestPicker) {
|
|
260
|
+
val handler = signerPickerRequestHandler
|
|
261
|
+
if (handler == null) {
|
|
262
|
+
cancelSignerSelection()
|
|
263
|
+
} else {
|
|
264
|
+
try {
|
|
265
|
+
handler()
|
|
266
|
+
} catch (_: Exception) {
|
|
267
|
+
cancelSignerSelection()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!selection.await()) {
|
|
272
|
+
throw SignerUnavailableException("Signer selection was dismissed")
|
|
273
|
+
}
|
|
274
|
+
return getSigner()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fun cancelSignerSelection() {
|
|
278
|
+
val selection =
|
|
279
|
+
synchronized(this) {
|
|
280
|
+
pendingSignerSelection.also {
|
|
281
|
+
pendingSignerSelection = null
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
selection?.complete(false)
|
|
285
|
+
}
|
|
286
|
+
|
|
185
287
|
fun getSigner(): NostrSigner {
|
|
186
288
|
activeSigner?.let { return it }
|
|
187
289
|
val activeType = getActiveType()
|
|
@@ -195,6 +297,7 @@ class SignerManager(
|
|
|
195
297
|
when (activeType) {
|
|
196
298
|
SignerType.AMBER -> createAmberSigner()
|
|
197
299
|
SignerType.BUNKER -> createBunkerSigner()
|
|
300
|
+
SignerType.LOCAL -> createLocalSigner()
|
|
198
301
|
SignerType.FAKE -> FakeSigner()
|
|
199
302
|
null -> throw IllegalStateException("No signer selected")
|
|
200
303
|
}
|
|
@@ -204,6 +307,7 @@ class SignerManager(
|
|
|
204
307
|
|
|
205
308
|
fun close() {
|
|
206
309
|
cancelBunkerPairing()
|
|
310
|
+
cancelSignerSelection()
|
|
207
311
|
closeActiveSigner()
|
|
208
312
|
}
|
|
209
313
|
|
|
@@ -289,10 +393,36 @@ class SignerManager(
|
|
|
289
393
|
return BunkerSigner(context, client)
|
|
290
394
|
}
|
|
291
395
|
|
|
396
|
+
private fun createLocalSigner(): LocalSigner {
|
|
397
|
+
try {
|
|
398
|
+
localAccountStore.loadKey()?.use { key ->
|
|
399
|
+
currentUserPubkey = key.publicKeyHex
|
|
400
|
+
} ?: throw SignerUnavailableException(
|
|
401
|
+
"Local account has not been created"
|
|
402
|
+
)
|
|
403
|
+
} catch (error: SignerUnavailableException) {
|
|
404
|
+
throw error
|
|
405
|
+
} catch (error: Exception) {
|
|
406
|
+
throw IllegalStateException(
|
|
407
|
+
"Unable to restore local account",
|
|
408
|
+
error
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
return LocalSigner(localAccountStore)
|
|
412
|
+
}
|
|
413
|
+
|
|
292
414
|
private fun setActiveTypeWithoutClosing(type: SignerType) {
|
|
293
415
|
prefs.edit {
|
|
294
|
-
putString(
|
|
416
|
+
putString(KEY_SIGNER_TYPE, type.name)
|
|
417
|
+
putBoolean(KEY_SIGNER_CONFIGURED, true)
|
|
295
418
|
}
|
|
419
|
+
val selection =
|
|
420
|
+
synchronized(this) {
|
|
421
|
+
pendingSignerSelection.also {
|
|
422
|
+
pendingSignerSelection = null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
selection?.complete(true)
|
|
296
426
|
}
|
|
297
427
|
|
|
298
428
|
private fun closeActiveSigner() {
|
|
@@ -319,6 +449,8 @@ class SignerManager(
|
|
|
319
449
|
}
|
|
320
450
|
|
|
321
451
|
private companion object {
|
|
452
|
+
const val KEY_SIGNER_TYPE = "type"
|
|
453
|
+
const val KEY_SIGNER_CONFIGURED = "signer_configured"
|
|
322
454
|
const val BUNKER_PERMISSIONS =
|
|
323
455
|
"sign_event,nip04_encrypt,nip04_decrypt," +
|
|
324
456
|
"nip44_encrypt,nip44_decrypt"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package com.pakstr.app.signer.local
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.crypto.Nip19Codec
|
|
4
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
5
|
+
|
|
6
|
+
internal class LocalAccountImportCandidate private constructor(
|
|
7
|
+
private val key: Nip46ClientKey,
|
|
8
|
+
val npub: String
|
|
9
|
+
) : AutoCloseable {
|
|
10
|
+
|
|
11
|
+
internal fun <T> withPrivateKey(
|
|
12
|
+
block: (ByteArray) -> T
|
|
13
|
+
): T = key.withPrivateKey(block)
|
|
14
|
+
|
|
15
|
+
override fun close() {
|
|
16
|
+
key.close()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
fun parse(nsec: String): LocalAccountImportCandidate {
|
|
21
|
+
val privateKey = Nip19Codec.decodeNsec(nsec)
|
|
22
|
+
return try {
|
|
23
|
+
val key = Nip46ClientKey.fromPrivateKey(privateKey)
|
|
24
|
+
try {
|
|
25
|
+
val publicKey = key.publicKey
|
|
26
|
+
val npub =
|
|
27
|
+
try {
|
|
28
|
+
Nip19Codec.encodeNpub(publicKey)
|
|
29
|
+
} finally {
|
|
30
|
+
publicKey.fill(0)
|
|
31
|
+
}
|
|
32
|
+
LocalAccountImportCandidate(key, npub)
|
|
33
|
+
} catch (error: Exception) {
|
|
34
|
+
key.close()
|
|
35
|
+
throw error
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
privateKey.fill(0)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
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 LocalAccountAlreadyExistsException :
|
|
20
|
+
IllegalStateException("Local account already exists")
|
|
21
|
+
|
|
22
|
+
internal class LocalAccountStore(
|
|
23
|
+
context: Context,
|
|
24
|
+
private val preferencesName: String = PREFERENCES_NAME,
|
|
25
|
+
private val keyAlias: String = KEY_ALIAS
|
|
26
|
+
) {
|
|
27
|
+
private val context = context.applicationContext
|
|
28
|
+
private val preferences =
|
|
29
|
+
this.context.getSharedPreferences(
|
|
30
|
+
preferencesName,
|
|
31
|
+
Context.MODE_PRIVATE
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
fun createAccount(): String =
|
|
35
|
+
synchronized(STORAGE_LOCK) {
|
|
36
|
+
if (hasAccountLocked()) {
|
|
37
|
+
return@synchronized requireNotNull(
|
|
38
|
+
loadKeyLocked()
|
|
39
|
+
).use { key ->
|
|
40
|
+
key.publicKeyHex
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
val key = Nip46ClientKey.generate()
|
|
45
|
+
try {
|
|
46
|
+
key.withPrivateKey(::saveNewKey)
|
|
47
|
+
key.publicKeyHex
|
|
48
|
+
} finally {
|
|
49
|
+
key.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fun importAccount(privateKey: ByteArray): String =
|
|
54
|
+
synchronized(STORAGE_LOCK) {
|
|
55
|
+
if (hasAccountLocked()) {
|
|
56
|
+
throw LocalAccountAlreadyExistsException()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Nip46ClientKey.fromPrivateKey(privateKey).use { key ->
|
|
60
|
+
key.withPrivateKey(::saveNewKey)
|
|
61
|
+
key.publicKeyHex
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun hasAccount(): Boolean =
|
|
66
|
+
synchronized(STORAGE_LOCK) {
|
|
67
|
+
hasAccountLocked()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
fun loadKey(): Nip46ClientKey? =
|
|
71
|
+
synchronized(STORAGE_LOCK) {
|
|
72
|
+
loadKeyLocked()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun hasAccountLocked(): Boolean {
|
|
76
|
+
val hasIv = preferences.contains(KEY_IV)
|
|
77
|
+
val hasCiphertext = preferences.contains(KEY_CIPHERTEXT)
|
|
78
|
+
if (hasIv != hasCiphertext) {
|
|
79
|
+
throw LocalAccountStorageException(
|
|
80
|
+
"Local account storage is incomplete"
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return hasIv
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private fun loadKeyLocked(): Nip46ClientKey? {
|
|
87
|
+
if (!hasAccountLocked()) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
val encodedIv = preferences.getString(KEY_IV, null)
|
|
92
|
+
?: throw LocalAccountStorageException(
|
|
93
|
+
"Local account storage is incomplete"
|
|
94
|
+
)
|
|
95
|
+
val encodedCiphertext =
|
|
96
|
+
preferences.getString(KEY_CIPHERTEXT, null)
|
|
97
|
+
?: throw LocalAccountStorageException(
|
|
98
|
+
"Local account storage is incomplete"
|
|
99
|
+
)
|
|
100
|
+
val iv = decode(encodedIv)
|
|
101
|
+
val ciphertext = decode(encodedCiphertext)
|
|
102
|
+
var privateKey: ByteArray? = null
|
|
103
|
+
|
|
104
|
+
return try {
|
|
105
|
+
val wrappingKey = loadWrappingKey()
|
|
106
|
+
privateKey = decrypt(wrappingKey, iv, ciphertext)
|
|
107
|
+
Nip46ClientKey.fromPrivateKey(privateKey)
|
|
108
|
+
} catch (error: LocalAccountStorageException) {
|
|
109
|
+
throw error
|
|
110
|
+
} catch (error: Exception) {
|
|
111
|
+
throw LocalAccountStorageException(
|
|
112
|
+
"Unable to restore local account",
|
|
113
|
+
error
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
iv.fill(0)
|
|
117
|
+
ciphertext.fill(0)
|
|
118
|
+
privateKey?.fill(0)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private fun saveNewKey(privateKey: ByteArray) {
|
|
123
|
+
if (hasAccountLocked()) {
|
|
124
|
+
throw LocalAccountAlreadyExistsException()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
val wrappingKey = getOrCreateWrappingKey()
|
|
128
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
129
|
+
cipher.init(Cipher.ENCRYPT_MODE, wrappingKey)
|
|
130
|
+
cipher.updateAAD(AUTHENTICATED_DATA)
|
|
131
|
+
val ciphertext = cipher.doFinal(privateKey)
|
|
132
|
+
val iv = cipher.iv
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
val saved =
|
|
136
|
+
preferences.edit()
|
|
137
|
+
.putString(
|
|
138
|
+
KEY_IV,
|
|
139
|
+
Base64.encodeToString(iv, Base64.NO_WRAP)
|
|
140
|
+
)
|
|
141
|
+
.putString(
|
|
142
|
+
KEY_CIPHERTEXT,
|
|
143
|
+
Base64.encodeToString(
|
|
144
|
+
ciphertext,
|
|
145
|
+
Base64.NO_WRAP
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
.commit()
|
|
149
|
+
if (!saved) {
|
|
150
|
+
throw LocalAccountStorageException(
|
|
151
|
+
"Unable to store local account"
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
iv.fill(0)
|
|
156
|
+
ciphertext.fill(0)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun decrypt(
|
|
161
|
+
wrappingKey: SecretKey,
|
|
162
|
+
iv: ByteArray,
|
|
163
|
+
ciphertext: ByteArray
|
|
164
|
+
): ByteArray {
|
|
165
|
+
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
166
|
+
cipher.init(
|
|
167
|
+
Cipher.DECRYPT_MODE,
|
|
168
|
+
wrappingKey,
|
|
169
|
+
GCMParameterSpec(GCM_TAG_SIZE_BITS, iv)
|
|
170
|
+
)
|
|
171
|
+
cipher.updateAAD(AUTHENTICATED_DATA)
|
|
172
|
+
return cipher.doFinal(ciphertext)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun getOrCreateWrappingKey(): SecretKey {
|
|
176
|
+
val keyStore = loadKeyStore()
|
|
177
|
+
val existing = keyStore.getKey(keyAlias, null)
|
|
178
|
+
if (existing != null) {
|
|
179
|
+
return existing as? SecretKey
|
|
180
|
+
?: throw LocalAccountStorageException(
|
|
181
|
+
"Invalid local account wrapping key"
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return try {
|
|
186
|
+
val generator =
|
|
187
|
+
KeyGenerator.getInstance(
|
|
188
|
+
KeyProperties.KEY_ALGORITHM_AES,
|
|
189
|
+
ANDROID_KEY_STORE
|
|
190
|
+
)
|
|
191
|
+
generator.init(
|
|
192
|
+
KeyGenParameterSpec.Builder(
|
|
193
|
+
keyAlias,
|
|
194
|
+
KeyProperties.PURPOSE_ENCRYPT or
|
|
195
|
+
KeyProperties.PURPOSE_DECRYPT
|
|
196
|
+
)
|
|
197
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
198
|
+
.setEncryptionPaddings(
|
|
199
|
+
KeyProperties.ENCRYPTION_PADDING_NONE
|
|
200
|
+
)
|
|
201
|
+
.setKeySize(WRAPPING_KEY_SIZE_BITS)
|
|
202
|
+
.build()
|
|
203
|
+
)
|
|
204
|
+
generator.generateKey()
|
|
205
|
+
} catch (error: Exception) {
|
|
206
|
+
throw LocalAccountStorageException(
|
|
207
|
+
"Unable to create local account wrapping key",
|
|
208
|
+
error
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private fun loadWrappingKey(): SecretKey {
|
|
214
|
+
val key = loadKeyStore().getKey(keyAlias, null)
|
|
215
|
+
?: throw LocalAccountStorageException(
|
|
216
|
+
"Local account wrapping key is unavailable"
|
|
217
|
+
)
|
|
218
|
+
return key as? SecretKey
|
|
219
|
+
?: throw LocalAccountStorageException(
|
|
220
|
+
"Invalid local account wrapping key"
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private fun loadKeyStore(): KeyStore {
|
|
225
|
+
return try {
|
|
226
|
+
KeyStore.getInstance(ANDROID_KEY_STORE)
|
|
227
|
+
.apply { load(null) }
|
|
228
|
+
} catch (error: Exception) {
|
|
229
|
+
throw LocalAccountStorageException(
|
|
230
|
+
"Unable to access local account storage",
|
|
231
|
+
error
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private fun decode(value: String): ByteArray {
|
|
237
|
+
return try {
|
|
238
|
+
Base64.decode(value, Base64.NO_WRAP)
|
|
239
|
+
} catch (error: IllegalArgumentException) {
|
|
240
|
+
throw LocalAccountStorageException(
|
|
241
|
+
"Local account storage is invalid",
|
|
242
|
+
error
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
internal companion object {
|
|
248
|
+
const val PREFERENCES_NAME = "local_signer_account"
|
|
249
|
+
const val KEY_ALIAS = "pakstr_local_signer_wrapping_key_v1"
|
|
250
|
+
const val KEY_IV = "iv"
|
|
251
|
+
const val KEY_CIPHERTEXT = "ciphertext"
|
|
252
|
+
private val STORAGE_LOCK = Any()
|
|
253
|
+
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
|
254
|
+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
255
|
+
private const val GCM_TAG_SIZE_BITS = 128
|
|
256
|
+
private const val WRAPPING_KEY_SIZE_BITS = 256
|
|
257
|
+
private val AUTHENTICATED_DATA =
|
|
258
|
+
"pakstr/local-account/v1".toByteArray(Charsets.UTF_8)
|
|
259
|
+
}
|
|
260
|
+
}
|