pakstr 0.19.3 → 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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/android-template/app/build.gradle.kts +4 -0
  3. package/android-template/app/src/androidTest/java/com/pakstr/app/signer/SignerManagerTest.kt +273 -0
  4. package/android-template/app/src/main/assets/www/index.html +0 -6
  5. package/android-template/app/src/main/java/com/pakstr/app/LocalServer.kt +20 -4
  6. package/android-template/app/src/main/java/com/pakstr/app/MainActivity.kt +136 -12
  7. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Hash.kt +56 -0
  8. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Signer.kt +104 -0
  9. package/android-template/app/src/main/java/com/pakstr/app/crypto/Bip340Verifier.kt +20 -170
  10. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip44Crypto.kt +513 -0
  11. package/android-template/app/src/main/java/com/pakstr/app/crypto/Nip46ClientKey.kt +140 -0
  12. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventHasher.kt +3 -28
  13. package/android-template/app/src/main/java/com/pakstr/app/crypto/NostrEventSerializer.kt +74 -0
  14. package/android-template/app/src/main/java/com/pakstr/app/crypto/Secp256k1.kt +100 -0
  15. package/android-template/app/src/main/java/com/pakstr/app/signer/BunkerSigner.kt +32 -7
  16. package/android-template/app/src/main/java/com/pakstr/app/signer/NostrBridge.kt +36 -9
  17. package/android-template/app/src/main/java/com/pakstr/app/signer/SignerManager.kt +240 -163
  18. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConfig.kt +175 -0
  19. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/BunkerConnectionStore.kt +144 -0
  20. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Client.kt +79 -0
  21. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Models.kt +319 -0
  22. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingClient.kt +159 -0
  23. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingQrCode.kt +35 -0
  24. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46PairingUri.kt +104 -0
  25. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46Protocol.kt +695 -0
  26. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/Nip46RelayTransport.kt +489 -0
  27. package/android-template/app/src/main/java/com/pakstr/app/signer/bunker/RelayNip46Client.kt +403 -0
  28. package/android-template/app/src/main/java/com/pakstr/app/utils/BunkerPairingDialog.kt +82 -0
  29. package/android-template/app/src/main/java/com/pakstr/app/utils/SignerPickerBottomSheet.kt +78 -17
  30. package/android-template/app/src/main/res/layout/activity_layout.xml +11 -0
  31. package/android-template/app/src/main/res/layout/dialog_bunker_pairing.xml +75 -0
  32. package/android-template/app/src/main/res/layout/dialog_picker.xml +60 -31
  33. package/android-template/app/src/main/res/values/strings.xml +27 -0
  34. package/android-template/app/src/test/java/com/pakstr/app/LocalServerQueryKeepAliveTest.kt +118 -0
  35. package/android-template/app/src/test/java/com/pakstr/app/crypto/Bip340SignerTest.kt +133 -0
  36. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip44CryptoTest.kt +199 -0
  37. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyProtocolTest.kt +82 -0
  38. package/android-template/app/src/test/java/com/pakstr/app/crypto/Nip46ClientKeyTest.kt +52 -0
  39. package/android-template/app/src/test/java/com/pakstr/app/crypto/NostrEventSerializerTest.kt +42 -0
  40. package/android-template/app/src/test/java/com/pakstr/app/signer/NostrBridgeErrorTest.kt +32 -0
  41. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/BunkerConfigTest.kt +120 -0
  42. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ModelsTest.kt +111 -0
  43. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingClientTest.kt +271 -0
  44. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingQrCodeTest.kt +54 -0
  45. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46PairingUriTest.kt +80 -0
  46. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46ProtocolTest.kt +578 -0
  47. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/Nip46RelayTransportTest.kt +508 -0
  48. package/android-template/app/src/test/java/com/pakstr/app/signer/bunker/RelayNip46ClientTest.kt +678 -0
  49. package/android-template/gradle/libs.versions.toml +8 -0
  50. package/dist/cli.js +8 -0
  51. package/dist/commands/init.js +16 -0
  52. package/dist/commands/nsite.js +38 -0
  53. package/dist/commands/publish.js +50 -2
  54. package/dist/commands/run.js +5 -5
  55. package/dist/core/blossom.js +2 -2
  56. package/dist/core/nsite.js +176 -0
  57. package/dist/core/pakstrConfig.js +85 -6
  58. package/package.json +1 -1
