pakstr 0.20.0 → 0.21.1
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/README.md +1 -1
- package/android-template/app/build.gradle.kts +4 -0
- package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +273 -0
- package/android-template/app/src/main/assets/www/index.html +0 -6
- package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +20 -4
- package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +130 -12
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Hash.kt +56 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Signer.kt +104 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Verifier.kt +20 -170
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip44Crypto.kt +513 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip46ClientKey.kt +140 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventHasher.kt +3 -28
- package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventSerializer.kt +74 -0
- package/android-template/app/src/main/java/com/pakstr/app/crypto/Secp256k1.kt +100 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/BunkerSigner.kt +32 -7
- package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +36 -9
- package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +240 -163
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConfig.kt +175 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt +144 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Client.kt +79 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Models.kt +319 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingClient.kt +159 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingQrCode.kt +35 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingUri.kt +104 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Protocol.kt +695 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46RelayTransport.kt +489 -0
- package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/RelayNip46Client.kt +403 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/BunkerPairingDialog.kt +82 -0
- package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +78 -17
- package/android-template/app/src/main/res/layout/activity_layout.xml +1 -0
- package/android-template/app/src/main/res/layout/dialog_bunker_pairing.xml +75 -0
- package/android-template/app/src/main/res/layout/dialog_picker.xml +60 -31
- package/android-template/app/src/main/res/values/strings.xml +27 -0
- package/android-template/app/src/test/java/com/pakstr/app/LocalServerQueryKeepAliveTest.kt +118 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Bip340SignerTest.kt +133 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip44CryptoTest.kt +199 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyProtocolTest.kt +82 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyTest.kt +52 -0
- package/android-template/app/src/test/java/com/pakstr/app/crypto/NostrEventSerializerTest.kt +42 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeErrorTest.kt +32 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/BunkerConfigTest.kt +120 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ModelsTest.kt +111 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingClientTest.kt +271 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingQrCodeTest.kt +54 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingUriTest.kt +80 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ProtocolTest.kt +578 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46RelayTransportTest.kt +508 -0
- package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt +678 -0
- package/android-template/gradle/libs.versions.toml +8 -0
- package/package.json +1 -1
package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package com.pakstr.app.signer.bunker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Base64
|
|
5
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
6
|
+
import androidx.security.crypto.MasterKey
|
|
7
|
+
import com.pakstr.app.crypto.Bip340Verifier
|
|
8
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
9
|
+
|
|
10
|
+
internal data class BunkerConnection(
|
|
11
|
+
val config: BunkerConfig,
|
|
12
|
+
val clientKey: Nip46ClientKey,
|
|
13
|
+
val userPubkey: String?,
|
|
14
|
+
val connected: Boolean
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
internal class BunkerConnectionStore(
|
|
18
|
+
context: Context
|
|
19
|
+
) {
|
|
20
|
+
private val preferences =
|
|
21
|
+
EncryptedSharedPreferences.create(
|
|
22
|
+
context.applicationContext,
|
|
23
|
+
PREFERENCES_NAME,
|
|
24
|
+
MasterKey.Builder(context.applicationContext)
|
|
25
|
+
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
26
|
+
.build(),
|
|
27
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
28
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
fun savePending(
|
|
32
|
+
config: BunkerConfig,
|
|
33
|
+
clientKey: Nip46ClientKey
|
|
34
|
+
) {
|
|
35
|
+
saveConnection(config, clientKey, null, false)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fun save(
|
|
39
|
+
config: BunkerConfig,
|
|
40
|
+
clientKey: Nip46ClientKey,
|
|
41
|
+
userPubkey: String?,
|
|
42
|
+
connected: Boolean = true
|
|
43
|
+
) {
|
|
44
|
+
saveConnection(
|
|
45
|
+
config.withoutPairingSecret(),
|
|
46
|
+
clientKey,
|
|
47
|
+
userPubkey,
|
|
48
|
+
connected
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fun saveConnection(
|
|
53
|
+
config: BunkerConfig,
|
|
54
|
+
clientKey: Nip46ClientKey,
|
|
55
|
+
userPubkey: String?,
|
|
56
|
+
connected: Boolean
|
|
57
|
+
) {
|
|
58
|
+
userPubkey?.let(::validateUserPubkey)
|
|
59
|
+
clientKey.withPrivateKey { privateKey ->
|
|
60
|
+
val encodedKey =
|
|
61
|
+
Base64.encodeToString(
|
|
62
|
+
privateKey,
|
|
63
|
+
Base64.NO_WRAP
|
|
64
|
+
)
|
|
65
|
+
val saved =
|
|
66
|
+
preferences.edit()
|
|
67
|
+
.putString(KEY_CONFIG, config.encode())
|
|
68
|
+
.putString(KEY_CLIENT_KEY, encodedKey)
|
|
69
|
+
.putBoolean(KEY_CONNECTED, connected)
|
|
70
|
+
.apply {
|
|
71
|
+
if (userPubkey == null) {
|
|
72
|
+
remove(KEY_USER_PUBKEY)
|
|
73
|
+
} else {
|
|
74
|
+
putString(KEY_USER_PUBKEY, userPubkey)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
.commit()
|
|
78
|
+
check(saved) {
|
|
79
|
+
"Unable to store bunker connection"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fun restore(): BunkerConnection? {
|
|
85
|
+
val encodedConfig = preferences.getString(KEY_CONFIG, null)
|
|
86
|
+
val encodedKey = preferences.getString(KEY_CLIENT_KEY, null)
|
|
87
|
+
if (encodedConfig == null && encodedKey == null) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
if (encodedConfig == null || encodedKey == null) {
|
|
91
|
+
clear()
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
val privateKey =
|
|
96
|
+
try {
|
|
97
|
+
Base64.decode(encodedKey, Base64.NO_WRAP)
|
|
98
|
+
} catch (_: Exception) {
|
|
99
|
+
clear()
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
return try {
|
|
103
|
+
val config = BunkerConfig.parse(encodedConfig)
|
|
104
|
+
val userPubkey = preferences.getString(KEY_USER_PUBKEY, null)
|
|
105
|
+
userPubkey?.let(::validateUserPubkey)
|
|
106
|
+
BunkerConnection(
|
|
107
|
+
config,
|
|
108
|
+
Nip46ClientKey.fromPrivateKey(privateKey),
|
|
109
|
+
userPubkey,
|
|
110
|
+
preferences.getBoolean(KEY_CONNECTED, false)
|
|
111
|
+
)
|
|
112
|
+
} catch (_: Exception) {
|
|
113
|
+
clear()
|
|
114
|
+
null
|
|
115
|
+
} finally {
|
|
116
|
+
privateKey.fill(0)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fun clear() {
|
|
121
|
+
if (!preferences.edit().clear().commit()) {
|
|
122
|
+
throw IllegalStateException(
|
|
123
|
+
"Unable to clear bunker connection"
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun validateUserPubkey(value: String) {
|
|
129
|
+
require(
|
|
130
|
+
value.matches(Regex("[0-9a-f]{64}")) &&
|
|
131
|
+
Bip340Verifier.isValidXOnlyPubkey(value)
|
|
132
|
+
) {
|
|
133
|
+
"Invalid user public key"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private companion object {
|
|
138
|
+
const val PREFERENCES_NAME = "bunker_connection"
|
|
139
|
+
const val KEY_CONFIG = "config"
|
|
140
|
+
const val KEY_CLIENT_KEY = "client_key"
|
|
141
|
+
const val KEY_USER_PUBKEY = "user_pubkey"
|
|
142
|
+
const val KEY_CONNECTED = "connected"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package com.pakstr.app.signer.bunker
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.exceptions.SignerUnavailableException
|
|
4
|
+
|
|
5
|
+
interface Nip46Client {
|
|
6
|
+
suspend fun getPublicKey(): String
|
|
7
|
+
|
|
8
|
+
suspend fun signEvent(eventJson: String): String
|
|
9
|
+
|
|
10
|
+
suspend fun nip04Encrypt(
|
|
11
|
+
pubkey: String,
|
|
12
|
+
plaintext: String
|
|
13
|
+
): String
|
|
14
|
+
|
|
15
|
+
suspend fun nip04Decrypt(
|
|
16
|
+
pubkey: String,
|
|
17
|
+
ciphertext: String
|
|
18
|
+
): String
|
|
19
|
+
|
|
20
|
+
suspend fun nip44Encrypt(
|
|
21
|
+
pubkey: String,
|
|
22
|
+
plaintext: String
|
|
23
|
+
): String
|
|
24
|
+
|
|
25
|
+
suspend fun nip44Decrypt(
|
|
26
|
+
pubkey: String,
|
|
27
|
+
ciphertext: String
|
|
28
|
+
): String
|
|
29
|
+
|
|
30
|
+
fun disconnect() = Unit
|
|
31
|
+
|
|
32
|
+
fun close()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
internal object UnavailableNip46Client : Nip46Client {
|
|
36
|
+
override suspend fun getPublicKey(): String {
|
|
37
|
+
throw unavailable()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override suspend fun signEvent(eventJson: String): String {
|
|
41
|
+
throw unavailable()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override suspend fun nip04Encrypt(
|
|
45
|
+
pubkey: String,
|
|
46
|
+
plaintext: String
|
|
47
|
+
): String {
|
|
48
|
+
throw unavailable()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override suspend fun nip04Decrypt(
|
|
52
|
+
pubkey: String,
|
|
53
|
+
ciphertext: String
|
|
54
|
+
): String {
|
|
55
|
+
throw unavailable()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override suspend fun nip44Encrypt(
|
|
59
|
+
pubkey: String,
|
|
60
|
+
plaintext: String
|
|
61
|
+
): String {
|
|
62
|
+
throw unavailable()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override suspend fun nip44Decrypt(
|
|
66
|
+
pubkey: String,
|
|
67
|
+
ciphertext: String
|
|
68
|
+
): String {
|
|
69
|
+
throw unavailable()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun close() = Unit
|
|
73
|
+
|
|
74
|
+
private fun unavailable(): SignerUnavailableException {
|
|
75
|
+
return SignerUnavailableException(
|
|
76
|
+
"Bunker signer is not connected"
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
package com.pakstr.app.signer.bunker
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.security.SecureRandom
|
|
6
|
+
import java.util.Arrays
|
|
7
|
+
|
|
8
|
+
internal enum class Nip46Method(
|
|
9
|
+
val wireName: String
|
|
10
|
+
) {
|
|
11
|
+
CONNECT("connect"),
|
|
12
|
+
DISCONNECT("disconnect"),
|
|
13
|
+
GET_PUBLIC_KEY("get_public_key"),
|
|
14
|
+
SIGN_EVENT("sign_event"),
|
|
15
|
+
NIP04_ENCRYPT("nip04_encrypt"),
|
|
16
|
+
NIP04_DECRYPT("nip04_decrypt"),
|
|
17
|
+
NIP44_ENCRYPT("nip44_encrypt"),
|
|
18
|
+
NIP44_DECRYPT("nip44_decrypt");
|
|
19
|
+
|
|
20
|
+
companion object {
|
|
21
|
+
fun fromWireName(value: String): Nip46Method {
|
|
22
|
+
return entries.firstOrNull { it.wireName == value }
|
|
23
|
+
?: throw Nip46ProtocolException(
|
|
24
|
+
"Unknown NIP-46 method"
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
internal class Nip46Request(
|
|
31
|
+
val id: String,
|
|
32
|
+
val method: Nip46Method,
|
|
33
|
+
params: List<String>
|
|
34
|
+
) {
|
|
35
|
+
val params: List<String> = params.toList()
|
|
36
|
+
|
|
37
|
+
fun toJson(): String {
|
|
38
|
+
validateNip46RequestId(id)
|
|
39
|
+
|
|
40
|
+
return JSONObject()
|
|
41
|
+
.put("id", id)
|
|
42
|
+
.put("method", method.wireName)
|
|
43
|
+
.put("params", JSONArray(params))
|
|
44
|
+
.toString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
override fun toString(): String {
|
|
48
|
+
return "Nip46Request(" +
|
|
49
|
+
"id=${id.take(12)}..., " +
|
|
50
|
+
"method=${method.wireName}, " +
|
|
51
|
+
"paramCount=${params.size}" +
|
|
52
|
+
")"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
companion object {
|
|
56
|
+
fun parse(value: String): Nip46Request {
|
|
57
|
+
val json = parseJsonObject(value)
|
|
58
|
+
requireExactString(json, "id")
|
|
59
|
+
requireExactString(json, "method")
|
|
60
|
+
val params = requireArray(json, "params")
|
|
61
|
+
val parsedParams =
|
|
62
|
+
(0 until params.length()).map { index ->
|
|
63
|
+
val parameter = params.get(index)
|
|
64
|
+
if (parameter !is String) {
|
|
65
|
+
throw Nip46ProtocolException(
|
|
66
|
+
"Invalid NIP-46 request"
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
parameter
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return Nip46Request(
|
|
73
|
+
id = json.getString("id")
|
|
74
|
+
.also(::validateNip46RequestId),
|
|
75
|
+
method = Nip46Method.fromWireName(
|
|
76
|
+
json.getString("method")
|
|
77
|
+
),
|
|
78
|
+
params = parsedParams
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
internal class Nip46Response(
|
|
85
|
+
val id: String,
|
|
86
|
+
val result: String?,
|
|
87
|
+
val error: String?
|
|
88
|
+
) {
|
|
89
|
+
override fun toString(): String {
|
|
90
|
+
return "Nip46Response(" +
|
|
91
|
+
"id=${id.take(12)}..., " +
|
|
92
|
+
"hasResult=${result != null}, " +
|
|
93
|
+
"hasError=${error != null}" +
|
|
94
|
+
")"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
companion object {
|
|
98
|
+
fun parse(value: String): Nip46Response {
|
|
99
|
+
val json = parseJsonObject(value)
|
|
100
|
+
val id = requireExactString(json, "id")
|
|
101
|
+
.also(::validateNip46RequestId)
|
|
102
|
+
val result = optionalString(json, "result")
|
|
103
|
+
val error = optionalString(json, "error")
|
|
104
|
+
|
|
105
|
+
if (error == null && result == null) {
|
|
106
|
+
throw Nip46ProtocolException(
|
|
107
|
+
"Invalid NIP-46 response"
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (error != null && error.isEmpty()) {
|
|
111
|
+
throw Nip46ProtocolException(
|
|
112
|
+
"Invalid NIP-46 response"
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Nip46Response(id, result, error)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
internal fun interface Nip46RequestIdGenerator {
|
|
122
|
+
fun nextId(): String
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
internal object SecureNip46RequestIdGenerator : Nip46RequestIdGenerator {
|
|
126
|
+
private val secureRandom = SecureRandom()
|
|
127
|
+
|
|
128
|
+
override fun nextId(): String {
|
|
129
|
+
val bytes = ByteArray(32)
|
|
130
|
+
secureRandom.nextBytes(bytes)
|
|
131
|
+
|
|
132
|
+
return try {
|
|
133
|
+
bytes.joinToString("") { byte ->
|
|
134
|
+
"%02x".format(byte.toInt() and 0xff)
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
Arrays.fill(bytes, 0)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
internal class Nip46RequestContext(
|
|
143
|
+
val id: String,
|
|
144
|
+
val method: Nip46Method,
|
|
145
|
+
val expectedUserPubkey: String? = null,
|
|
146
|
+
val expectedConnectResult: String? = null,
|
|
147
|
+
unsignedEventFingerprint: ByteArray? = null
|
|
148
|
+
) : AutoCloseable {
|
|
149
|
+
private var fingerprint =
|
|
150
|
+
unsignedEventFingerprint?.copyOf()
|
|
151
|
+
private var state = RequestState.PENDING
|
|
152
|
+
|
|
153
|
+
@Synchronized
|
|
154
|
+
internal fun beginResponse(
|
|
155
|
+
authChallenge: Boolean
|
|
156
|
+
) {
|
|
157
|
+
when (state) {
|
|
158
|
+
RequestState.COMPLETED ->
|
|
159
|
+
throw Nip46ProtocolException(
|
|
160
|
+
"NIP-46 request is already completed"
|
|
161
|
+
)
|
|
162
|
+
RequestState.PENDING,
|
|
163
|
+
RequestState.AUTH_CHALLENGED -> {
|
|
164
|
+
state =
|
|
165
|
+
if (authChallenge) {
|
|
166
|
+
RequestState.AUTH_CHALLENGED
|
|
167
|
+
} else {
|
|
168
|
+
RequestState.COMPLETED
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
internal fun matchesUnsignedEvent(
|
|
175
|
+
candidate: ByteArray
|
|
176
|
+
): Boolean {
|
|
177
|
+
val expected = fingerprint ?: return false
|
|
178
|
+
return java.security.MessageDigest.isEqual(
|
|
179
|
+
expected,
|
|
180
|
+
candidate
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
override fun close() {
|
|
185
|
+
fingerprint?.let { Arrays.fill(it, 0) }
|
|
186
|
+
fingerprint = null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
override fun toString(): String {
|
|
190
|
+
return "Nip46RequestContext(" +
|
|
191
|
+
"id=${id.take(12)}..., " +
|
|
192
|
+
"method=${method.wireName}" +
|
|
193
|
+
")"
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private enum class RequestState {
|
|
197
|
+
PENDING,
|
|
198
|
+
AUTH_CHALLENGED,
|
|
199
|
+
COMPLETED
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
internal class Nip46OutboundRequest(
|
|
204
|
+
val eventJson: String,
|
|
205
|
+
val context: Nip46RequestContext
|
|
206
|
+
) {
|
|
207
|
+
override fun toString(): String {
|
|
208
|
+
return "Nip46OutboundRequest(" +
|
|
209
|
+
"context=$context" +
|
|
210
|
+
")"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
internal class Nip46VerifiedResponse(
|
|
215
|
+
val response: Nip46Response
|
|
216
|
+
) {
|
|
217
|
+
override fun toString(): String {
|
|
218
|
+
return "Nip46VerifiedResponse(response=$response)"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
internal sealed interface Nip46ResponseOutcome {
|
|
223
|
+
class Success(
|
|
224
|
+
val result: String
|
|
225
|
+
) : Nip46ResponseOutcome {
|
|
226
|
+
override fun toString(): String =
|
|
227
|
+
"Nip46ResponseOutcome.Success"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
class RemoteError : Nip46ResponseOutcome {
|
|
231
|
+
override fun toString(): String =
|
|
232
|
+
"Nip46ResponseOutcome.RemoteError"
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
class AuthChallenge(
|
|
236
|
+
val url: String
|
|
237
|
+
) : Nip46ResponseOutcome {
|
|
238
|
+
override fun toString(): String =
|
|
239
|
+
"Nip46ResponseOutcome.AuthChallenge"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
internal class Nip46ProtocolLimits(
|
|
244
|
+
val maximumEventBytes: Int = 512 * 1024,
|
|
245
|
+
val maximumEncryptedContentLength: Int = 384 * 1024,
|
|
246
|
+
val maximumPayloadBytes: Int = 256 * 1024,
|
|
247
|
+
val maximumAuthUrlLength: Int = 4 * 1024
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
internal class Nip46ProtocolException(
|
|
251
|
+
message: String
|
|
252
|
+
) : Exception(message)
|
|
253
|
+
|
|
254
|
+
internal fun validateNip46RequestId(value: String) {
|
|
255
|
+
if (!value.matches(Regex("[0-9a-f]{64}"))) {
|
|
256
|
+
throw Nip46ProtocolException(
|
|
257
|
+
"Invalid NIP-46 request ID"
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun parseJsonObject(value: String): JSONObject {
|
|
263
|
+
return try {
|
|
264
|
+
JSONObject(value)
|
|
265
|
+
} catch (_: Exception) {
|
|
266
|
+
throw Nip46ProtocolException(
|
|
267
|
+
"Invalid NIP-46 JSON"
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun requireExactString(
|
|
273
|
+
json: JSONObject,
|
|
274
|
+
key: String
|
|
275
|
+
): String {
|
|
276
|
+
if (!json.has(key) || json.isNull(key)) {
|
|
277
|
+
throw Nip46ProtocolException(
|
|
278
|
+
"Invalid NIP-46 JSON"
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
val value = json.get(key)
|
|
282
|
+
if (value !is String) {
|
|
283
|
+
throw Nip46ProtocolException(
|
|
284
|
+
"Invalid NIP-46 JSON"
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return value
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private fun optionalString(
|
|
291
|
+
json: JSONObject,
|
|
292
|
+
key: String
|
|
293
|
+
): String? {
|
|
294
|
+
if (!json.has(key) || json.isNull(key)) {
|
|
295
|
+
return null
|
|
296
|
+
}
|
|
297
|
+
val value = json.get(key)
|
|
298
|
+
if (value !is String) {
|
|
299
|
+
throw Nip46ProtocolException(
|
|
300
|
+
"Invalid NIP-46 JSON"
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
return value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun requireArray(
|
|
307
|
+
json: JSONObject,
|
|
308
|
+
key: String
|
|
309
|
+
): JSONArray {
|
|
310
|
+
if (!json.has(key) || json.isNull(key)) {
|
|
311
|
+
throw Nip46ProtocolException(
|
|
312
|
+
"Invalid NIP-46 JSON"
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
return json.optJSONArray(key)
|
|
316
|
+
?: throw Nip46ProtocolException(
|
|
317
|
+
"Invalid NIP-46 JSON"
|
|
318
|
+
)
|
|
319
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package com.pakstr.app.signer.bunker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
5
|
+
import com.pakstr.app.crypto.NostrEventVerifier
|
|
6
|
+
import com.pakstr.app.debug.AppDebugLogger
|
|
7
|
+
import kotlinx.coroutines.Dispatchers
|
|
8
|
+
import kotlinx.coroutines.withContext
|
|
9
|
+
import org.json.JSONArray
|
|
10
|
+
import org.json.JSONObject
|
|
11
|
+
import java.security.SecureRandom
|
|
12
|
+
import java.util.concurrent.CountDownLatch
|
|
13
|
+
import java.util.concurrent.TimeUnit
|
|
14
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
15
|
+
|
|
16
|
+
internal class Nip46PairingClient(
|
|
17
|
+
private val relayUrls: List<String>,
|
|
18
|
+
private val clientKey: Nip46ClientKey,
|
|
19
|
+
private val pairingSecret: String,
|
|
20
|
+
connectionFactory: Nip46RelayConnectionFactory,
|
|
21
|
+
diagnosticLogger: (String) -> Unit = {}
|
|
22
|
+
) : AutoCloseable {
|
|
23
|
+
constructor(
|
|
24
|
+
context: Context,
|
|
25
|
+
relayUrls: List<String>,
|
|
26
|
+
clientKey: Nip46ClientKey,
|
|
27
|
+
pairingSecret: String
|
|
28
|
+
) : this(
|
|
29
|
+
relayUrls,
|
|
30
|
+
clientKey,
|
|
31
|
+
pairingSecret,
|
|
32
|
+
OkHttpNip46RelayConnectionFactory { message ->
|
|
33
|
+
AppDebugLogger.log(context, DIAGNOSTIC_TAG, message)
|
|
34
|
+
},
|
|
35
|
+
{ message -> AppDebugLogger.log(context, DIAGNOSTIC_TAG, message) }
|
|
36
|
+
)
|
|
37
|
+
private val transport =
|
|
38
|
+
Nip46RelayTransport(
|
|
39
|
+
relayUrls = relayUrls,
|
|
40
|
+
remoteSignerPubkey = null,
|
|
41
|
+
clientPubkey = clientKey.publicKeyHex,
|
|
42
|
+
connectionFactory = connectionFactory,
|
|
43
|
+
diagnosticLogger = diagnosticLogger
|
|
44
|
+
)
|
|
45
|
+
private val completed = CountDownLatch(1)
|
|
46
|
+
private val lock = Any()
|
|
47
|
+
private var remoteSignerPubkey: String? = null
|
|
48
|
+
private val closed = AtomicBoolean(false)
|
|
49
|
+
|
|
50
|
+
init {
|
|
51
|
+
transport.start(::receiveEvent)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
suspend fun awaitRelayReady(
|
|
55
|
+
timeoutMillis: Long = DEFAULT_RELAY_READY_TIMEOUT_MILLIS
|
|
56
|
+
): Boolean = transport.awaitReady(timeoutMillis)
|
|
57
|
+
|
|
58
|
+
suspend fun awaitConnection(
|
|
59
|
+
timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS
|
|
60
|
+
): BunkerConfig? = withContext(Dispatchers.IO) {
|
|
61
|
+
completed.await(timeoutMillis, TimeUnit.MILLISECONDS)
|
|
62
|
+
val remote = synchronized(lock) { remoteSignerPubkey }
|
|
63
|
+
?: return@withContext null
|
|
64
|
+
val query =
|
|
65
|
+
relayUrls.joinToString("&") { relay ->
|
|
66
|
+
"relay=" + java.net.URLEncoder.encode(
|
|
67
|
+
relay,
|
|
68
|
+
Charsets.UTF_8.name()
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
BunkerConfig.parse("bunker://$remote?$query")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun close() {
|
|
75
|
+
if (closed.compareAndSet(false, true)) {
|
|
76
|
+
completed.countDown()
|
|
77
|
+
transport.close()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun receiveEvent(eventJson: String) {
|
|
82
|
+
val event =
|
|
83
|
+
try {
|
|
84
|
+
JSONObject(eventJson)
|
|
85
|
+
} catch (_: Exception) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
val author = event.optString("pubkey")
|
|
89
|
+
if (!author.matches(Regex("[0-9a-f]{64}"))) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (
|
|
93
|
+
event.optInt("kind", -1) != EVENT_KIND ||
|
|
94
|
+
!hasRecipient(event.optJSONArray("tags")) ||
|
|
95
|
+
!NostrEventVerifier.verify(event)
|
|
96
|
+
) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
val response =
|
|
100
|
+
try {
|
|
101
|
+
val decrypted =
|
|
102
|
+
clientKey.decryptNip44(
|
|
103
|
+
event.getString("content"),
|
|
104
|
+
author
|
|
105
|
+
)
|
|
106
|
+
JSONObject(decrypted)
|
|
107
|
+
} catch (_: Exception) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
val result = response.opt("result")
|
|
111
|
+
val error = response.opt("error")
|
|
112
|
+
if (
|
|
113
|
+
result !is String ||
|
|
114
|
+
result != pairingSecret ||
|
|
115
|
+
(error != null && error != JSONObject.NULL)
|
|
116
|
+
) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
synchronized(lock) {
|
|
120
|
+
if (remoteSignerPubkey == null) {
|
|
121
|
+
remoteSignerPubkey = author
|
|
122
|
+
completed.countDown()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun hasRecipient(tags: JSONArray?): Boolean {
|
|
128
|
+
if (tags == null) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
return (0 until tags.length()).any { index ->
|
|
132
|
+
val tag = tags.optJSONArray(index)
|
|
133
|
+
tag != null &&
|
|
134
|
+
tag.length() >= 2 &&
|
|
135
|
+
tag.optString(0) == "p" &&
|
|
136
|
+
tag.optString(1) == clientKey.publicKeyHex
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
companion object {
|
|
141
|
+
private const val DIAGNOSTIC_TAG = "NIP46"
|
|
142
|
+
private const val EVENT_KIND = 24133
|
|
143
|
+
private const val DEFAULT_RELAY_READY_TIMEOUT_MILLIS = 30_000L
|
|
144
|
+
private const val DEFAULT_TIMEOUT_MILLIS = 300_000L
|
|
145
|
+
private val secureRandom = SecureRandom()
|
|
146
|
+
|
|
147
|
+
fun generateSecret(): String {
|
|
148
|
+
val bytes = ByteArray(32)
|
|
149
|
+
secureRandom.nextBytes(bytes)
|
|
150
|
+
return try {
|
|
151
|
+
bytes.joinToString("") { byte ->
|
|
152
|
+
"%02x".format(byte.toInt() and 0xff)
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
bytes.fill(0)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|