diff --git a/README.md b/README.md index ffafec932..5339adc3a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Nostr Connect (NIP-46) - [x] Wallet Connect API (NIP-47) - [ ] Proxy Tags (NIP-48, Not applicable) -- [ ] Private key encryption for import/export (NIP-49) +- [x] Private key encryption for import/export (NIP-49) - [x] Online Relay Search (NIP-50) - [x] Lists (NIP-51) - [ ] Calendar Events (NIP-52) diff --git a/quartz/build.gradle b/quartz/build.gradle index c01833fb1..3dd64b7cd 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -54,6 +54,8 @@ dependencies { // immutable collections to avoid recomposition api('org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7') + api('com.lambdaworks:scrypt:1.4.0') + // Parses URLs from Text: api "io.github.url-detector:url-detector:0.1.23" diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP49Test.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP49Test.kt new file mode 100644 index 000000000..01825904a --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/NIP49Test.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.crypto.Nip49 +import com.vitorpamplona.quartz.encoders.toHexKey +import fr.acinq.secp256k1.Secp256k1 +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import java.security.SecureRandom + +@RunWith(AndroidJUnit4::class) +public class NIP49Test { + companion object { + val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + + val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + val TEST_CASE_PASSWORD = "nostr" + + val MAIN_TEST_CASES = + listOf( + Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), + Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), + Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), + Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 7, 0x00), + Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 8, 0x01), + Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 9, 0x02), + ) + } + + val random = SecureRandom() + val nip49 = Nip49(Secp256k1.get(), random) + + @Test + fun decodeBech32() { + val data = + Nip49.EncryptedInfo.decodePayload( + TEST_CASE, + )!! + + assertEquals(2, data.version) + assertEquals(16, data.logn) + assertEquals("52d7c3f8580e7b41953381e5bc49646b", data.salt.toHexKey()) + assertEquals("c33f02a7dcaac8bdd8da23cd449783240b6ebc12edeea7bf", data.nonce.toHexKey()) + assertEquals(0, data.keySecurity) + assertEquals("b8e8803440de7b3e9519c3e734cb2ac9a211ea2dc52312e5117a11a3022d813ab438719ca0b504a1193be510c3aee776", data.encryptedKey.toHexKey()) + } + + @Test + fun decrypt() { + val decrypted = nip49.decrypt(TEST_CASE, TEST_CASE_PASSWORD) + assertEquals(TEST_CASE_EXPECTED, decrypted) + } + + @Test + fun encryptDecryptTestCase() { + val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) + val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + + assertEquals(TEST_CASE_EXPECTED, decrypted) + } + + @Test + fun encryptDecrypt() { + MAIN_TEST_CASES.forEach { + val encrypted = nip49.encrypt(it.secretKey, it.password, it.logn, it.ksb) + + assertNotNull(encrypted) + + val decrypted = nip49.decrypt(encrypted!!, it.password) + + assertEquals(it.secretKey, decrypted) + } + } + + class Nip49TestCase( + val password: String, + val secretKey: String, + val logn: Int, + val ksb: Byte, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt new file mode 100644 index 000000000..2d53fc2d7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt @@ -0,0 +1,173 @@ +/** + * Copyright (c) 2023 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.crypto + +import android.util.Log +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid +import com.lambdaworks.crypto.SCrypt +import com.vitorpamplona.quartz.encoders.Bech32 +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.bechToBytes +import com.vitorpamplona.quartz.encoders.hexToByteArray +import com.vitorpamplona.quartz.encoders.toHexKey +import fr.acinq.secp256k1.Secp256k1 +import java.security.SecureRandom + +class Nip49(val secp256k1: Secp256k1, val random: SecureRandom) { + private val libSodium = SodiumAndroid() + private val lazySodium = LazySodiumAndroid(libSodium) + + fun decrypt( + nCryptSec: String, + password: String, + ): HexKey? { + return decrypt(EncryptedInfo.decodePayload(nCryptSec), password) + } + + fun decrypt( + encryptedInfo: EncryptedInfo?, + password: String = "", + ): String? { + check(encryptedInfo != null) { "Couldn't decode key" } + check(encryptedInfo.version == EncryptedInfo.V) { "invalid version" } + + val n = Math.pow(2.0, encryptedInfo.logn.toDouble()).toInt() + val key = SCrypt.scrypt(password.toByteArray(Charsets.UTF_8), encryptedInfo.salt, n, 8, 1, 32) + val m = ByteArray(32) + + lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( + m, + longArrayOf(32L), + key, + encryptedInfo.encryptedKey, + encryptedInfo.encryptedKey.size.toLong(), + byteArrayOf(encryptedInfo.keySecurity), + 1, + encryptedInfo.nonce, + key, + ) + + return m.toHexKey() + } + + fun encrypt( + secretKeyHex: String, + password: String, + logn: Int, + ksb: Byte, + ): String? { + return encrypt(secretKeyHex.hexToByteArray(), password, logn, ksb) + } + + fun encrypt( + secretKey: ByteArray, + password: String, + logn: Int, + ksb: Byte, + ): String { + check(secretKey.size == 32) { "invalid secret key" } + val salt = ByteArray(16) + random.nextBytes(salt) + + val nonce = ByteArray(24) + random.nextBytes(nonce) + + val n = Math.pow(2.0, logn.toDouble()).toInt() + val key = SCrypt.scrypt(password.toByteArray(Charsets.UTF_8), salt, n, 8, 1, 32) + val ciphertext = ByteArray(48) + + // byte[] c, long[] cLen, + // byte[] m, long mLen, + // byte[] ad, long adLen, + // byte[] nSec, byte[] nPub, byte[] k + lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( + ciphertext, longArrayOf(48), + secretKey, secretKey.size.toLong(), + byteArrayOf(ksb), 1, + key, + nonce, + key, + ) + + return EncryptedInfo( + EncryptedInfo.V, + logn.toByte(), + salt, + nonce, + ksb, + ciphertext, + ).encodePayload() + } + + class EncryptedInfo( + val version: Byte, + val logn: Byte, + val salt: ByteArray, + val nonce: ByteArray, + val keySecurity: Byte, + val encryptedKey: ByteArray, + ) { + companion object { + const val V: Byte = 0x02 + + const val UNSAFE_HANDLING = 0x00 + const val SAFE_HANDLING = 0x01 + const val CLIENT_DOES_NOT_TRACK = 0x02 + + fun decodePayload(nCryptSec: String): EncryptedInfo? { + val byteArray = + try { + nCryptSec.bechToBytes() + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP49 $nCryptSec: ${e.message}", e) + return null + } + + return try { + return EncryptedInfo( + version = byteArray[0], + logn = byteArray[1], + salt = byteArray.copyOfRange(2, 2 + 16), + nonce = byteArray.copyOfRange(2 + 16, 2 + 16 + 24), + keySecurity = byteArray.copyOfRange(2 + 16 + 24, 2 + 16 + 24 + 1).get(0), + encryptedKey = byteArray.copyOfRange(2 + 16 + 24 + 1, byteArray.size), + ) + } catch (e: Exception) { + Log.w("NIP44v2", "Unable to Parse encrypted ncryptsec payload") + null + } + } + } + + // ln(n.toDouble()).toInt().toByte(), + fun encodePayload(): String { + return Bech32.encodeBytes( + hrp = "ncryptsec", + byteArrayOf( + version, + logn, + ) + salt + nonce + keySecurity + encryptedKey, + Bech32.Encoding.Bech32, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt index 03f638351..57bcb9f17 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip19.kt @@ -32,6 +32,7 @@ object Nip19 { EVENT, RELAY, ADDRESS, + NSEC, } enum class TlvTypes(val id: Byte) { diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt index 9bb72ac58..750104269 100644 --- a/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt +++ b/quartz/src/test/java/com/vitorpamplona/quartz/encoders/NIP19ParserTest.kt @@ -24,6 +24,18 @@ import org.junit.Assert.assertEquals import org.junit.Test class NIP19ParserTest { + @Test + fun nCryptSecParser() { + val result = + Nip19.uriToRoute( + "nostr:ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p", + ) + assertEquals( + "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", + result?.hex, + ) + } + @Test fun nAddrParser() { val result =