Quartz support for NIP-49 encrypted nsecs

This commit is contained in:
Vitor Pamplona 2024-02-13 19:51:32 -05:00
parent 74f0393706
commit b94c1e2792
6 changed files with 292 additions and 1 deletions

View File

@ -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)

View File

@ -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"

View File

@ -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>(
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,
)
}

View File

@ -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,
)
}
}
}

View File

@ -32,6 +32,7 @@ object Nip19 {
EVENT,
RELAY,
ADDRESS,
NSEC,
}
enum class TlvTypes(val id: Byte) {

View File

@ -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 =