@@ -1,16 +1,15 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
- <LinearLayout xmlns:app="http://android.com"
3
- xmlns:android="http://schemas.android.com/apk/res/android"
2
+ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:app="http://schemas.android.com/apk/res-auto"
4
4
  android:layout_width="match_parent"
5
5
  android:layout_height="wrap_content"
6
6
  android:background="@drawable/bg_bottom_sheet"
7
7
  android:orientation="vertical"
8
- android:paddingBottom="32dp"
9
- android:paddingEnd="24dp"
10
8
  android:paddingStart="24dp"
11
- android:paddingTop="16dp">
9
+ android:paddingTop="16dp"
10
+ android:paddingEnd="24dp"
11
+ android:paddingBottom="32dp">
12
12
 
13
- <!-- Handle Indicator (Modern Pill) -->
14
13
  <View
15
14
  android:layout_width="48dp"
16
15
  android:layout_height="5dp"
@@ -18,34 +17,30 @@
18
17
  android:layout_marginBottom="28dp"
19
18
  android:background="#E5E7EB" />
20
19
 
21
- <!-- Header Title -->
22
20
  <TextView
23
21
  android:layout_width="wrap_content"
24
22
  android:layout_height="wrap_content"
25
- android:text="Welcome"
23
+ android:fontFamily="sans-serif-medium"
24
+ android:text="@string/signer_picker_title"
26
25
  android:textColor="#1F2937"
27
26
  android:textSize="24sp"
28
- android:fontFamily="sans-serif-medium"
29
27
  android:textStyle="bold" />
30
28
 
31
- <!-- Header Subtitle -->
32
29
  <TextView
33
30
  android:layout_width="wrap_content"
34
31
  android:layout_height="wrap_content"
35
- android:layout_marginBottom="24dp"
36
32
  android:layout_marginTop="6dp"
37
- android:text="Please select one of the options to continue:"
33
+ android:layout_marginBottom="24dp"
34
+ android:text="@string/signer_picker_subtitle"
38
35
  android:textColor="#4B5563"
39
36
  android:textSize="15sp" />
40
37
 
41
- <!-- RadioGroup za selekciju opcija -->
42
38
  <RadioGroup
43
39
  android:id="@+id/options_radio_group"
44
40
  android:layout_width="match_parent"
45
41
  android:layout_height="wrap_content"
46
42
  android:orientation="vertical">
47
43
 
48
- <!-- OPCIJA 1: Launch Web Application -->
49
44
  <RadioButton
50
45
  android:id="@+id/radio_option_one"
51
46
  android:layout_width="match_parent"
@@ -54,48 +49,82 @@
54
49
  android:background="@drawable/selector_radio_card"
55
50
  android:button="@null"
56
51
  android:checked="true"
57
- android:drawableEnd="@drawable/selector_radio_checkmark"
58
52
  android:drawableStart="@drawable/ic_menu_browser"
53
+ android:drawableEnd="@drawable/selector_radio_checkmark"
59
54
  android:drawablePadding="16dp"
60
55
  android:drawableTint="@drawable/selector_radio_tint"
61
- android:paddingEnd="20dp"
56
+ android:fontFamily="sans-serif-medium"
62
57
  android:paddingStart="20dp"
63
- android:text="Continue with Amber"
58
+ android:paddingEnd="20dp"
59
+ android:text="@string/signer_picker_amber"
64
60
  android:textColor="#1F2937"
65
- android:textSize="16sp"
66
- android:fontFamily="sans-serif-medium" />
61
+ android:textSize="16sp" />
67
62
 
68
- <!-- OPCIJA 2: Bunker -->
69
63
  <RadioButton
70
64
  android:id="@+id/radio_option_two"
71
65
  android:layout_width="match_parent"
72
66
  android:layout_height="64dp"
73
- android:layout_marginBottom="24dp"
67
+ android:layout_marginBottom="12dp"
74
68
  android:background="@drawable/selector_radio_card"
75
69
  android:button="@null"
76
- android:drawableEnd="@drawable/selector_radio_checkmark"
77
70
  android:drawableStart="@drawable/ic_menu_browser"
