pakstr 0.20.0 → 0.21.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/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 +136 -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 +11 -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
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
package com.pakstr.app.signer.bunker
|
|
2
|
+
|
|
3
|
+
import com.pakstr.app.crypto.Bip340Verifier
|
|
4
|
+
import com.pakstr.app.crypto.Nip46ClientKey
|
|
5
|
+
import com.pakstr.app.crypto.NostrEventVerifier
|
|
6
|
+
import com.pakstr.app.signer.NostrEventBuilder
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
import java.net.URI
|
|
10
|
+
import java.security.MessageDigest
|
|
11
|
+
|
|
12
|
+
internal class Nip46Protocol(
|
|
13
|
+
private val clientKey: Nip46ClientKey,
|
|
14
|
+
private val config: BunkerConfig,
|
|
15
|
+
private val requestIdGenerator: Nip46RequestIdGenerator =
|
|
16
|
+
SecureNip46RequestIdGenerator,
|
|
17
|
+
private val clockSeconds: () -> Long = {
|
|
18
|
+
System.currentTimeMillis() / 1000L
|
|
19
|
+
},
|
|
20
|
+
private val limits: Nip46ProtocolLimits =
|
|
21
|
+
Nip46ProtocolLimits()
|
|
22
|
+
) {
|
|
23
|
+
fun prepareConnect(
|
|
24
|
+
requestedPermissions: String? = null,
|
|
25
|
+
clientMetadataJson: String? = null
|
|
26
|
+
): Nip46OutboundRequest {
|
|
27
|
+
clientMetadataJson?.let(::validateMetadata)
|
|
28
|
+
|
|
29
|
+
val pairingSecret = config.pairingSecret
|
|
30
|
+
val params = mutableListOf(config.remoteSignerPubkey)
|
|
31
|
+
if (
|
|
32
|
+
pairingSecret != null ||
|
|
33
|
+
requestedPermissions != null ||
|
|
34
|
+
clientMetadataJson != null
|
|
35
|
+
) {
|
|
36
|
+
params += pairingSecret.orEmpty()
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
requestedPermissions != null ||
|
|
40
|
+
clientMetadataJson != null
|
|
41
|
+
) {
|
|
42
|
+
params += requestedPermissions.orEmpty()
|
|
43
|
+
}
|
|
44
|
+
if (clientMetadataJson != null) {
|
|
45
|
+
params += clientMetadataJson
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return prepare(
|
|
49
|
+
Nip46Method.CONNECT,
|
|
50
|
+
params,
|
|
51
|
+
expectedConnectResult = pairingSecret
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun clearPairingSecret() {
|
|
56
|
+
config.clearPairingSecret()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fun prepareDisconnect(): Nip46OutboundRequest {
|
|
60
|
+
return prepare(
|
|
61
|
+
Nip46Method.DISCONNECT,
|
|
62
|
+
emptyList()
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fun prepareGetPublicKey(): Nip46OutboundRequest {
|
|
67
|
+
return prepare(
|
|
68
|
+
Nip46Method.GET_PUBLIC_KEY,
|
|
69
|
+
emptyList()
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fun prepareSignEvent(
|
|
74
|
+
unsignedEventJson: String,
|
|
75
|
+
expectedUserPubkey: String
|
|
76
|
+
): Nip46OutboundRequest {
|
|
77
|
+
validatePublicKey(expectedUserPubkey)
|
|
78
|
+
val normalized =
|
|
79
|
+
normalizeUnsignedEvent(unsignedEventJson)
|
|
80
|
+
val fingerprint =
|
|
81
|
+
MessageDigest.getInstance("SHA-256")
|
|
82
|
+
.digest(
|
|
83
|
+
normalized.toByteArray(Charsets.UTF_8)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return prepare(
|
|
87
|
+
Nip46Method.SIGN_EVENT,
|
|
88
|
+
listOf(normalized),
|
|
89
|
+
fingerprint,
|
|
90
|
+
expectedUserPubkey
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun prepareNip04Encrypt(
|
|
95
|
+
pubkey: String,
|
|
96
|
+
plaintext: String
|
|
97
|
+
): Nip46OutboundRequest {
|
|
98
|
+
validatePublicKey(pubkey)
|
|
99
|
+
return prepare(
|
|
100
|
+
Nip46Method.NIP04_ENCRYPT,
|
|
101
|
+
listOf(pubkey, plaintext)
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fun prepareNip04Decrypt(
|
|
106
|
+
pubkey: String,
|
|
107
|
+
ciphertext: String
|
|
108
|
+
): Nip46OutboundRequest {
|
|
109
|
+
validatePublicKey(pubkey)
|
|
110
|
+
return prepare(
|
|
111
|
+
Nip46Method.NIP04_DECRYPT,
|
|
112
|
+
listOf(pubkey, ciphertext)
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fun prepareNip44Encrypt(
|
|
117
|
+
pubkey: String,
|
|
118
|
+
plaintext: String
|
|
119
|
+
): Nip46OutboundRequest {
|
|
120
|
+
validatePublicKey(pubkey)
|
|
121
|
+
return prepare(
|
|
122
|
+
Nip46Method.NIP44_ENCRYPT,
|
|
123
|
+
listOf(pubkey, plaintext)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fun prepareNip44Decrypt(
|
|
128
|
+
pubkey: String,
|
|
129
|
+
ciphertext: String
|
|
130
|
+
): Nip46OutboundRequest {
|
|
131
|
+
validatePublicKey(pubkey)
|
|
132
|
+
return prepare(
|
|
133
|
+
Nip46Method.NIP44_DECRYPT,
|
|
134
|
+
listOf(pubkey, ciphertext)
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fun decodeResponseEvent(
|
|
139
|
+
eventJson: String
|
|
140
|
+
): Nip46VerifiedResponse {
|
|
141
|
+
requireSize(
|
|
142
|
+
eventJson,
|
|
143
|
+
limits.maximumEventBytes,
|
|
144
|
+
"NIP-46 response event is too large"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
val event = parseObject(
|
|
148
|
+
eventJson,
|
|
149
|
+
"Invalid NIP-46 response event"
|
|
150
|
+
)
|
|
151
|
+
validateEventShape(event)
|
|
152
|
+
|
|
153
|
+
if (event.getInt("kind") != EVENT_KIND) {
|
|
154
|
+
throw Nip46ProtocolException(
|
|
155
|
+
"Invalid NIP-46 response event"
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
event.getString("pubkey") !=
|
|
160
|
+
config.remoteSignerPubkey
|
|
161
|
+
) {
|
|
162
|
+
throw Nip46ProtocolException(
|
|
163
|
+
"Unexpected NIP-46 response author"
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
validateRecipientTags(
|
|
167
|
+
event.getJSONArray("tags")
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
val verified =
|
|
171
|
+
try {
|
|
172
|
+
NostrEventVerifier.verify(event)
|
|
173
|
+
} catch (_: Exception) {
|
|
174
|
+
false
|
|
175
|
+
}
|
|
176
|
+
if (!verified) {
|
|
177
|
+
throw Nip46ProtocolException(
|
|
178
|
+
"NIP-46 response verification failed"
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
val encryptedContent = event.getString("content")
|
|
183
|
+
if (
|
|
184
|
+
encryptedContent.length >
|
|
185
|
+
limits.maximumEncryptedContentLength
|
|
186
|
+
) {
|
|
187
|
+
throw Nip46ProtocolException(
|
|
188
|
+
"NIP-46 response payload is too large"
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
val decrypted =
|
|
193
|
+
try {
|
|
194
|
+
clientKey.decryptNip44(
|
|
195
|
+
encryptedContent,
|
|
196
|
+
config.remoteSignerPubkey
|
|
197
|
+
)
|
|
198
|
+
} catch (_: Exception) {
|
|
199
|
+
throw Nip46ProtocolException(
|
|
200
|
+
"Invalid NIP-46 response payload"
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
requireSize(
|
|
204
|
+
decrypted,
|
|
205
|
+
limits.maximumPayloadBytes,
|
|
206
|
+
"NIP-46 response payload is too large"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
val response =
|
|
210
|
+
try {
|
|
211
|
+
Nip46Response.parse(decrypted)
|
|
212
|
+
} catch (_: Exception) {
|
|
213
|
+
throw Nip46ProtocolException(
|
|
214
|
+
"Invalid NIP-46 response payload"
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Nip46VerifiedResponse(response)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fun correlateResponse(
|
|
222
|
+
verified: Nip46VerifiedResponse,
|
|
223
|
+
context: Nip46RequestContext
|
|
224
|
+
): Nip46ResponseOutcome {
|
|
225
|
+
val response = verified.response
|
|
226
|
+
if (response.id != context.id) {
|
|
227
|
+
throw Nip46ProtocolException(
|
|
228
|
+
"NIP-46 response does not match request"
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
response.result == AUTH_URL_RESULT &&
|
|
234
|
+
response.error != null
|
|
235
|
+
) {
|
|
236
|
+
validateAuthUrl(response.error)
|
|
237
|
+
context.beginResponse(authChallenge = true)
|
|
238
|
+
return Nip46ResponseOutcome.AuthChallenge(
|
|
239
|
+
response.error
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (response.error != null) {
|
|
244
|
+
context.beginResponse(authChallenge = false)
|
|
245
|
+
return Nip46ResponseOutcome.RemoteError()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
val result =
|
|
249
|
+
response.result
|
|
250
|
+
?: throw Nip46ProtocolException(
|
|
251
|
+
"Invalid NIP-46 response"
|
|
252
|
+
)
|
|
253
|
+
validateResult(
|
|
254
|
+
context,
|
|
255
|
+
result
|
|
256
|
+
)
|
|
257
|
+
context.beginResponse(authChallenge = false)
|
|
258
|
+
|
|
259
|
+
return Nip46ResponseOutcome.Success(result)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun validateAuthUrl(value: String) {
|
|
263
|
+
if (value.length > limits.maximumAuthUrlLength) {
|
|
264
|
+
throw Nip46ProtocolException(
|
|
265
|
+
"Invalid NIP-46 auth URL"
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
val uri =
|
|
269
|
+
try {
|
|
270
|
+
URI(value)
|
|
271
|
+
} catch (_: Exception) {
|
|
272
|
+
throw Nip46ProtocolException(
|
|
273
|
+
"Invalid NIP-46 auth URL"
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
if (
|
|
277
|
+
uri.scheme != "https" ||
|
|
278
|
+
uri.host.isNullOrBlank() ||
|
|
279
|
+
uri.rawUserInfo != null ||
|
|
280
|
+
uri.rawFragment != null
|
|
281
|
+
) {
|
|
282
|
+
throw Nip46ProtocolException(
|
|
283
|
+
"Invalid NIP-46 auth URL"
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private fun prepare(
|
|
289
|
+
method: Nip46Method,
|
|
290
|
+
params: List<String>,
|
|
291
|
+
fingerprint: ByteArray? = null,
|
|
292
|
+
expectedUserPubkey: String? = null,
|
|
293
|
+
expectedConnectResult: String? = null
|
|
294
|
+
): Nip46OutboundRequest {
|
|
295
|
+
val id = requestIdGenerator.nextId()
|
|
296
|
+
validateNip46RequestId(id)
|
|
297
|
+
val request = Nip46Request(id, method, params)
|
|
298
|
+
val requestJson = request.toJson()
|
|
299
|
+
requireSize(
|
|
300
|
+
requestJson,
|
|
301
|
+
limits.maximumPayloadBytes,
|
|
302
|
+
"NIP-46 request payload is too large"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
val encrypted =
|
|
306
|
+
try {
|
|
307
|
+
clientKey.encryptNip44(
|
|
308
|
+
requestJson,
|
|
309
|
+
config.remoteSignerPubkey
|
|
310
|
+
)
|
|
311
|
+
} catch (_: Exception) {
|
|
312
|
+
throw Nip46ProtocolException(
|
|
313
|
+
"Unable to encrypt NIP-46 request"
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
encrypted.length >
|
|
318
|
+
limits.maximumEncryptedContentLength
|
|
319
|
+
) {
|
|
320
|
+
throw Nip46ProtocolException(
|
|
321
|
+
"NIP-46 request payload is too large"
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
val event =
|
|
326
|
+
JSONObject()
|
|
327
|
+
.put("pubkey", clientKey.publicKeyHex)
|
|
328
|
+
.put("created_at", clockSeconds())
|
|
329
|
+
.put("kind", EVENT_KIND)
|
|
330
|
+
.put(
|
|
331
|
+
"tags",
|
|
332
|
+
JSONArray()
|
|
333
|
+
.put(
|
|
334
|
+
JSONArray()
|
|
335
|
+
.put("p")
|
|
336
|
+
.put(config.remoteSignerPubkey)
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
.put("content", encrypted)
|
|
340
|
+
NostrEventBuilder.addId(event)
|
|
341
|
+
|
|
342
|
+
val eventId = hexToBytes(event.getString("id"))
|
|
343
|
+
val signature =
|
|
344
|
+
try {
|
|
345
|
+
clientKey.sign(eventId)
|
|
346
|
+
} finally {
|
|
347
|
+
eventId.fill(0)
|
|
348
|
+
}
|
|
349
|
+
event.put("sig", bytesToHex(signature))
|
|
350
|
+
signature.fill(0)
|
|
351
|
+
|
|
352
|
+
if (!NostrEventVerifier.verify(event)) {
|
|
353
|
+
throw Nip46ProtocolException(
|
|
354
|
+
"Unable to verify NIP-46 request event"
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
val eventJson = event.toString()
|
|
359
|
+
requireSize(
|
|
360
|
+
eventJson,
|
|
361
|
+
limits.maximumEventBytes,
|
|
362
|
+
"NIP-46 request event is too large"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return Nip46OutboundRequest(
|
|
366
|
+
eventJson,
|
|
367
|
+
Nip46RequestContext(
|
|
368
|
+
id = id,
|
|
369
|
+
method = method,
|
|
370
|
+
expectedUserPubkey = expectedUserPubkey,
|
|
371
|
+
expectedConnectResult = expectedConnectResult,
|
|
372
|
+
unsignedEventFingerprint = fingerprint
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private fun validateResult(
|
|
378
|
+
context: Nip46RequestContext,
|
|
379
|
+
result: String
|
|
380
|
+
) {
|
|
381
|
+
when (context.method) {
|
|
382
|
+
Nip46Method.CONNECT -> {
|
|
383
|
+
if (
|
|
384
|
+
result != "ack" &&
|
|
385
|
+
result != context.expectedConnectResult
|
|
386
|
+
) {
|
|
387
|
+
throw Nip46ProtocolException(
|
|
388
|
+
"Invalid NIP-46 connect response"
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
Nip46Method.DISCONNECT -> Unit
|
|
393
|
+
Nip46Method.GET_PUBLIC_KEY ->
|
|
394
|
+
validatePublicKey(result)
|
|
395
|
+
Nip46Method.SIGN_EVENT ->
|
|
396
|
+
validateSignedEvent(result, context)
|
|
397
|
+
Nip46Method.NIP04_ENCRYPT,
|
|
398
|
+
Nip46Method.NIP44_ENCRYPT -> {
|
|
399
|
+
if (result.isEmpty()) {
|
|
400
|
+
throw Nip46ProtocolException(
|
|
401
|
+
"Invalid NIP-46 encryption response"
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
Nip46Method.NIP04_DECRYPT,
|
|
406
|
+
Nip46Method.NIP44_DECRYPT -> Unit
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun validateSignedEvent(
|
|
411
|
+
value: String,
|
|
412
|
+
context: Nip46RequestContext
|
|
413
|
+
) {
|
|
414
|
+
val event = parseObject(
|
|
415
|
+
value,
|
|
416
|
+
"Invalid signed event response"
|
|
417
|
+
)
|
|
418
|
+
validateEventShape(event)
|
|
419
|
+
val verified =
|
|
420
|
+
try {
|
|
421
|
+
NostrEventVerifier.verify(event)
|
|
422
|
+
} catch (_: Exception) {
|
|
423
|
+
false
|
|
424
|
+
}
|
|
425
|
+
if (!verified) {
|
|
426
|
+
throw Nip46ProtocolException(
|
|
427
|
+
"Invalid signed event response"
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
if (
|
|
431
|
+
event.getString("pubkey") !=
|
|
432
|
+
context.expectedUserPubkey
|
|
433
|
+
) {
|
|
434
|
+
throw Nip46ProtocolException(
|
|
435
|
+
"Unexpected signed event author"
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
val normalized = normalizeSignedEvent(event)
|
|
440
|
+
val fingerprint =
|
|
441
|
+
MessageDigest.getInstance("SHA-256")
|
|
442
|
+
.digest(
|
|
443
|
+
normalized.toByteArray(Charsets.UTF_8)
|
|
444
|
+
)
|
|
445
|
+
try {
|
|
446
|
+
if (!context.matchesUnsignedEvent(fingerprint)) {
|
|
447
|
+
throw Nip46ProtocolException(
|
|
448
|
+
"Signed event does not match request"
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
} finally {
|
|
452
|
+
fingerprint.fill(0)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private fun normalizeUnsignedEvent(value: String): String {
|
|
457
|
+
val json = parseObject(
|
|
458
|
+
value,
|
|
459
|
+
"Invalid unsigned event"
|
|
460
|
+
)
|
|
461
|
+
val forbidden = listOf("id", "pubkey", "sig")
|
|
462
|
+
if (forbidden.any(json::has)) {
|
|
463
|
+
throw Nip46ProtocolException(
|
|
464
|
+
"Unsigned event contains signer-owned fields"
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
val allowed = setOf(
|
|
468
|
+
"kind",
|
|
469
|
+
"created_at",
|
|
470
|
+
"tags",
|
|
471
|
+
"content"
|
|
472
|
+
)
|
|
473
|
+
if (json.keys().asSequence().any { it !in allowed }) {
|
|
474
|
+
throw Nip46ProtocolException(
|
|
475
|
+
"Unsigned event contains unsupported fields"
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return normalizedUnsignedFields(json).toString()
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private fun normalizeSignedEvent(
|
|
483
|
+
event: JSONObject
|
|
484
|
+
): String {
|
|
485
|
+
return normalizedUnsignedFields(event).toString()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private fun normalizedUnsignedFields(
|
|
489
|
+
json: JSONObject
|
|
490
|
+
): JSONObject {
|
|
491
|
+
val kind = requireKind(json)
|
|
492
|
+
requireIntegral(json, "created_at")
|
|
493
|
+
val tags = requireTags(json)
|
|
494
|
+
requireString(json, "content")
|
|
495
|
+
|
|
496
|
+
return JSONObject()
|
|
497
|
+
.put("kind", kind)
|
|
498
|
+
.put("created_at", json.getLong("created_at"))
|
|
499
|
+
.put("tags", tags)
|
|
500
|
+
.put("content", json.getString("content"))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private fun validateEventShape(event: JSONObject) {
|
|
504
|
+
requireString(event, "id")
|
|
505
|
+
requireString(event, "pubkey")
|
|
506
|
+
requireIntegral(event, "created_at")
|
|
507
|
+
requireKind(event)
|
|
508
|
+
requireTags(event)
|
|
509
|
+
requireString(event, "content")
|
|
510
|
+
requireString(event, "sig")
|
|
511
|
+
validatePublicKey(event.getString("pubkey"))
|
|
512
|
+
if (!event.getString("id").matches(Regex("[0-9a-f]{64}"))) {
|
|
513
|
+
throw Nip46ProtocolException(
|
|
514
|
+
"Invalid NIP-46 event ID"
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
if (!event.getString("sig").matches(Regex("[0-9a-f]{128}"))) {
|
|
518
|
+
throw Nip46ProtocolException(
|
|
519
|
+
"Invalid NIP-46 event signature"
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private fun validateRecipientTags(tags: JSONArray) {
|
|
525
|
+
var recipients = 0
|
|
526
|
+
for (index in 0 until tags.length()) {
|
|
527
|
+
val tag = tags.getJSONArray(index)
|
|
528
|
+
if (
|
|
529
|
+
tag.length() > 0 &&
|
|
530
|
+
tag.getString(0) == "p"
|
|
531
|
+
) {
|
|
532
|
+
recipients++
|
|
533
|
+
if (
|
|
534
|
+
tag.length() < 2 ||
|
|
535
|
+
tag.getString(1) != clientKey.publicKeyHex
|
|
536
|
+
) {
|
|
537
|
+
throw Nip46ProtocolException(
|
|
538
|
+
"Unexpected NIP-46 response recipient"
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (recipients != 1) {
|
|
544
|
+
throw Nip46ProtocolException(
|
|
545
|
+
"Invalid NIP-46 response recipient"
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private fun validateMetadata(value: String) {
|
|
551
|
+
requireSize(
|
|
552
|
+
value,
|
|
553
|
+
limits.maximumPayloadBytes,
|
|
554
|
+
"NIP-46 client metadata is too large"
|
|
555
|
+
)
|
|
556
|
+
parseObject(
|
|
557
|
+
value,
|
|
558
|
+
"Invalid NIP-46 client metadata"
|
|
559
|
+
)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private fun validatePublicKey(value: String) {
|
|
563
|
+
if (
|
|
564
|
+
!value.matches(Regex("[0-9a-f]{64}")) ||
|
|
565
|
+
!Bip340Verifier.isValidXOnlyPubkey(value)
|
|
566
|
+
) {
|
|
567
|
+
throw Nip46ProtocolException(
|
|
568
|
+
"Invalid Nostr public key"
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private fun requireTags(json: JSONObject): JSONArray {
|
|
574
|
+
val tags = json.optJSONArray("tags")
|
|
575
|
+
?: throw Nip46ProtocolException(
|
|
576
|
+
"Invalid Nostr event"
|
|
577
|
+
)
|
|
578
|
+
for (index in 0 until tags.length()) {
|
|
579
|
+
val tag = tags.optJSONArray(index)
|
|
580
|
+
?: throw Nip46ProtocolException(
|
|
581
|
+
"Invalid Nostr event"
|
|
582
|
+
)
|
|
583
|
+
if (tag.length() == 0) {
|
|
584
|
+
throw Nip46ProtocolException(
|
|
585
|
+
"Invalid Nostr event"
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
for (valueIndex in 0 until tag.length()) {
|
|
589
|
+
if (tag.get(valueIndex) !is String) {
|
|
590
|
+
throw Nip46ProtocolException(
|
|
591
|
+
"Invalid Nostr event"
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return tags
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private fun requireString(
|
|
600
|
+
json: JSONObject,
|
|
601
|
+
key: String
|
|
602
|
+
) {
|
|
603
|
+
if (
|
|
604
|
+
!json.has(key) ||
|
|
605
|
+
json.isNull(key) ||
|
|
606
|
+
json.get(key) !is String
|
|
607
|
+
) {
|
|
608
|
+
throw Nip46ProtocolException(
|
|
609
|
+
"Invalid Nostr event"
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private fun requireKind(json: JSONObject): Int {
|
|
615
|
+
requireIntegral(json, "kind")
|
|
616
|
+
val kind = json.getLong("kind")
|
|
617
|
+
if (kind !in 0L..65535L) {
|
|
618
|
+
throw Nip46ProtocolException(
|
|
619
|
+
"Invalid Nostr event kind"
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
return kind.toInt()
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private fun requireIntegral(
|
|
626
|
+
json: JSONObject,
|
|
627
|
+
key: String
|
|
628
|
+
) {
|
|
629
|
+
if (!json.has(key) || json.isNull(key)) {
|
|
630
|
+
throw Nip46ProtocolException(
|
|
631
|
+
"Invalid Nostr event"
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
val value = json.get(key)
|
|
635
|
+
if (
|
|
636
|
+
value !is Byte &&
|
|
637
|
+
value !is Short &&
|
|
638
|
+
value !is Int &&
|
|
639
|
+
value !is Long
|
|
640
|
+
) {
|
|
641
|
+
throw Nip46ProtocolException(
|
|
642
|
+
"Invalid Nostr event"
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private fun parseObject(
|
|
648
|
+
value: String,
|
|
649
|
+
message: String
|
|
650
|
+
): JSONObject {
|
|
651
|
+
return try {
|
|
652
|
+
JSONObject(value)
|
|
653
|
+
} catch (_: Exception) {
|
|
654
|
+
throw Nip46ProtocolException(message)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private fun requireSize(
|
|
659
|
+
value: String,
|
|
660
|
+
maximumBytes: Int,
|
|
661
|
+
message: String
|
|
662
|
+
) {
|
|
663
|
+
if (
|
|
664
|
+
value.toByteArray(Charsets.UTF_8).size >
|
|
665
|
+
maximumBytes
|
|
666
|
+
) {
|
|
667
|
+
throw Nip46ProtocolException(message)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private fun hexToBytes(value: String): ByteArray {
|
|
672
|
+
if (!value.matches(Regex("[0-9a-f]{64}"))) {
|
|
673
|
+
throw Nip46ProtocolException(
|
|
674
|
+
"Invalid NIP-46 event ID"
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
return ByteArray(32) { index ->
|
|
678
|
+
value.substring(
|
|
679
|
+
index * 2,
|
|
680
|
+
index * 2 + 2
|
|
681
|
+
).toInt(16).toByte()
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private fun bytesToHex(value: ByteArray): String {
|
|
686
|
+
return value.joinToString("") { byte ->
|
|
687
|
+
"%02x".format(byte.toInt() and 0xff)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
companion object {
|
|
692
|
+
private const val EVENT_KIND = 24133
|
|
693
|
+
private const val AUTH_URL_RESULT = "auth_url"
|
|
694
|
+
}
|
|
695
|
+
}
|