pakstr 0.21.1 → 0.22.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/Nip55CallbackTest.kt +1 -1
- package/android-template/app/src/main/java/com/pakstr/app/Nip55Callback.kt +3 -0
- package/android-template/app/src/main/java/com/pakstr/app/Nip55SignEventRequest.kt +303 -0
- package/android-template/app/src/main/java/com/pakstr/app/PakstrBridge.kt +1 -1
- package/android-template/app/src/main/java/com/pakstr/app/WebViewController.kt +66 -16
- package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventDispatchTest.kt +115 -0
- package/android-template/app/src/test/java/com/pakstr/app/Nip55SignEventRequestTest.kt +90 -0
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ class Nip55CallbackTest {
|
|
|
10
10
|
|
|
11
11
|
@Test
|
|
12
12
|
fun accepts_fragment_callback_and_preserves_logical_event() {
|
|
13
|
-
val event = "{\"kind\":27235,\"content\":\"hello & goodbye / 100%\"}"
|
|
13
|
+
val event = "{\"id\":\"${"a".repeat(64)}\",\"pubkey\":\"${"b".repeat(64)}\",\"created_at\":1789552517,\"kind\":27235,\"tags\":[],\"content\":\"hello & goodbye / 100%\",\"sig\":\"${"c".repeat(128)}\"}"
|
|
14
14
|
val intent = callbackIntent(
|
|
15
15
|
"${Nip55Callback.PARAM}=${Uri.encode(event)}"
|
|
16
16
|
)
|
|
@@ -10,6 +10,9 @@ internal object Nip55Callback {
|
|
|
10
10
|
private const val FRAGMENT_PREFIX = "$PARAM="
|
|
11
11
|
private const val MAX_RESULT_LENGTH = 128 * 1024
|
|
12
12
|
|
|
13
|
+
val callbackPrefix: String
|
|
14
|
+
get() = "${BuildConfig.PAKSTR_NIP55_CALLBACK_SCHEME}://$HOST#$FRAGMENT_PREFIX"
|
|
15
|
+
|
|
13
16
|
fun result(intent: Intent?): String? {
|
|
14
17
|
if (intent?.action != Intent.ACTION_VIEW) {
|
|
15
18
|
return null
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.crypto.NostrEventHasher
|
|
4
|
+
import com.pakstr.app.signer.SignerType
|
|
5
|
+
import org.json.JSONArray
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import java.io.ByteArrayOutputStream
|
|
8
|
+
import java.nio.ByteBuffer
|
|
9
|
+
import java.nio.charset.CodingErrorAction
|
|
10
|
+
|
|
11
|
+
internal sealed interface Nip55SignEventRequest {
|
|
12
|
+
data object External : Nip55SignEventRequest
|
|
13
|
+
|
|
14
|
+
data class Rejected(
|
|
15
|
+
val reason: String
|
|
16
|
+
) : Nip55SignEventRequest
|
|
17
|
+
|
|
18
|
+
data class Parsed(
|
|
19
|
+
val event: JSONObject,
|
|
20
|
+
val callbackUrl: String
|
|
21
|
+
) : Nip55SignEventRequest
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
internal sealed interface Nip55NormalizedEvent {
|
|
25
|
+
data class Rejected(
|
|
26
|
+
val reason: String
|
|
27
|
+
) : Nip55NormalizedEvent
|
|
28
|
+
|
|
29
|
+
data class Ready(
|
|
30
|
+
val eventJson: String
|
|
31
|
+
) : Nip55NormalizedEvent
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
internal object Nip55SignEventParser {
|
|
35
|
+
private const val SCHEME_PREFIX = "nostrsigner:"
|
|
36
|
+
private const val MAX_REQUEST_BYTES = 128 * 1024
|
|
37
|
+
private val RELEVANT_PARAMETERS = setOf(
|
|
38
|
+
"type",
|
|
39
|
+
"returnType",
|
|
40
|
+
"compressionType",
|
|
41
|
+
"callbackUrl"
|
|
42
|
+
)
|
|
43
|
+
private val EVENT_FIELDS = setOf(
|
|
44
|
+
"kind",
|
|
45
|
+
"created_at",
|
|
46
|
+
"tags",
|
|
47
|
+
"content",
|
|
48
|
+
"pubkey",
|
|
49
|
+
"id",
|
|
50
|
+
"sig"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
fun parse(
|
|
54
|
+
rawUrl: String,
|
|
55
|
+
expectedCallbackUrl: String
|
|
56
|
+
): Nip55SignEventRequest {
|
|
57
|
+
if (rawUrl.toByteArray(Charsets.UTF_8).size > MAX_REQUEST_BYTES) {
|
|
58
|
+
return rejected("request too large")
|
|
59
|
+
}
|
|
60
|
+
if (!rawUrl.startsWith(SCHEME_PREFIX, ignoreCase = true)) {
|
|
61
|
+
return Nip55SignEventRequest.External
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
val schemeSpecificPart = rawUrl.substring(SCHEME_PREFIX.length)
|
|
65
|
+
if ('#' in schemeSpecificPart) {
|
|
66
|
+
return rejected("malformed request")
|
|
67
|
+
}
|
|
68
|
+
val separator = schemeSpecificPart.indexOf('?')
|
|
69
|
+
val encodedEvent = if (separator >= 0) {
|
|
70
|
+
schemeSpecificPart.substring(0, separator)
|
|
71
|
+
} else {
|
|
72
|
+
schemeSpecificPart
|
|
73
|
+
}
|
|
74
|
+
val rawQuery = if (separator >= 0) {
|
|
75
|
+
schemeSpecificPart.substring(separator + 1)
|
|
76
|
+
} else {
|
|
77
|
+
""
|
|
78
|
+
}
|
|
79
|
+
if (encodedEvent.isEmpty()) {
|
|
80
|
+
return rejected("missing event")
|
|
81
|
+
}
|
|
82
|
+
if (!hasOpaquePayloadEncoding(encodedEvent)) {
|
|
83
|
+
return rejected("malformed event encoding")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val parameters = parseParameters(rawQuery)
|
|
87
|
+
?: return rejected("malformed parameters")
|
|
88
|
+
val type = parameters["type"]
|
|
89
|
+
if (type != "sign_event") {
|
|
90
|
+
return Nip55SignEventRequest.External
|
|
91
|
+
}
|
|
92
|
+
if (parameters["returnType"] != "event") {
|
|
93
|
+
return rejected("unsupported return type")
|
|
94
|
+
}
|
|
95
|
+
val compressionType = parameters["compressionType"]
|
|
96
|
+
if (compressionType != null && compressionType != "none") {
|
|
97
|
+
return rejected("unsupported compression")
|
|
98
|
+
}
|
|
99
|
+
val callbackUrl = parameters["callbackUrl"]
|
|
100
|
+
?: return rejected("missing callback")
|
|
101
|
+
if (callbackUrl != expectedCallbackUrl) {
|
|
102
|
+
return rejected("unexpected callback")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val eventText = percentDecode(encodedEvent)
|
|
106
|
+
?: return rejected("malformed event encoding")
|
|
107
|
+
val event = try {
|
|
108
|
+
JSONObject(eventText)
|
|
109
|
+
} catch (_: Exception) {
|
|
110
|
+
return rejected("invalid event")
|
|
111
|
+
}
|
|
112
|
+
if (event.keys().asSequence().any { it !in EVENT_FIELDS }) {
|
|
113
|
+
return rejected("unsupported event field")
|
|
114
|
+
}
|
|
115
|
+
if (!hasIntegral(event, "kind") ||
|
|
116
|
+
event.getLong("kind") !in 0L..65535L ||
|
|
117
|
+
!hasIntegral(event, "created_at") ||
|
|
118
|
+
event.optJSONArray("tags") == null ||
|
|
119
|
+
!hasString(event, "content") ||
|
|
120
|
+
!hasValidTags(event.getJSONArray("tags"))
|
|
121
|
+
) {
|
|
122
|
+
return rejected("invalid event fields")
|
|
123
|
+
}
|
|
124
|
+
if (event.has("sig") &&
|
|
125
|
+
(!hasString(event, "sig") || event.getString("sig").isNotEmpty())
|
|
126
|
+
) {
|
|
127
|
+
return rejected("event already signed")
|
|
128
|
+
}
|
|
129
|
+
if (event.has("pubkey") && !hasString(event, "pubkey")) {
|
|
130
|
+
return rejected("invalid pubkey")
|
|
131
|
+
}
|
|
132
|
+
if (event.has("id") && !hasString(event, "id")) {
|
|
133
|
+
return rejected("invalid event id")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Nip55SignEventRequest.Parsed(event, callbackUrl)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fun normalize(
|
|
140
|
+
request: Nip55SignEventRequest.Parsed,
|
|
141
|
+
activePubkey: String
|
|
142
|
+
): Nip55NormalizedEvent {
|
|
143
|
+
if (activePubkey.isBlank()) {
|
|
144
|
+
return normalizedRejected("signer unavailable")
|
|
145
|
+
}
|
|
146
|
+
val event = JSONObject(request.event.toString())
|
|
147
|
+
if (event.has("pubkey") && event.getString("pubkey") != activePubkey) {
|
|
148
|
+
return normalizedRejected("pubkey mismatch")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (event.has("id")) {
|
|
152
|
+
val expectedId = event.getString("id")
|
|
153
|
+
event.put("pubkey", activePubkey)
|
|
154
|
+
val actualId = try {
|
|
155
|
+
NostrEventHasher.hashEvent(event)
|
|
156
|
+
} catch (_: Exception) {
|
|
157
|
+
return normalizedRejected("invalid event id")
|
|
158
|
+
}
|
|
159
|
+
if (actualId != expectedId) {
|
|
160
|
+
return normalizedRejected("event id mismatch")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return Nip55NormalizedEvent.Ready(
|
|
165
|
+
JSONObject()
|
|
166
|
+
.put("kind", event.getInt("kind"))
|
|
167
|
+
.put("created_at", event.getLong("created_at"))
|
|
168
|
+
.put("tags", event.getJSONArray("tags"))
|
|
169
|
+
.put("content", event.getString("content"))
|
|
170
|
+
.toString()
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private fun hasOpaquePayloadEncoding(value: String): Boolean {
|
|
175
|
+
var index = 0
|
|
176
|
+
while (index < value.length) {
|
|
177
|
+
if (value[index] == '%') {
|
|
178
|
+
if (index + 2 >= value.length ||
|
|
179
|
+
value[index + 1].digitToIntOrNull(16) == null ||
|
|
180
|
+
value[index + 2].digitToIntOrNull(16) == null
|
|
181
|
+
) {
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
index += 3
|
|
185
|
+
} else {
|
|
186
|
+
val character = value[index]
|
|
187
|
+
if (!character.isLetterOrDigit() && character !in "-._~") {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
index++
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private fun parseParameters(rawQuery: String): Map<String, String>? {
|
|
197
|
+
val parameters = mutableMapOf<String, String>()
|
|
198
|
+
if (rawQuery.isEmpty()) {
|
|
199
|
+
return parameters
|
|
200
|
+
}
|
|
201
|
+
for (part in rawQuery.split('&')) {
|
|
202
|
+
val separator = part.indexOf('=')
|
|
203
|
+
if (separator < 0) {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
val name = percentDecode(part.substring(0, separator)) ?: return null
|
|
207
|
+
val value = percentDecode(part.substring(separator + 1)) ?: return null
|
|
208
|
+
if (name in RELEVANT_PARAMETERS && parameters.containsKey(name)) {
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
parameters[name] = value
|
|
212
|
+
}
|
|
213
|
+
return parameters
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private fun percentDecode(value: String): String? {
|
|
217
|
+
val bytes = ByteArrayOutputStream(value.length)
|
|
218
|
+
var index = 0
|
|
219
|
+
while (index < value.length) {
|
|
220
|
+
val character = value[index]
|
|
221
|
+
if (character == '%') {
|
|
222
|
+
if (index + 2 >= value.length) return null
|
|
223
|
+
val high = value[index + 1].digitToIntOrNull(16) ?: return null
|
|
224
|
+
val low = value[index + 2].digitToIntOrNull(16) ?: return null
|
|
225
|
+
bytes.write((high shl 4) or low)
|
|
226
|
+
index += 3
|
|
227
|
+
} else {
|
|
228
|
+
val codePoint = value.codePointAt(index)
|
|
229
|
+
bytes.write(String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8))
|
|
230
|
+
index += Character.charCount(codePoint)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return try {
|
|
234
|
+
Charsets.UTF_8.newDecoder()
|
|
235
|
+
.onMalformedInput(CodingErrorAction.REPORT)
|
|
236
|
+
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
237
|
+
.decode(ByteBuffer.wrap(bytes.toByteArray()))
|
|
238
|
+
.toString()
|
|
239
|
+
} catch (_: Exception) {
|
|
240
|
+
null
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun hasIntegral(event: JSONObject, name: String): Boolean {
|
|
245
|
+
if (!event.has(name) || event.isNull(name)) return false
|
|
246
|
+
return event.get(name) is Byte ||
|
|
247
|
+
event.get(name) is Short ||
|
|
248
|
+
event.get(name) is Int ||
|
|
249
|
+
event.get(name) is Long
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private fun hasString(event: JSONObject, name: String): Boolean {
|
|
253
|
+
return event.has(name) && !event.isNull(name) && event.get(name) is String
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private fun hasValidTags(tags: JSONArray): Boolean {
|
|
257
|
+
for (tagIndex in 0 until tags.length()) {
|
|
258
|
+
val tag = tags.optJSONArray(tagIndex) ?: return false
|
|
259
|
+
if (tag.length() == 0) return false
|
|
260
|
+
for (valueIndex in 0 until tag.length()) {
|
|
261
|
+
if (tag.get(valueIndex) !is String) return false
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private fun rejected(reason: String) = Nip55SignEventRequest.Rejected(reason)
|
|
268
|
+
|
|
269
|
+
private fun normalizedRejected(reason: String) = Nip55NormalizedEvent.Rejected(reason)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
internal fun dispatchNip55SignEvent(
|
|
273
|
+
rawUrl: String,
|
|
274
|
+
activeSignerType: SignerType?,
|
|
275
|
+
expectedCallbackUrl: String,
|
|
276
|
+
forwardExternally: () -> Unit,
|
|
277
|
+
launchSigning: (suspend () -> Unit) -> Unit,
|
|
278
|
+
getActivePubkey: suspend () -> String,
|
|
279
|
+
signEvent: suspend (String) -> String,
|
|
280
|
+
deliverSignedEvent: suspend (String) -> Unit,
|
|
281
|
+
logRejection: (String) -> Unit
|
|
282
|
+
) {
|
|
283
|
+
if (activeSignerType != SignerType.BUNKER) {
|
|
284
|
+
forwardExternally()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
when (val request = Nip55SignEventParser.parse(rawUrl, expectedCallbackUrl)) {
|
|
289
|
+
Nip55SignEventRequest.External -> forwardExternally()
|
|
290
|
+
is Nip55SignEventRequest.Rejected -> logRejection(request.reason)
|
|
291
|
+
is Nip55SignEventRequest.Parsed -> launchSigning {
|
|
292
|
+
when (val normalized = Nip55SignEventParser.normalize(
|
|
293
|
+
request,
|
|
294
|
+
getActivePubkey()
|
|
295
|
+
)) {
|
|
296
|
+
is Nip55NormalizedEvent.Rejected -> logRejection(normalized.reason)
|
|
297
|
+
is Nip55NormalizedEvent.Ready -> deliverSignedEvent(
|
|
298
|
+
signEvent(normalized.eventJson)
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -17,6 +17,13 @@ import com.pakstr.app.debug.AppDebugLogger
|
|
|
17
17
|
import com.pakstr.app.permissions.interfaces.PermissionHandler
|
|
18
18
|
import com.pakstr.app.signer.NostrBridge
|
|
19
19
|
import com.pakstr.app.signer.SignerManager
|
|
20
|
+
import kotlinx.coroutines.CancellationException
|
|
21
|
+
import kotlinx.coroutines.CoroutineScope
|
|
22
|
+
import kotlinx.coroutines.Dispatchers
|
|
23
|
+
import kotlinx.coroutines.SupervisorJob
|
|
24
|
+
import kotlinx.coroutines.cancel
|
|
25
|
+
import kotlinx.coroutines.launch
|
|
26
|
+
import kotlinx.coroutines.withContext
|
|
20
27
|
|
|
21
28
|
/**
|
|
22
29
|
* Controls WebView configuration and lifecycle.
|
|
@@ -44,6 +51,7 @@ class WebViewController(
|
|
|
44
51
|
) {
|
|
45
52
|
private var pendingNip55Result: String? = null
|
|
46
53
|
private var isSetup = false
|
|
54
|
+
private val nip55Scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
47
55
|
|
|
48
56
|
// Keeps a reference to the runnable to prevent memory leaks if destroyed early
|
|
49
57
|
private val loadRunnable = Runnable {
|
|
@@ -228,22 +236,37 @@ class WebViewController(
|
|
|
228
236
|
): Boolean {
|
|
229
237
|
val url = request?.url ?: return true
|
|
230
238
|
if (url.scheme.equals("nostrsigner", ignoreCase = true)) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
239
|
+
dispatchNip55SignEvent(
|
|
240
|
+
rawUrl = url.toString(),
|
|
241
|
+
activeSignerType = signerManager.getActiveType(),
|
|
242
|
+
expectedCallbackUrl = Nip55Callback.callbackPrefix,
|
|
243
|
+
forwardExternally = { launchExternalNip55(url) },
|
|
244
|
+
launchSigning = { operation ->
|
|
245
|
+
nip55Scope.launch {
|
|
246
|
+
try {
|
|
247
|
+
operation()
|
|
248
|
+
} catch (error: CancellationException) {
|
|
249
|
+
throw error
|
|
250
|
+
} catch (error: Exception) {
|
|
251
|
+
AppDebugLogger.log(
|
|
252
|
+
context,
|
|
253
|
+
"NIP55",
|
|
254
|
+
"Bunker flow failed: ${error::class.java.simpleName}"
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
getActivePubkey = signerManager::getCurrentUserPubkey,
|
|
260
|
+
signEvent = { event ->
|
|
261
|
+
signerManager.getSigner().signEvent(event)
|
|
262
|
+
},
|
|
263
|
+
deliverSignedEvent = { signedEvent ->
|
|
264
|
+
withContext(Dispatchers.Main) {
|
|
265
|
+
handleNip55Callback(signedEvent)
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
logRejection = ::logNip55Rejection
|
|
269
|
+
)
|
|
247
270
|
return true
|
|
248
271
|
}
|
|
249
272
|
return url.host != "127.0.0.1"
|
|
@@ -355,7 +378,34 @@ class WebViewController(
|
|
|
355
378
|
(context as? Activity)?.findViewById<View>(R.id.loader)?.visibility = View.GONE
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
private fun launchExternalNip55(url: android.net.Uri) {
|
|
382
|
+
try {
|
|
383
|
+
val intent = Intent(Intent.ACTION_VIEW, url).apply {
|
|
384
|
+
putExtra(
|
|
385
|
+
Browser.EXTRA_APPLICATION_ID,
|
|
386
|
+
context.packageName
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
context.startActivity(intent)
|
|
390
|
+
} catch (_: ActivityNotFoundException) {
|
|
391
|
+
AppDebugLogger.error(
|
|
392
|
+
context,
|
|
393
|
+
"WEBVIEW",
|
|
394
|
+
"No app can handle nostrsigner URL"
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private fun logNip55Rejection(reason: String) {
|
|
400
|
+
AppDebugLogger.log(
|
|
401
|
+
context,
|
|
402
|
+
"NIP55",
|
|
403
|
+
"Request rejected: $reason"
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
358
407
|
fun destroy() {
|
|
408
|
+
nip55Scope.cancel()
|
|
359
409
|
webView.stopLoading()
|
|
360
410
|
|
|
361
411
|
webView.clearHistory()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.signer.SignerType
|
|
4
|
+
import kotlinx.coroutines.runBlocking
|
|
5
|
+
import org.json.JSONArray
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertFalse
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
|
|
12
|
+
class Nip55SignEventDispatchTest {
|
|
13
|
+
private val callback = "pakstr-test://nip55-callback#nip55_event="
|
|
14
|
+
private val activePubkey = "1".repeat(64)
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun bunker_valid_sign_event_signs_internally_without_external_intent() {
|
|
18
|
+
val result = dispatch(SignerType.BUNKER, validRequest())
|
|
19
|
+
|
|
20
|
+
assertTrue(result.signed)
|
|
21
|
+
assertFalse(result.forwarded)
|
|
22
|
+
assertEquals(SIGNED_EVENT, result.delivered)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun bunker_invalid_sign_event_is_consumed_without_external_intent() {
|
|
27
|
+
val result = dispatch(
|
|
28
|
+
SignerType.BUNKER,
|
|
29
|
+
validRequest().replace("returnType=event", "returnType=signature")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
assertFalse(result.signed)
|
|
33
|
+
assertFalse(result.forwarded)
|
|
34
|
+
assertTrue(result.rejection != null)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun bunker_other_operation_is_forwarded_externally() {
|
|
39
|
+
val result = dispatch(
|
|
40
|
+
SignerType.BUNKER,
|
|
41
|
+
validRequest().replace("type=sign_event", "type=get_public_key")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assertFalse(result.signed)
|
|
45
|
+
assertTrue(result.forwarded)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Test
|
|
49
|
+
fun amber_sign_event_is_forwarded_externally() {
|
|
50
|
+
val result = dispatch(SignerType.AMBER, validRequest())
|
|
51
|
+
|
|
52
|
+
assertFalse(result.signed)
|
|
53
|
+
assertTrue(result.forwarded)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private fun dispatch(type: SignerType, rawUrl: String): Result {
|
|
57
|
+
var forwarded = false
|
|
58
|
+
var signed = false
|
|
59
|
+
var delivered: String? = null
|
|
60
|
+
var rejection: String? = null
|
|
61
|
+
|
|
62
|
+
dispatchNip55SignEvent(
|
|
63
|
+
rawUrl = rawUrl,
|
|
64
|
+
activeSignerType = type,
|
|
65
|
+
expectedCallbackUrl = callback,
|
|
66
|
+
forwardExternally = { forwarded = true },
|
|
67
|
+
launchSigning = { operation -> runBlocking { operation() } },
|
|
68
|
+
getActivePubkey = { activePubkey },
|
|
69
|
+
signEvent = {
|
|
70
|
+
signed = true
|
|
71
|
+
SIGNED_EVENT
|
|
72
|
+
},
|
|
73
|
+
deliverSignedEvent = { delivered = it },
|
|
74
|
+
logRejection = { rejection = it }
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return Result(forwarded, signed, delivered, rejection)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private fun validRequest(): String {
|
|
81
|
+
val event = JSONObject()
|
|
82
|
+
.put("kind", 27235)
|
|
83
|
+
.put("created_at", 1789552517L)
|
|
84
|
+
.put("tags", JSONArray())
|
|
85
|
+
.put("content", "")
|
|
86
|
+
.toString()
|
|
87
|
+
return "nostrsigner:${encode(event)}" +
|
|
88
|
+
"?type=sign_event&returnType=event&compressionType=none" +
|
|
89
|
+
"&callbackUrl=${encode(callback)}"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private fun encode(value: String): String {
|
|
93
|
+
return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
|
|
94
|
+
val valueInt = byte.toInt() and 0xff
|
|
95
|
+
val character = valueInt.toChar()
|
|
96
|
+
if (character.isLetterOrDigit() || character in "-._~") {
|
|
97
|
+
character.toString()
|
|
98
|
+
} else {
|
|
99
|
+
"%${valueInt.toString(16).uppercase().padStart(2, '0')}"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private data class Result(
|
|
105
|
+
val forwarded: Boolean,
|
|
106
|
+
val signed: Boolean,
|
|
107
|
+
val delivered: String?,
|
|
108
|
+
val rejection: String?
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
private companion object {
|
|
112
|
+
const val SIGNED_EVENT =
|
|
113
|
+
"{\"id\":\"signed-id\",\"pubkey\":\"signed-pubkey\",\"sig\":\"signed-sig\"}"
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package com.pakstr.app
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import org.junit.Assert.assertEquals
|
|
5
|
+
import org.junit.Assert.assertTrue
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
|
|
8
|
+
class Nip55SignEventRequestTest {
|
|
9
|
+
private val callback =
|
|
10
|
+
"pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c://nip55-callback#nip55_event="
|
|
11
|
+
|
|
12
|
+
@Test
|
|
13
|
+
fun parses_exact_regress_request() {
|
|
14
|
+
val rawUrl =
|
|
15
|
+
"nostrsigner:%7B%22kind%22%3A27235%2C%22created_at%22%3A1789552517%2C%22content%22%3A%22%22%2C%22tags%22%3A%5B%5B%22u%22%2C%22http%3A%2F%2F127.0.0.1%3A8080%2Fapi%2Fauth%2Flogin%22%5D%2C%5B%22method%22%2C%22POST%22%5D%5D%7D?compressionType=none&returnType=event&type=sign_event&callbackUrl=pakstr-7f7e720bd582301afabb6b441f89a4fb8d1a481c3e0286c69345d03e7cb1929c%3A%2F%2Fnip55-callback%23nip55_event%3D"
|
|
16
|
+
|
|
17
|
+
val parsed = Nip55SignEventParser.parse(rawUrl, callback)
|
|
18
|
+
|
|
19
|
+
assertTrue(parsed is Nip55SignEventRequest.Parsed)
|
|
20
|
+
parsed as Nip55SignEventRequest.Parsed
|
|
21
|
+
assertEquals(callback, parsed.callbackUrl)
|
|
22
|
+
assertEquals(27235, parsed.event.getInt("kind"))
|
|
23
|
+
assertEquals(1789552517L, parsed.event.getLong("created_at"))
|
|
24
|
+
assertEquals("", parsed.event.getString("content"))
|
|
25
|
+
assertEquals(
|
|
26
|
+
"http://127.0.0.1:8080/api/auth/login",
|
|
27
|
+
parsed.event.getJSONArray("tags").getJSONArray(0).getString(1)
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun accepts_event_return_with_none_or_omitted_compression() {
|
|
33
|
+
val base = requestQuery("type=sign_event&returnType=event&callbackUrl=${encode(callback)}")
|
|
34
|
+
val withNone = requestQuery(
|
|
35
|
+
"type=sign_event&returnType=event&compressionType=none&callbackUrl=${encode(callback)}"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assertTrue(Nip55SignEventParser.parse(base, callback) is Nip55SignEventRequest.Parsed)
|
|
39
|
+
assertTrue(Nip55SignEventParser.parse(withNone, callback) is Nip55SignEventRequest.Parsed)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Test
|
|
43
|
+
fun rejects_malformed_or_unsupported_sign_event() {
|
|
44
|
+
val unsupported = requestQuery(
|
|
45
|
+
"type=sign_event&returnType=event&compressionType=gzip&callbackUrl=${encode(callback)}"
|
|
46
|
+
)
|
|
47
|
+
val malformed =
|
|
48
|
+
"nostrsigner:%7Bbad?type=sign_event&returnType=event&callbackUrl=${encode(callback)}"
|
|
49
|
+
|
|
50
|
+
assertTrue(
|
|
51
|
+
Nip55SignEventParser.parse(unsupported, callback) is Nip55SignEventRequest.Rejected
|
|
52
|
+
)
|
|
53
|
+
assertTrue(
|
|
54
|
+
Nip55SignEventParser.parse(malformed, callback) is Nip55SignEventRequest.Rejected
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun rejects_foreign_callback() {
|
|
60
|
+
val rawUrl = requestQuery(
|
|
61
|
+
"type=sign_event&returnType=event&callbackUrl=${encode("other://nip55-callback#nip55_event=")}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assertTrue(
|
|
65
|
+
Nip55SignEventParser.parse(rawUrl, callback) is Nip55SignEventRequest.Rejected
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun requestQuery(query: String): String {
|
|
70
|
+
val event = JSONObject()
|
|
71
|
+
.put("kind", 27235)
|
|
72
|
+
.put("created_at", 1789552517L)
|
|
73
|
+
.put("tags", org.json.JSONArray())
|
|
74
|
+
.put("content", "")
|
|
75
|
+
.toString()
|
|
76
|
+
return "nostrsigner:${encode(event)}?$query"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun encode(value: String): String {
|
|
80
|
+
return value.toByteArray(Charsets.UTF_8).joinToString("") { byte ->
|
|
81
|
+
val valueInt = byte.toInt() and 0xff
|
|
82
|
+
val character = valueInt.toChar()
|
|
83
|
+
if (character.isLetterOrDigit() || character in "-._~") {
|
|
84
|
+
character.toString()
|
|
85
|
+
} else {
|
|
86
|
+
"%${valueInt.toString(16).uppercase().padStart(2, '0')}"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|