71
+ android:drawableEnd="@drawable/selector_radio_checkmark"
78
72
  android:drawablePadding="16dp"
79
73
  android:drawableTint="@drawable/selector_radio_tint"
80
- android:paddingEnd="20dp"
74
+ android:fontFamily="sans-serif-medium"
81
75
  android:paddingStart="20dp"
82
- android:text="Connect to remote signer (Bunker)"
76
+ android:paddingEnd="20dp"
77
+ android:text="@string/signer_picker_bunker"
83
78
  android:textColor="#1F2937"
84
- android:textSize="16sp"
85
- android:fontFamily="sans-serif-medium" />
86
-
79
+ android:textSize="16sp" />
87
80
  </RadioGroup>
88
81
 
89
- <!-- Glavno akciono dugme na dnu -->
82
+ <com.google.android.material.textfield.TextInputLayout
83
+ android:id="@+id/bunker_uri_container"
84
+ style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
85
+ android:layout_width="match_parent"
86
+ android:layout_height="wrap_content"
87
+ android:layout_marginBottom="12dp"
88
+ android:hint="@string/signer_picker_bunker_hint"
89
+ android:visibility="gone">
90
+
91
+ <com.google.android.material.textfield.TextInputEditText
92
+ android:id="@+id/bunker_uri"
93
+ android:layout_width="match_parent"
94
+ android:layout_height="wrap_content"
95
+ android:inputType="textUri"
96
+ android:maxLines="4" />
97
+ </com.google.android.material.textfield.TextInputLayout>
98
+
99
+ <com.google.android.material.button.MaterialButton
100
+ android:id="@+id/button_share_pairing"
101
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
102
+ android:layout_width="match_parent"
103
+ android:layout_height="48dp"
104
+ android:layout_marginBottom="12dp"
105
+ android:text="@string/signer_picker_share_pairing"
106
+ android:textAllCaps="false"
107
+ android:visibility="gone" />
108
+
109
+ <com.google.android.material.button.MaterialButton
110
+ android:id="@+id/button_logout_bunker"
111
+ style="@style/Widget.MaterialComponents.Button.TextButton"
112
+ android:layout_width="match_parent"
113
+ android:layout_height="48dp"
114
+ android:layout_marginBottom="12dp"
115
+ android:text="@string/signer_picker_logout_bunker"
116
+ android:textAllCaps="false"
117
+ android:visibility="gone" />
118
+
90
119
  <com.google.android.material.button.MaterialButton
91
120
  android:id="@+id/button_confirm"
92
121
  android:layout_width="match_parent"
93
122
  android:layout_height="56dp"
94
- android:text="Continue"
123
+ android:backgroundTint="#2563EB"
124
+ android:text="@string/signer_picker_continue"
95
125
  android:textAllCaps="false"
96
- android:textSize="16sp"
97
126
  android:textColor="@android:color/white"
98
- android:backgroundTint="#2563EB"
127
+ android:textSize="16sp"
99
128
  app:cornerRadius="16dp" />
100
129
 
101
130
  </LinearLayout>
@@ -44,4 +44,31 @@
44
44
 
45
45
  <string name="developer_tools">Open Developer Tools</string>
46
46
  <string name="diagnostics_mode_enabled">Diagnostics mode enabled</string>
