diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index bf87b85cf..0d489fc94 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -143,12 +143,14 @@ class Nip44v2Test { } @Test - fun invalidMessageLengths() { + fun extendedMessageLengths() { for (v in vectors.v2?.invalid?.encryptMsgLengths!!) { val key = RandomInstance.bytes(32) try { - nip44v2.encrypt("a".repeat(v), key) - fail("Should Throw for $v") + val input = "a".repeat(v) + val result = nip44v2.encrypt(input, key) + val decrypted = nip44v2.decrypt(result, key) + assertEquals(input, decrypted) } catch (e: Exception) { assertNotNull(e) } diff --git a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json index 8fcabe8a1..4d105420f 100644 --- a/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json +++ b/quartz/src/androidInstrumentedTest/resources/nip44.vectors.json @@ -510,6 +510,22 @@ "repeat": 16383, "plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f", "payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "!", + "repeat": 65536, + "plaintext_sha256": "b007fe445ff5b583c095c4688c75d8afef66d4c93eb6aeebcea0942e670e1cd3", + "payload_sha256": "f816ffbcb053a0669488992e2d7c57c2d950d3d4af6e19a16297f3e565a4103f" + }, + { + "conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0", + "nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0", + "pattern": "a", + "repeat": 20000000, + "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", + "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" } ] }, 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 e0ca8c2bd..c732abbc0 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.utils.LibSodiumInstance import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.CancellationException -import java.nio.ByteBuffer -import java.nio.ByteOrder import java.util.Base64 import kotlin.math.floor import kotlin.math.log2 @@ -42,6 +40,9 @@ class Nip44v2 { private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b + private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded + fun clearCache() { sharedKeyCache.clearCache() } @@ -151,42 +152,49 @@ class Nip44v2 { check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } - check(unpaddedLen <= maxPlaintextSize) { "Message is too long ($unpaddedLen): $plaintext" } - val prefix = - ByteBuffer - .allocate(2) - .order(ByteOrder.BIG_ENDIAN) - .putShort(unpaddedLen.toShort()) - .array() + if (unpaddedLen <= maxPlaintextSize) { + // 2 bytes in big endian + intTo2BytesBigEndian(unpaddedLen) + } else if (unpaddedLen <= extMaxPlaintextSize) { + // Extension to allow > 65KB payloads + // 2+4 bytes in big endian + byteArrayOf(0, 0) + intTo4BytesBigEndian(unpaddedLen) + } else { + throw IllegalArgumentException("Message is too long ($unpaddedLen): $plaintext") + } + val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return ByteBuffer.wrap(prefix + unpadded + suffix).array() + return prefix + unpadded + suffix } - private fun bytesToInt( - byte1: Byte, - byte2: Byte, - bigEndian: Boolean, - ): Int = - if (bigEndian) { - (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - } else { - (byte2.toInt() and 0xFF shl 8 or (byte1.toInt() and 0xFF)) - } - fun unpad(padded: ByteArray): String { - val unpaddedLen: Int = bytesToInt(padded[0], padded[1], true) - val unpadded = padded.sliceArray(2 until 2 + unpaddedLen) + val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1]) - check( - unpaddedLen in minPlaintextSize..maxPlaintextSize && - unpadded.size == unpaddedLen && - padded.size == 2 + calcPaddedLen(unpaddedLen), - ) { - "invalid padding ${unpadded.size} != $unpaddedLen" + return if (unpaddedLenPreExt == 0) { + // NIP-44 extension to handle bigger than 65K payloads + val unpaddedLenExt: Int = bytesToIntBigEndian(padded[2], padded[3], padded[4], padded[5]) + + check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) { + "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" + } + + check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) { + "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" + } + + String(padded, 6, unpaddedLenExt) + } else { + check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) { + "Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize" + } + + check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) { + "Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt" + } + + String(padded, 2, unpaddedLenPreExt) } - - return unpadded.decodeToString() } fun hmacAad( @@ -264,4 +272,41 @@ class Nip44v2 { byteArrayOf(V.toByte()) + nonce + ciphertext + mac, ) } + + // ----------------------------------- + // FASTER METHODS THAN BUFFER WRAPPING + // ----------------------------------- + private fun bytesToIntBigEndian( + byte1: Byte, + byte2: Byte, + ): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) + + private fun bytesToIntBigEndian( + byte1: Byte, + byte2: Byte, + byte3: Byte, + byte4: Byte, + ): Int { + val result = + ((byte1.toLong() and 0xFF) shl 24) or + ((byte2.toLong() and 0xFF) shl 16) or + ((byte3.toLong() and 0xFF) shl 8) or + (byte4.toLong() and 0xFF) + + check(result <= Int.MAX_VALUE) { + "JVM cannot handle more than 2GB payloads. Current length: $result" + } + + return result.toInt() + } + + private fun intTo2BytesBigEndian(value: Int): ByteArray = byteArrayOf((value shr 8).toByte(), (value and 0xFF).toByte()) + + private fun intTo4BytesBigEndian(value: Int): ByteArray = + byteArrayOf( + (value shr 24).toByte(), + (value shr 16).toByte(), + (value shr 8).toByte(), + (value and 0xFF).toByte(), + ) }