From 689066fd44f256a1556e1841e6cbc2175053c225 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 12:29:26 -0400 Subject: [PATCH 01/10] Benchmark for NIP-44 operations --- .../quartz/benchmark/EncryptDecrypt.kt | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt new file mode 100644 index 000000000..c315d0d4c --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt @@ -0,0 +1,86 @@ +/** + * 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 EncryptDecrypt { + @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, sharedKey) + } + } + + @Test + fun decrypt() { + benchmarkRule.measureRepeated { + nip44v2.decrypt(encrypted, sharedKey) + } + } + + @Test + fun messageKeys() { + benchmarkRule.measureRepeated { + nip44v2.getMessageKeys(sharedKey, encrypted.nonce) + } + } + + @Test + fun checkMac() { + val messageKey = nip44v2.getMessageKeys(sharedKey, encrypted.nonce) + + benchmarkRule.measureRepeated { + nip44v2.checkHMacAad(messageKey, encrypted) + } + } + + @Test + fun hkdfExtract() { + val messageKey = nip44v2.getMessageKeys(sharedKey, encrypted.nonce) + val hkdf = Hkdf() + benchmarkRule.measureRepeated { + hkdf.extract(encrypted.nonce, encrypted.ciphertext, messageKey.hmacKey) + } + } +} From 67f9557081b5b1998ea8ca5d505fe49684a1b82c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 12:30:26 -0400 Subject: [PATCH 02/10] Reduces the need to keep the memory for checking the Mac and decrypting at the same time, allowing the GC to run after the check was correct. --- .../quartz/nip44Encryption/Nip44v2.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) 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..ef9d0bb55 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -100,9 +100,17 @@ 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: 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( @@ -110,20 +118,16 @@ class Nip44v2 { conversationKey: ByteArray, ): String { val messageKey = getMessageKeys(conversationKey, decoded.nonce) - val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) - check(calculatedMac.contentEquals(decoded.mac)) { - "Invalid Mac: Calculated ${calculatedMac.toHexKey()}, decoded: ${decoded.mac.toHexKey()}" - } + checkHMacAad(messageKey, decoded) - val padded = + return unpad( LibSodiumInstance.cryptoStreamChaCha20IetfXor( - decoded.ciphertext, - messageKey.chachaNonce, - messageKey.chachaKey, - ) - - return unpad(padded) + message = decoded.ciphertext, + nonce = messageKey.chachaNonce, + key = messageKey.chachaKey, + ), + ) } fun getConversationKey( From bd70058f78a99ed7d9dfebaa3a5760a9d69a2b86 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 12:30:56 -0400 Subject: [PATCH 03/10] Avoids creating a byte array and recreating it again inside of the Mac instance. --- .../vitorpamplona/quartz/nip44Encryption/Nip44v2.kt | 2 +- .../quartz/nip44Encryption/crypto/Hkdf.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 ef9d0bb55..d3ca381fe 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -210,7 +210,7 @@ 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( 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..94fa551f0 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 @@ -37,6 +37,18 @@ class Hkdf( return mac.doFinal(key) } + fun extract( + key1: ByteArray, + key2: ByteArray, + salt: ByteArray, + ): ByteArray { + val mac = Mac.getInstance(algorithm) + mac.init(SecretKeySpec(salt, algorithm)) + mac.update(key1) + mac.update(key2) + return mac.doFinal() + } + fun expand( key: ByteArray, nonce: ByteArray, From 98aaef61c74a16a8ebdc1ba079b104a7aedf9fe7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 15:15:44 -0400 Subject: [PATCH 04/10] Faster Hkdf functions with less array copying and allocations (which can be impactful if the ciphertext is large) --- .../quartz/benchmark/EncryptDecrypt.kt | 30 +---- .../vitorpamplona/quartz/benchmark/Hkdf.kt | 72 ++++++++++++ .../quartz/nip44Encryption/Nip44v2.kt | 9 +- .../quartz/nip44Encryption/crypto/Hkdf.kt | 78 ++++++++++++- .../quartz/nip44Encryption/crypto/HkdfText.kt | 103 ++++++++++++++++++ 5 files changed, 252 insertions(+), 40 deletions(-) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt create mode 100644 quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/HkdfText.kt diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt index c315d0d4c..a74372245 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt @@ -25,7 +25,6 @@ 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 @@ -48,39 +47,14 @@ class EncryptDecrypt { @Test fun encrypt() { benchmarkRule.measureRepeated { - nip44v2.encrypt(msg, sharedKey) + nip44v2.encrypt(msg, privateKey, publicKey) } } @Test fun decrypt() { benchmarkRule.measureRepeated { - nip44v2.decrypt(encrypted, sharedKey) - } - } - - @Test - fun messageKeys() { - benchmarkRule.measureRepeated { - nip44v2.getMessageKeys(sharedKey, encrypted.nonce) - } - } - - @Test - fun checkMac() { - val messageKey = nip44v2.getMessageKeys(sharedKey, encrypted.nonce) - - benchmarkRule.measureRepeated { - nip44v2.checkHMacAad(messageKey, encrypted) - } - } - - @Test - fun hkdfExtract() { - val messageKey = nip44v2.getMessageKeys(sharedKey, encrypted.nonce) - val hkdf = Hkdf() - benchmarkRule.measureRepeated { - hkdf.extract(encrypted.nonce, encrypted.ciphertext, messageKey.hmacKey) + nip44v2.decrypt(encrypted, privateKey, publicKey) } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt new file mode 100644 index 000000000..4b5e97b98 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt @@ -0,0 +1,72 @@ +/** + * 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 Hkdf { + @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 hkdfExpand() { + 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/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index d3ca381fe..b5fd1289b 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -216,14 +216,7 @@ class Nip44v2 { 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), - ) - } + ): MessageKey = hkdf.fastExpand(conversationKey, nonce) class MessageKey( val chachaKey: ByteArray, 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 94fa551f0..5ef41fe06 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,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip44Encryption.crypto +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2.MessageKey import java.nio.ByteBuffer import javax.crypto.Mac +import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec class Hkdf( @@ -33,7 +35,7 @@ class Hkdf( salt: ByteArray, ): ByteArray { val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(salt, algorithm)) + mac.init(HMacKey(salt)) return mac.doFinal(key) } @@ -43,7 +45,7 @@ class Hkdf( salt: ByteArray, ): ByteArray { val mac = Mac.getInstance(algorithm) - mac.init(SecretKeySpec(salt, algorithm)) + mac.init(HMacKey(salt)) mac.update(key1) mac.update(key2) return mac.doFinal() @@ -53,7 +55,7 @@ class Hkdf( key: ByteArray, nonce: ByteArray, outputLength: Int, - ): ByteArray { + ): MessageKey { check(key.size == hashLen) check(nonce.size == hashLen) @@ -74,6 +76,74 @@ 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, + ): MessageKey { + check(key.size == hashLen) + check(nonce.size == hashLen) + + val mac = Mac.getInstance(algorithm) + mac.init(HMacKey(key)) + + // 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(32) + System.arraycopy(round2, 12, hmacKey, 0, 20) + System.arraycopy(round3, 0, hmacKey, 20, 12) + + return MessageKey( + chachaKey = round1, + chachaNonce = round2.copyOfRange(0, 12), + hmacKey = hmacKey, + ) } } + +class HMacKey( + val key: ByteArray, +) : SecretKey { + override fun getAlgorithm() = "HmacSHA256" + + 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 HMacKey) 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/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()) + } +} From cb5299e265c4e2a79df084764d48f346071885de Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 16:14:09 -0400 Subject: [PATCH 05/10] Simple refactoring --- .../quartz/benchmark/{Hkdf.kt => HkdfBenchmark.kt} | 2 +- .../{EncryptDecrypt.kt => Nip44EncryptDecryptBenchmark.kt} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/{Hkdf.kt => HkdfBenchmark.kt} (99%) rename benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/{EncryptDecrypt.kt => Nip44EncryptDecryptBenchmark.kt} (98%) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt similarity index 99% rename from benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt index 4b5e97b98..bcdb1c5db 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Hkdf.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt @@ -31,7 +31,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class Hkdf { +class HkdfBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() companion object { diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt similarity index 98% rename from benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt rename to benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt index a74372245..0274764c1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/EncryptDecrypt.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt @@ -30,7 +30,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class EncryptDecrypt { +class Nip44EncryptDecryptBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() companion object { From 24a575015586b4059ae6710a28050cfcafa359c0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 16:14:43 -0400 Subject: [PATCH 06/10] Generalizes fixed key setup --- .../quartz/nip44Encryption/crypto/FixedKey.kt | 46 +++++++++++++++++++ .../quartz/nip44Encryption/crypto/Hkdf.kt | 29 ++---------- 2 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/FixedKey.kt 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 5ef41fe06..65bc65bbe 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 @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip44Encryption.crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2.MessageKey import java.nio.ByteBuffer import javax.crypto.Mac -import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec class Hkdf( @@ -35,7 +34,7 @@ class Hkdf( salt: ByteArray, ): ByteArray { val mac = Mac.getInstance(algorithm) - mac.init(HMacKey(salt)) + mac.init(FixedKey(salt, algorithm)) return mac.doFinal(key) } @@ -45,7 +44,7 @@ class Hkdf( salt: ByteArray, ): ByteArray { val mac = Mac.getInstance(algorithm) - mac.init(HMacKey(salt)) + mac.init(FixedKey(salt, algorithm)) mac.update(key1) mac.update(key2) return mac.doFinal() @@ -95,7 +94,7 @@ class Hkdf( check(nonce.size == hashLen) val mac = Mac.getInstance(algorithm) - mac.init(HMacKey(key)) + mac.init(FixedKey(key, algorithm)) // First round: T(1) = HMAC-SHA256(key, nonce || 0x01) mac.update(nonce) @@ -125,25 +124,3 @@ class Hkdf( ) } } - -class HMacKey( - val key: ByteArray, -) : SecretKey { - override fun getAlgorithm() = "HmacSHA256" - - 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 HMacKey) return false - return key.contentEquals(other.key) - } - - override fun destroy() = key.fill(0) - - override fun isDestroyed() = key.all { it.toInt() == 0 } -} From 239cac430efc604af8039e0531f0de43d46ef624 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 16:15:07 -0400 Subject: [PATCH 07/10] Adds a test for native vs libsodium chacha20 --- .../quartz/benchmark/ChaCha20Benchmark.kt | 81 +++++++++++++++++++ .../quartz/nip44Encryption/Nip44v2.kt | 16 +--- .../quartz/nip44Encryption/crypto/ChaCha20.kt | 74 +++++++++++++++++ 3 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt create mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt 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/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index b5fd1289b..9433108c1 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) @@ -122,11 +118,7 @@ class Nip44v2 { checkHMacAad(messageKey, decoded) return unpad( - LibSodiumInstance.cryptoStreamChaCha20IetfXor( - message = decoded.ciphertext, - nonce = messageKey.chachaNonce, - key = messageKey.chachaKey, - ), + chaCha.decrypt(decoded.ciphertext, messageKey.chachaNonce, messageKey.chachaKey), ) } 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) +} From e329b096baf5b7a72377c187fb6708c537bc6ac8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 16:21:11 -0400 Subject: [PATCH 08/10] Moves the message key to the HKDF function --- .../vitorpamplona/quartz/nip44Encryption/Nip44v2.kt | 10 ++-------- .../quartz/nip44Encryption/crypto/Hkdf.kt | 7 ++++++- 2 files changed, 8 insertions(+), 9 deletions(-) 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 9433108c1..a749f0ed6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -99,7 +99,7 @@ class Nip44v2 { ): String = decrypt(EncryptedInfo.decodePayload(payload), conversationKey) fun checkHMacAad( - messageKey: MessageKey, + messageKey: Hkdf.MessageKey, decoded: EncryptedInfo, ) { val calculatedMac = hmacAad(messageKey.hmacKey, decoded.ciphertext, decoded.nonce) @@ -208,13 +208,7 @@ class Nip44v2 { fun getMessageKeys( conversationKey: ByteArray, nonce: ByteArray, - ): MessageKey = hkdf.fastExpand(conversationKey, nonce) - - class MessageKey( - val chachaKey: ByteArray, - val chachaNonce: ByteArray, - val hmacKey: ByteArray, - ) + ): Hkdf.MessageKey = hkdf.fastExpand(conversationKey, nonce) /** @return 32B shared secret */ fun computeConversationKey( 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 65bc65bbe..a30c84765 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,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip44Encryption.crypto -import com.vitorpamplona.quartz.nip44Encryption.Nip44v2.MessageKey import java.nio.ByteBuffer import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec @@ -123,4 +122,10 @@ class Hkdf( hmacKey = hmacKey, ) } + + class MessageKey( + val chachaKey: ByteArray, + val chachaNonce: ByteArray, + val hmacKey: ByteArray, + ) } From cd0b1b65fb667939959508191c8612e9ba5245b5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 17:25:36 -0400 Subject: [PATCH 09/10] Merge expanding and checking Hmac functions to avoid re-creating the Mac instance. --- .../quartz/benchmark/HkdfBenchmark.kt | 10 +++++++++- .../quartz/nip44Encryption/Nip44v2.kt | 15 +++++++++++---- .../quartz/nip44Encryption/crypto/Hkdf.kt | 17 ++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt index bcdb1c5db..207b9e8bf 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt @@ -46,7 +46,15 @@ class HkdfBenchmark { } @Test - fun hkdfExpand() { + 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) 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 a749f0ed6..38ccddd47 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -113,12 +113,14 @@ class Nip44v2 { decoded: EncryptedInfo, conversationKey: ByteArray, ): String { - val messageKey = getMessageKeys(conversationKey, decoded.nonce) - - checkHMacAad(messageKey, decoded) + val messageKey = checkMessageKeys(conversationKey, decoded) return unpad( - chaCha.decrypt(decoded.ciphertext, messageKey.chachaNonce, messageKey.chachaKey), + chaCha.decrypt( + decoded.ciphertext, + messageKey.chachaNonce, + messageKey.chachaKey, + ), ) } @@ -210,6 +212,11 @@ class Nip44v2 { nonce: ByteArray, ): Hkdf.MessageKey = hkdf.fastExpand(conversationKey, nonce) + fun checkMessageKeys( + conversationKey: ByteArray, + decoded: EncryptedInfo, + ): Hkdf.MessageKey = hkdf.fastExpand(conversationKey, decoded.nonce, decoded.ciphertext, decoded.mac) + /** @return 32B shared secret */ fun computeConversationKey( privateKey: ByteArray, 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 a30c84765..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 @@ -88,6 +89,8 @@ class Hkdf( fun fastExpand( key: ByteArray, nonce: ByteArray, + ciphertext: ByteArray? = null, + ciphertextMac: ByteArray? = null, ): MessageKey { check(key.size == hashLen) check(nonce.size == hashLen) @@ -112,10 +115,22 @@ class Hkdf( mac.update(3) val round3 = mac.doFinal() - val hmacKey = ByteArray(32) + 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), From eaf8cb338425a3074822e8d4669e79b9a4a2b1d4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Sep 2025 17:25:47 -0400 Subject: [PATCH 10/10] Don't check for the max size in string anymore --- .../kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38ccddd47..8fa099d5d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -235,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)}" }