47
+
48
+ <string name="signer_settings">Signer settings</string>
49
+ <string name="signer_picker_title">Choose a signer</string>
50
+ <string name="signer_picker_subtitle">Use Amber or connect to a NIP-46 remote signer.</string>
51
+ <string name="signer_picker_amber">Continue with Amber</string>
52
+ <string name="signer_picker_bunker">Connect to remote signer (Bunker)</string>
53
+ <string name="signer_picker_bunker_hint">Paste a bunker:// connection</string>
54
+ <string name="signer_picker_share_pairing">Share a nostrconnect:// pairing</string>
55
+ <string name="signer_picker_logout_bunker">Disconnect remote signer</string>
56
+ <string name="signer_picker_continue">Continue</string>
57
+ <string name="signer_picker_invalid_bunker">Enter a valid bunker:// connection.</string>
58
+ <string name="signer_picker_pairing_relays">Pairing relays (wss://, one per line)</string>
59
+ <string name="signer_picker_pairing_relays_default">wss://nos.lol\nwss://relay.primal.net\nwss://offchain.pub</string>
60
+ <string name="signer_picker_pairing_share_title">Pair with remote signer</string>
61
+ <string name="signer_picker_pairing_qr_title">Scan with Amber</string>
62
+ <string name="signer_picker_pairing_qr_instructions">In Amber, choose “Scan a QR code”, then scan this code. You can also copy or share the exact pairing URI.</string>
63
+ <string name="signer_picker_pairing_qr_content_description">QR code containing the nostrconnect pairing URI</string>
64
+ <string name="signer_picker_pairing_copy">Copy pairing URI</string>
65
+ <string name="signer_picker_pairing_share">Share pairing URI</string>
66
+ <string name="signer_picker_pairing_clip_label">Nostrconnect pairing URI</string>
67
+ <string name="signer_picker_pairing_copied">Pairing URI copied.</string>
68
+ <string name="signer_picker_pairing_waiting">Waiting for the remote signer to connect…</string>
69
+ <string name="signer_picker_pairing_connected">Remote signer connected.</string>
70
+ <string name="signer_picker_pairing_failed">Remote signer pairing timed out.</string>
71
+ <string name="signer_picker_connection_failed">Unable to configure the remote signer.</string>
72
+ <string name="signer_picker_disconnected">Remote signer disconnected.</string>
73
+ <string name="signer_auth_browser_unavailable">No browser is available for signer authorization.</string>
47
74
  </resources>
@@ -0,0 +1,118 @@
1
+ package com.pakstr.app
2
+
3
+ import fi.iki.elonen.NanoHTTPD
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Assert.assertTrue
8
+ import org.junit.Test
9
+ import java.io.BufferedInputStream
10
+ import java.io.ByteArrayOutputStream
11
+ import java.io.InputStream
12
+ import java.net.Socket
13
+ import java.nio.charset.StandardCharsets
14
+ import java.util.Collections
15
+
16
+ class LocalServerQueryKeepAliveTest {
17
+
18
+ @Test
19
+ fun queryDoesNotLeakToNextRequestOnKeepAliveSession() {
20
+ val requests = Collections.synchronizedList(mutableListOf<CapturedRequest>())
21
+ val server = object : NanoHTTPD(0) {
22
+ override fun serve(session: IHTTPSession): Response {
23
+ val query = currentRawQueryString(session)
24
+ requests += CapturedRequest(
25
+ uri = session.uri,
26
+ rawQuery = session.queryParameterString,
27
+ query = query,
28
+ localUrl = localRequestUrl(session.uri, query).toString()
29
+ )
30
+ return newFixedLengthResponse("ok")
31
+ }
32
+ }
33
+ val encodedEvent = "%7B%22content%22%3A%22${"event".repeat(512)}%22%7D"
34
+ val callbackQuery = "nip55_event=$encodedEvent"
35
+
36
+ server.start()
37
+ try {
38
+ Socket("127.0.0.1", server.listeningPort).use { socket ->
39
+ socket.soTimeout = 5000
40
+ val input = BufferedInputStream(socket.getInputStream())
41
+ val output = socket.getOutputStream()
42
+
43
+ output.write(
44
+ (
45
+ "GET /?$callbackQuery HTTP/1.1\r\n" +
46
+ "Host: 127.0.0.1\r\n\r\n"
47
+ ).toByteArray(StandardCharsets.ISO_8859_1)
48
+ )
49
+ output.flush()
50
+ readResponse(input)
51
+
52
+ output.write(
53
+ (
54
+ "POST /api/auth/login HTTP/1.1\r\n" +
55
+ "Host: 127.0.0.1\r\n" +
56
+ "Content-Length: 0\r\n\r\n"
57
+ ).toByteArray(StandardCharsets.ISO_8859_1)
58
+ )
59
+ output.flush()
60
+ readResponse(input)
61
+ }
62
+ } finally {
63
+ server.stop()
64
+ }
65
+
66
+ assertEquals(2, requests.size)
67
+ assertEquals(callbackQuery, requests[0].query)
68
+
69
+ val loginRequest = requests[1]
70
+ assertEquals("/api/auth/login", loginRequest.uri)
71
+ assertEquals(callbackQuery, loginRequest.rawQuery)
72
+ assertNull(loginRequest.query)
73
+ assertEquals(
74
+ "http://127.0.0.1:${Config.PORT}/api/auth/login",
75
+ loginRequest.localUrl
76
+ )
77
+ assertFalse(loginRequest.localUrl.contains("nip55_event"))
78
+ assertFalse(loginRequest.localUrl.contains(encodedEvent))
79
+ }
80
+
81
+ private fun readResponse(input: InputStream) {
82
+ val headers = mutableMapOf<String, String>()
83
+ assertTrue(readLine(input).startsWith("HTTP/1.1 200"))
84
+ while (true) {
85
+ val line = readLine(input)
86
+ if (line.isEmpty()) break
87
+ val separator = line.indexOf(':')
88
+ assertTrue(separator > 0)
89
+ headers[line.substring(0, separator).lowercase()] =
90
+ line.substring(separator + 1).trim()
91
+ }
92
+
93
+ val contentLength = headers["content-length"]?.toInt() ?: 0
94
+ repeat(contentLength) {
95
+ assertTrue(input.read() != -1)
96
+ }
97
+ }
98
+
99
+ private fun readLine(input: InputStream): String {
100
+ val bytes = ByteArrayOutputStream()
101
+ while (true) {
102
+ val value = input.read()
103
+ assertTrue(value != -1)
104
+ if (value == '\r'.code) {
105
+ assertEquals('\n'.code, input.read())
106
+ return bytes.toString(StandardCharsets.ISO_8859_1.name())
107
+ }
108
+ bytes.write(value)
109
+ }
110
+ }
111
+
112
+ private data class CapturedRequest(
113
+ val uri: String,
114
+ val rawQuery: String?,
115
+ val query: String?,
116
+ val localUrl: String
117
+ )
118
+ }
@@ -0,0 +1,133 @@
1
+ package com.pakstr.app.crypto
2
+
3
+ import org.junit.Assert.assertArrayEquals
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertThrows
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class Bip340SignerTest {
10
+
11
+ @Test
12
+ fun matches_official_signing_vectors() {
13
+ VECTORS.forEach { vector ->
14
+ val privateKey = Bip340Hash.hexToBytes(vector.privateKey)
15
+ val message = Bip340Hash.hexToBytes(vector.message)
16
+ val auxiliaryRandom =
17
+ Bip340Hash.hexToBytes(vector.auxiliaryRandom)
18
+
19
+ val signature =
20
+ Bip340Signer.sign(
21
+ privateKey,
22
+ message,
23
+ auxiliaryRandom
24
+ )
25
+
26
+ assertEquals(
27
+ vector.publicKey.lowercase(),
28
+ Bip340Hash.bytesToHex(
29
+ Secp256k1.xOnlyPublicKey(privateKey)
30
+ )
31
+ )
32
+ assertEquals(
33
+ vector.signature.lowercase(),
34
+ Bip340Hash.bytesToHex(signature)
35
+ )
36
+ assertTrue(
37
+ Bip340Verifier.verify(
38
+ vector.publicKey,
39
+ vector.message,
40
+ vector.signature
41
+ )
42
+ )
43
+ }
44
+ }
45
+
46
+ @Test
47
+ fun rejects_invalid_signing_inputs() {
48
+ val validKey = ByteArray(32).also { it[31] = 1 }
49
+
50
+ assertThrows(IllegalArgumentException::class.java) {
51
+ Bip340Signer.sign(
52
+ ByteArray(32),
53
+ ByteArray(32),
54
+ ByteArray(32)
55
+ )
56
+ }
57
+ assertThrows(IllegalArgumentException::class.java) {
58
+ Bip340Signer.sign(
59
+ validKey,
60
+ ByteArray(31),
61
+ ByteArray(32)
62
+ )
63
+ }
64
+ assertThrows(IllegalArgumentException::class.java) {
65
+ Bip340Signer.sign(
66
+ validKey,
67
+ ByteArray(32),
68
+ ByteArray(31)
69
+ )
70
+ }
71
+ }
72
+
73
+ @Test
74
+ fun x_only_public_key_is_defensive_copy() {
75
+ val key =
76
+ Nip46ClientKey.fromPrivateKey(
77
+ ByteArray(32).also { it[31] = 3 }
78
+ )
79
+
80
+ key.use {
81
+ val first = key.publicKey
82
+ first.fill(0)
83
+
84
+ assertArrayEquals(
85
+ Bip340Hash.hexToBytes(VECTORS.first().publicKey),
86
+ key.publicKey
87
+ )
88
+ }
89
+ }
90
+
91
+ private data class Vector(
92
+ val privateKey: String,
93
+ val publicKey: String,
94
+ val auxiliaryRandom: String,
95
+ val message: String,
96
+ val signature: String
97
+ )
98
+
99
+ companion object {
100
+ // https://github.com/bitcoin/bips/blob/master/bip-0340/test-vectors.csv
101
+ private val VECTORS =
102
+ listOf(
103
+ Vector(
104
+ "0000000000000000000000000000000000000000000000000000000000000003",
105
+ "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9",
106
+ "0000000000000000000000000000000000000000000000000000000000000000",
107
+ "0000000000000000000000000000000000000000000000000000000000000000",
108
+ "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"
109
+ ),
110
+ Vector(
111
+ "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF",
112
+ "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659",
113
+ "0000000000000000000000000000000000000000000000000000000000000001",
114
+ "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89",
115
+ "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A"
116
+ ),
117
+ Vector(
118
+ "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9",
119
+ "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8",
120
+ "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906",
121
+ "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C",
122
+ "5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7"
123
+ ),
124
+ Vector(
125
+ "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710",
126
+ "25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517",
127
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
128
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
129
+ "7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3"
130
+ )
131
+ )
132
+ }
133
+ }
@@ -0,0 +1,199 @@
1
+ package com.pakstr.app.crypto
2
+
3
+ import org.junit.Assert.assertArrayEquals
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Assert.assertThrows
8
+ import org.junit.Test
9
+ import java.util.Base64
10
+
11
+ class Nip44CryptoTest {
12
+
13
+ @Test
14
+ fun matches_official_conversation_key_vector() {
15
+ val conversationKey =
16
+ Nip44Crypto.deriveConversationKey(
17
+ hex("315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268"),
18
+ hex("c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133")
19
+ )
20
+
21
+ assertEquals(
22
+ "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1",
23
+ Bip340Hash.bytesToHex(conversationKey)
24
+ )
25
+ }
26
+
27
+ @Test
28
+ fun matches_official_message_key_vector() {
29
+ val keys =
30
+ Nip44Crypto.deriveMessageKeys(
31
+ hex("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54"),
32
+ hex("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72")
33
+ )
34
+
35
+ keys.use {
36
+ assertEquals(
37
+ "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
38
+ Bip340Hash.bytesToHex(keys.chachaKey)
39
+ )
40
+ assertEquals(
41
+ "c4ad129bb01180c0933a160c",
42
+ Bip340Hash.bytesToHex(keys.chachaNonce)
43
+ )
44
+ assertEquals(
45
+ "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4",
46
+ Bip340Hash.bytesToHex(keys.hmacKey)
47
+ )
48
+ }
49
+ }
50
+
51
+ @Test
52
+ fun matches_official_encryption_vector() {
53
+ val conversationKey =
54
+ hex("c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d")
55
+ val nonce =
56
+ hex("0000000000000000000000000000000000000000000000000000000000000001")
57
+
58
+ val payload =
59
+ Nip44Crypto.encryptWithNonce(
60
+ "a",
61
+ conversationKey,
62
+ nonce
63
+ )
64
+
65
+ assertEquals(EXPECTED_PAYLOAD, payload)
66
+ assertEquals(
67
+ "a",
68
+ Nip44Crypto.decryptWithConversationKey(
69
+ payload,
70
+ conversationKey
71
+ )
72
+ )
73
+ }
74
+
75
+ @Test
76
+ fun padding_matches_official_boundaries() {
77
+ mapOf(
78
+ 1L to 32L,
79
+ 16L to 32L,
80
+ 32L to 32L,
81
+ 33L to 64L,
82
+ 65L to 96L,
83
+ 200L to 224L,
84
+ 250L to 256L,
85
+ 257L to 320L,
86
+ 515L to 640L,
87
+ 65535L to 65536L,
88
+ 65536L to 65536L,
89
+ 65537L to 81920L
90
+ ).forEach { (input, expected) ->
91
+ assertEquals(
92
+ expected,
93
+ Nip44Crypto.calculatePaddedLength(input)
94
+ )
95
+ }
96
+ }
97
+
98
+ @Test
99
+ fun encrypt_round_trip_uses_fresh_nonce() {
100
+ val privateKey = ByteArray(32).also { it[31] = 1 }
101
+ val publicKey =
102
+ Secp256k1.xOnlyPublicKey(
103
+ ByteArray(32).also { it[31] = 2 }
104
+ )
105
+
106
+ val first = Nip44Crypto.encrypt("hello", privateKey, publicKey)
107
+ val second = Nip44Crypto.encrypt("hello", privateKey, publicKey)
108
+
109
+ assertFalse(first == second)
110
+ assertEquals(
111
+ "hello",
112
+ Nip44Crypto.decrypt(first, privateKey, publicKey)
113
+ )
114
+ }
115
+
116
+ @Test
117
+ fun rejects_empty_plaintext_and_non_canonical_base64() {
118
+ val conversationKey =
119
+ hex("c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d")
120
+ val nonce =
121
+ hex("0000000000000000000000000000000000000000000000000000000000000001")
122
+
123
+ assertThrows(IllegalArgumentException::class.java) {
124
+ Nip44Crypto.encryptWithNonce("", conversationKey, nonce)
125
+ }
126
+
127
+ val canonical = Nip44Crypto.encryptWithNonce(
128
+ "a".repeat(33),
129
+ conversationKey,
130
+ nonce
131
+ )
132
+ assertTrue(canonical.endsWith("="))
133
+ val alphabet =
134
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
135
+ val finalDataIndex = canonical.length - 2
136
+ val canonicalValue = alphabet.indexOf(canonical[finalDataIndex])
137
+ val nonCanonical = canonical.replaceRange(
138
+ finalDataIndex,
139
+ finalDataIndex + 1,
140
+ alphabet[canonicalValue xor 1].toString()
141
+ )
142
+ assertArrayEquals(
143
+ Base64.getDecoder().decode(canonical),
144
+ Base64.getDecoder().decode(nonCanonical)
145
+ )
146
+
147
+ assertThrows(IllegalArgumentException::class.java) {
148
+ Nip44Crypto.decryptWithConversationKey(
149
+ nonCanonical,
150
+ conversationKey
151
+ )
152
+ }
153
+ }
154
+
155
+ @Test
156
+ fun rejects_tampered_mac_and_unknown_version() {
157
+ val conversationKey =
158
+ hex("c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d")
159
+ val decoded = Base64.getDecoder().decode(EXPECTED_PAYLOAD)
160
+
161
+ decoded[decoded.lastIndex] =
162
+ (decoded.last().toInt() xor 1).toByte()
163
+ val tampered = Base64.getEncoder().encodeToString(decoded)
164
+
165
+ assertThrows(IllegalArgumentException::class.java) {
166
+ Nip44Crypto.decryptWithConversationKey(
167
+ tampered,
168
+ conversationKey
169
+ )
170
+ }
171
+
172
+ decoded[0] = 3
173
+ val unknownVersion =
174
+ Base64.getEncoder().encodeToString(decoded)
175
+
176
+ assertThrows(IllegalArgumentException::class.java) {
177
+ Nip44Crypto.decryptWithConversationKey(
178
+ unknownVersion,
179
+ conversationKey
180
+ )
181
+ }
182
+ assertThrows(IllegalArgumentException::class.java) {
183
+ Nip44Crypto.decryptWithConversationKey(
184
+ "#$EXPECTED_PAYLOAD",
185
+ conversationKey
186
+ )
187
+ }
188
+ }
189
+
190
+ private fun hex(value: String): ByteArray {
191
+ return Bip340Hash.hexToBytes(value)
192
+ }
193
+
194
+ companion object {
195
+ // https://github.com/paulmillr/nip44/blob/master/nip44.vectors.json
196
+ private const val EXPECTED_PAYLOAD =
197
+ "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
198
+ }
199
+ }