diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt new file mode 100644 index 000000000..7b572b34e --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 +import com.vitorpamplona.quartz.utils.RandomInstance +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChaCha20Benchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object Companion { + val nip44v2 = Nip44v2() + val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" + + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) + + val nonce = RandomInstance.bytes(32) + val messageKeys = nip44v2.getMessageKeys(sharedKey, nonce) + val padded = nip44v2.pad(msg) + + val chaCha = ChaCha20() + } + + @Test + fun encryptLibSodium() { + benchmarkRule.measureRepeated { + chaCha.encryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun encryptNative() { + benchmarkRule.measureRepeated { + chaCha.encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun decryptLibSodium() { + benchmarkRule.measureRepeated { + chaCha.decryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun decryptNative() { + benchmarkRule.measureRepeated { + chaCha.decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt new file mode 100644 index 000000000..207b9e8bf --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HkdfBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val nip44v2 = Nip44v2() + val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" + + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) + val encrypted = nip44v2.encrypt(msg, sharedKey) + } + + @Test + fun hkdfExpandWithCheck() { + val hkdf = Hkdf() + benchmarkRule.measureRepeated { + hkdf.fastExpand(sharedKey, encrypted.nonce, encrypted.ciphertext, encrypted.mac) + } + } + + @Test + fun hkdfFastExpand() { + val hkdf = Hkdf() + benchmarkRule.measureRepeated { + hkdf.fastExpand(sharedKey, encrypted.nonce) + } + } + + @Test + fun hkdfExpandOld() { + val hkdf = Hkdf() + benchmarkRule.measureRepeated { + hkdf.expand(sharedKey, encrypted.nonce, 76) + } + } + + @Test + fun hkdfExtract() { + val messageKey = nip44v2.getMessageKeys(sharedKey, encrypted.nonce) + val hkdf = Hkdf() + benchmarkRule.measureRepeated { + hkdf.extract(encrypted.nonce, encrypted.ciphertext, messageKey.hmacKey) + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt new file mode 100644 index 000000000..0274764c1 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Nip44EncryptDecryptBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val nip44v2 = Nip44v2() + val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" + + val privateKey = Nip01.privKeyCreate() + val publicKey = Nip01.pubKeyCreate(privateKey) + + val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) + val encrypted = nip44v2.encrypt(msg, sharedKey) + } + + @Test + fun encrypt() { + benchmarkRule.measureRepeated { + nip44v2.encrypt(msg, privateKey, publicKey) + } + } + + @Test + fun decrypt() { + benchmarkRule.measureRepeated { + nip44v2.decrypt(encrypted, privateKey, publicKey) + } + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index c732abbc0..8fa099d5d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.nip44Encryption import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf -import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.CancellationException @@ -33,6 +33,7 @@ import kotlin.math.log2 class Nip44v2 { private val sharedKeyCache = SharedKeyCache() private val hkdf = Hkdf() + private val chaCha = ChaCha20() private val saltPrefix = "nip44-v2".toByteArray(Charsets.UTF_8) private val hashLength = 32 @@ -69,12 +70,7 @@ class Nip44v2 { val messageKeys = getMessageKeys(conversationKey, nonce) val padded = pad(plaintext) - val ciphertext = - LibSodiumInstance.cryptoStreamChaCha20IetfXor( - padded, - messageKeys.chachaNonce, - messageKeys.chachaKey, - ) + val ciphertext = chaCha.encrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) val mac = hmacAad(messageKeys.hmacKey, ciphertext, nonce) @@ -100,30 +96,32 @@ class Nip44v2 { fun decrypt( payload: String, conversationKey: ByteArray, - ): String { - val decoded = EncryptedInfo.decodePayload(payload) - return decrypt(decoded, conversationKey) + ): String = decrypt(EncryptedInfo.decodePayload(payload), conversationKey) + + fun checkHMacAad( + messageKey: Hkdf.MessageKey, + decoded: EncryptedInfo, + ) { + val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) + + check(calculatedMac.contentEquals(decoded.mac)) { + "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" + } } fun decrypt( decoded: EncryptedInfo, conversationKey: ByteArray, ): String { - val messageKey = getMessageKeys(conversationKey, decoded.nonce) - val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) + val messageKey = checkMessageKeys(conversationKey, decoded) - check(calculatedMac.contentEquals(decoded.mac)) { - "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" - } - - val padded = - LibSodiumInstance.cryptoStreamChaCha20IetfXor( + return unpad( + chaCha.decrypt( decoded.ciphertext, messageKey.chachaNonce, messageKey.chachaKey, - ) - - return unpad(padded) + ), + ) } fun getConversationKey( @@ -206,26 +204,18 @@ class Nip44v2 { "AAD associated data must be 32 bytes, but it was ${aad.size} bytes" } - return hkdf.extract(aad + message, key) + return hkdf.extract(aad, message, key) } fun getMessageKeys( conversationKey: ByteArray, nonce: ByteArray, - ): MessageKey { - val keys = hkdf.expand(conversationKey, nonce, 76) - return MessageKey( - chachaKey = keys.copyOfRange(0, 32), - chachaNonce = keys.copyOfRange(32, 44), - hmacKey = keys.copyOfRange(44, 76), - ) - } + ): Hkdf.MessageKey = hkdf.fastExpand(conversationKey, nonce) - class MessageKey( - val chachaKey: ByteArray, - val chachaNonce: ByteArray, - val hmacKey: ByteArray, - ) + fun checkMessageKeys( + conversationKey: ByteArray, + decoded: EncryptedInfo, + ): Hkdf.MessageKey = hkdf.fastExpand(conversationKey, decoded.nonce, decoded.ciphertext, decoded.mac) /** @return 32B shared secret */ fun computeConversationKey( @@ -245,7 +235,7 @@ class Nip44v2 { const val V: Int = 2 fun decodePayload(payload: String): EncryptedInfo { - check(payload.length >= 132 || payload.length <= 87472) { + check(payload.length >= 132) { "Invalid payload length ${payload.length} for $payload" } check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt new file mode 100644 index 000000000..1ddba1261 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025 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.nip44Encryption.crypto + +import com.vitorpamplona.quartz.utils.LibSodiumInstance +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec + +/** + * Encapsulates the ChaCha20 options. LibSodium is faster on real hardware: 851ns vs 2,535ns encrypt/decrypt times + */ +class ChaCha20 { + fun encrypt( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ) = encryptLibSodium(message, nonce, key) + + fun decrypt( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ) = decryptLibSodium(message, nonce, key) + + fun encryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } + + fun decryptNative( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + val cipher = Cipher.getInstance("ChaCha20") + cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) + return cipher.doFinal(message) + } + + fun encryptLibSodium( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key) + + fun decryptLibSodium( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key) +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/FixedKey.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/FixedKey.kt new file mode 100644 index 000000000..6f4c21bc6 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/FixedKey.kt @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2025 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.nip44Encryption.crypto + +import javax.crypto.SecretKey + +class FixedKey( + val key: ByteArray, + val algo: String, +) : SecretKey { + override fun getAlgorithm() = algo + + override fun getEncoded() = key + + override fun getFormat() = "RAW" + + override fun hashCode() = key.contentHashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FixedKey) return false + return key.contentEquals(other.key) + } + + override fun destroy() = key.fill(0) + + override fun isDestroyed() = key.all { it.toInt() == 0 } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt index 263ef2efe..35909b400 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Hkdf.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip44Encryption.crypto +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import java.nio.ByteBuffer import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec @@ -33,15 +34,27 @@ class Hkdf( salt: ByteArray, ): ByteArray { val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(salt, algorithm)) + mac.init(FixedKey(salt, algorithm)) return mac.doFinal(key) } + fun extract( + key1: ByteArray, + key2: ByteArray, + salt: ByteArray, + ): ByteArray { + val mac = Mac.getInstance(algorithm) + mac.init(FixedKey(salt, algorithm)) + mac.update(key1) + mac.update(key2) + return mac.doFinal() + } + fun expand( key: ByteArray, nonce: ByteArray, outputLength: Int, - ): ByteArray { + ): MessageKey { check(key.size == hashLen) check(nonce.size == hashLen) @@ -62,6 +75,72 @@ class Hkdf( val result = ByteArray(outputLength) generatedBytes.rewind() generatedBytes[result, 0, outputLength] - return result + + return MessageKey( + chachaKey = result.copyOfRange(0, 32), + chachaNonce = result.copyOfRange(32, 44), + hmacKey = result.copyOfRange(44, 76), + ) } + + /** + * Expands with outputLength == 76 while using the least amount of memory allocation + */ + fun fastExpand( + key: ByteArray, + nonce: ByteArray, + ciphertext: ByteArray? = null, + ciphertextMac: ByteArray? = null, + ): MessageKey { + check(key.size == hashLen) + check(nonce.size == hashLen) + + val mac = Mac.getInstance(algorithm) + mac.init(FixedKey(key, algorithm)) + + // First round: T(1) = HMAC-SHA256(key, nonce || 0x01) + mac.update(nonce) + mac.update(1) + val round1 = mac.doFinal() + + // Second round: T(2) = HMAC-SHA256(key, T(1) || nonce || 0x02) + mac.update(round1) + mac.update(nonce) + mac.update(2) + val round2 = mac.doFinal() + + // Third round: T(3) = HMAC-SHA256(key, T(2) || nonce || 0x03) + mac.update(round2) + mac.update(nonce) + mac.update(3) + val round3 = mac.doFinal() + + val hmacKey = ByteArray(hashLen) + System.arraycopy(round2, 12, hmacKey, 0, 20) + System.arraycopy(round3, 0, hmacKey, 20, 12) + + // checks the mac here to avoid building a new Mac.getInstance(algorithm) + if (ciphertext != null && ciphertextMac != null) { + mac.init(FixedKey(hmacKey, algorithm)) + mac.update(nonce) + mac.update(ciphertext) + val calculatedMac = mac.doFinal() + + check(calculatedMac.contentEquals(ciphertextMac)) { + "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${ciphertextMac.toHexKey()}" + } + } + + return MessageKey( + chachaKey = round1, + chachaNonce = round2.copyOfRange(0, 12), + hmacKey = hmacKey, + ) + } + + class MessageKey( + val chachaKey: ByteArray, + val chachaNonce: ByteArray, + val hmacKey: ByteArray, + ) } diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt new file mode 100644 index 000000000..f6e9cfb11 --- /dev/null +++ b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2025 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.nip44Encryption.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import org.junit.Test +import kotlin.test.assertEquals + +class HkdfText { + companion object { + val hkdf = Hkdf() + } + + @Test + fun testExpand1() { + val result = + hkdf.expand( + "b0a6232d3e5444dbf2291f3290504989716f1dc78ac2da812230720fd76c8f06".hexToByteArray(), + "c9300041754c97f030ec9c35db607cb642db0f4305f178692954f3a883ea3209".hexToByteArray(), + 76, + ) + + assertEquals("9077071b92dfbd725f5a1dd2dbbf349615f1d0ce22b436e2f505cc05984ef588", result.hmacKey.toHexKey()) + assertEquals("3256a46b762158a6e08338daf2c5b3eefd2bed343cb22df8b6315567b9207cef", result.chachaKey.toHexKey()) + assertEquals("670bb239ac07dbb801653aa5", result.chachaNonce.toHexKey()) + + val result2 = + hkdf.fastExpand( + "b0a6232d3e5444dbf2291f3290504989716f1dc78ac2da812230720fd76c8f06".hexToByteArray(), + "c9300041754c97f030ec9c35db607cb642db0f4305f178692954f3a883ea3209".hexToByteArray(), + ) + + assertEquals("9077071b92dfbd725f5a1dd2dbbf349615f1d0ce22b436e2f505cc05984ef588", result2.hmacKey.toHexKey()) + assertEquals("3256a46b762158a6e08338daf2c5b3eefd2bed343cb22df8b6315567b9207cef", result2.chachaKey.toHexKey()) + assertEquals("670bb239ac07dbb801653aa5", result2.chachaNonce.toHexKey()) + } + + @Test + fun testExpand2() { + val result = + hkdf.expand( + "bc5d4e032696ef107ef1c7b6fc5f00c6e7b31ae4f86ee486ce24aa0d84847d83".hexToByteArray(), + "830426c471c0870afb0208cabcaa0e4d66fe51af7163336b7b9ec1846c31e900".hexToByteArray(), + 76, + ) + + assertEquals("26ccd9471681fc42459dbf7b14fc54d3f5276ddad482f7c9f81dc021ccbe5592", result.hmacKey.toHexKey()) + assertEquals("a13a519db107cf9ecbca6ba79ab2428fcd624286025b7ee452f153ae770f07a8", result.chachaKey.toHexKey()) + assertEquals("4c8218640c43de928f4c52e1", result.chachaNonce.toHexKey()) + + val result2 = + hkdf.fastExpand( + "bc5d4e032696ef107ef1c7b6fc5f00c6e7b31ae4f86ee486ce24aa0d84847d83".hexToByteArray(), + "830426c471c0870afb0208cabcaa0e4d66fe51af7163336b7b9ec1846c31e900".hexToByteArray(), + ) + + assertEquals("26ccd9471681fc42459dbf7b14fc54d3f5276ddad482f7c9f81dc021ccbe5592", result2.hmacKey.toHexKey()) + assertEquals("a13a519db107cf9ecbca6ba79ab2428fcd624286025b7ee452f153ae770f07a8", result2.chachaKey.toHexKey()) + assertEquals("4c8218640c43de928f4c52e1", result2.chachaNonce.toHexKey()) + } + + @Test + fun testExpand3() { + val result = + hkdf.expand( + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + 76, + ) + + assertEquals("a30b80c908d01aa28eae9dcdec55ac104f9c889b989ee563919985a84f66360d", result.hmacKey.toHexKey()) + assertEquals("3769af12ff4dbf44e516a22d1d0512e8bc42516d59e8bf401ea346a4d60dccf7", result.chachaKey.toHexKey()) + assertEquals("77938d29bb13ea73f677ac27", result.chachaNonce.toHexKey()) + + val result2 = + hkdf.fastExpand( + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + "0000000000000000000000000000000000000000000000000000000000000000".hexToByteArray(), + ) + + assertEquals("a30b80c908d01aa28eae9dcdec55ac104f9c889b989ee563919985a84f66360d", result2.hmacKey.toHexKey()) + assertEquals("3769af12ff4dbf44e516a22d1d0512e8bc42516d59e8bf401ea346a4d60dccf7", result2.chachaKey.toHexKey()) + assertEquals("77938d29bb13ea73f677ac27", result2.chachaNonce.toHexKey()) + } +}