From 7b91dd234e13e05a3fea59006f7b5a1463c6f89f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:18:38 +0000 Subject: [PATCH] fix(quartz): NIP-44 integer padding math, strict extended prefix, payload cap - calcPaddedLen used Float math and returned wrong buckets above 2^24 (20971520 -> 25165824, 33554432 -> 41943040) and overflowed Int past 2^30. It is now integer-only on Long, as the spec requires. - unpad rejects an extended [0,0][u32] prefix whose length is < 65536, so a short message has a single valid encoding. - decodePayload enforces a configurable maximum (default 64M chars) before base64 decoding, and rejects payloads that decode to fewer than 99 bytes. - Nip44 reads only the first base64 quantum to find the version byte. - Adds the spec's 65535/65536/65537-byte vectors and padding tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc --- .../resources/nip44.vectors.json | 24 +++ .../quartz/nip44Encryption/Nip44.kt | 11 +- .../quartz/nip44Encryption/Nip44v2.kt | 140 +++++++++++------- .../quartz/nip44Encryption/Nip44v2BaseTest.kt | 2 +- .../nip44Encryption/Nip44v2PaddingTest.kt | 136 +++++++++++++++++ .../commonTest/resources/nip44.vectors.json | 24 +++ 6 files changed, 277 insertions(+), 60 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt diff --git a/quartz/src/androidDeviceTest/resources/nip44.vectors.json b/quartz/src/androidDeviceTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/androidDeviceTest/resources/nip44.vectors.json +++ b/quartz/src/androidDeviceTest/resources/nip44.vectors.json @@ -526,6 +526,30 @@ "repeat": 20000000, "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65535, + "plaintext_sha256": "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42", + "payload_sha256": "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65536, + "plaintext_sha256": "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a", + "payload_sha256": "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65537, + "plaintext_sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "payload_sha256": "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435" } ] }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt index c5eb78b4f8..c53f8862ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44.kt @@ -115,14 +115,17 @@ object Nip44 { ): String { require(ciphertext.isNotBlank()) { "ciphertext must not be blank" } - // Ignores if it is not base64 - val byteArray = Base64.decode(ciphertext) + require(ciphertext.length >= 4) { "ciphertext is too short" } - return when (byteArray[0].toInt()) { + // Only the version byte is needed here: the first base64 quantum (4 chars) holds it. + // Decoding the whole payload would allocate its full size just to be thrown away. + val version = Base64.decode(ciphertext, 0, 4)[0].toInt() + + return when (version) { EncryptedInfo.V -> Nip04.decrypt(ciphertext, privateKey, pubKey) Nip44v1.EncryptedInfo.V -> v1.decrypt(ciphertext, privateKey, pubKey) Nip44v2.EncryptedInfo.V -> v2.decrypt(ciphertext, privateKey, pubKey) - else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code ${byteArray[0].toInt()}") + else -> throw IllegalArgumentException("Invalid or unsupported NIP-44 version code $version") } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt index 98baa80452..5c3efeceac 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2.kt @@ -28,10 +28,17 @@ import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.equalsConstantTime import kotlinx.coroutines.CancellationException import kotlin.io.encoding.Base64 -import kotlin.math.floor -import kotlin.math.log2 -class Nip44v2 { +/** + * NIP-44 v2 encryption. + * + * @param maxPayloadLength the largest base64 payload [decrypt] accepts. NIP-44 lets plaintexts reach + * 2^32 - 1 bytes but asks implementations to set their own ceiling and to enforce it before base64 + * decoding, since decrypting needs several times the payload size in working memory. + */ +class Nip44v2( + val maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH, +) { private val sharedKeyCache = SharedKeyCache() private val hkdf = Hkdf() private val chaCha = ChaCha20() @@ -40,10 +47,11 @@ class Nip44v2 { private val hashLength = 32 private val minPlaintextSize: Int = 0x0001 // 1b msg => padded to 32b - private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb + private val maxPlaintextSize: Int = 0xffff // 65535 (64kb-1) => padded to 64kb, u16 prefix - private val extMinPlaintextSize: Int = 0x00000001 // 1b msg => padded to 32b - private val extMaxPlaintextSize: Long = 0xffffffff // 4294967294 => padded + // Lengths at or above this use the extended [0,0][u32] prefix, and only those may. + private val extMinPlaintextSize: Long = 0x10000 // 65536 => padded to 64kb, 6-byte prefix + private val extMaxPlaintextSize: Long = 0xffffffff // 4294967295 => padded to 2^32 fun clearCache() { sharedKeyCache.clearCache() @@ -97,7 +105,7 @@ class Nip44v2 { fun decrypt( payload: String, conversationKey: ByteArray, - ): String = decrypt(EncryptedInfo.decodePayload(payload), conversationKey) + ): String = decrypt(EncryptedInfo.decodePayload(payload, maxPayloadLength), conversationKey) fun checkHMacAad( messageKey: Hkdf.MessageKey, @@ -137,58 +145,76 @@ class Nip44v2 { return computed } - fun calcPaddedLen(len: Int): Int { + fun calcPaddedLen(len: Int): Long = calcPaddedLen(len.toLong()) + + /** + * NIP-44 padded length. Integer-only: floating point loses precision above 2^24, and the + * result can reach 2^32 (for 2^32 - 1), which only fits 64-bit arithmetic. + */ + fun calcPaddedLen(len: Long): Long { check(len > 0) { "expected positive integer" } if (len <= 32) return 32 - val nextPower = 1 shl (floor(log2(len - 1f)) + 1).toInt() - val chunk = if (nextPower <= 256) 32 else nextPower / 8 - return chunk * (floor((len - 1f) / chunk).toInt() + 1) + // 1 shl (floor(log2(len - 1)) + 1) == 1 shl bitLength(len - 1) + val nextPower = 1L shl (Long.SIZE_BITS - (len - 1).countLeadingZeroBits()) + val chunk = if (nextPower <= 256) 32L else nextPower / 8 + return chunk * ((len - 1) / chunk + 1) } fun pad(plaintext: String): ByteArray { val unpadded = plaintext.encodeToByteArray() val unpaddedLen = unpadded.size - check(unpaddedLen > 0) { "Message is empty ($unpaddedLen): $plaintext" } + check(unpaddedLen >= minPlaintextSize) { "Message is empty ($unpaddedLen): $plaintext" } - val prefix = - 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 prefixLen = if (unpaddedLen <= maxPlaintextSize) 2 else 6 + val totalLen = prefixLen + calcPaddedLen(unpaddedLen) - val suffix = ByteArray(calcPaddedLen(unpaddedLen) - unpaddedLen) - return prefix + unpadded + suffix + require(totalLen <= Int.MAX_VALUE) { + "Message is too long for this platform ($unpaddedLen bytes pad to $totalLen)" + } + + // Zero-filled: everything after the plaintext is already the padding. + val padded = ByteArray(totalLen.toInt()) + if (prefixLen == 2) { + // 2 bytes in big endian + padded[0] = (unpaddedLen shr 8).toByte() + padded[1] = (unpaddedLen and 0xFF).toByte() + } else { + // Extension to allow >= 64KB payloads: [0, 0] + 4 bytes in big endian + padded[2] = (unpaddedLen shr 24).toByte() + padded[3] = (unpaddedLen shr 16).toByte() + padded[4] = (unpaddedLen shr 8).toByte() + padded[5] = (unpaddedLen and 0xFF).toByte() + } + unpadded.copyInto(padded, prefixLen) + return padded } fun unpad(padded: ByteArray): String { + check(padded.size >= 2) { "Invalid padding: ${padded.size} bytes" } val unpaddedLenPreExt: Int = bytesToIntBigEndian(padded[0], padded[1]) 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(padded.size >= 6) { "Invalid padding: ${padded.size} bytes" } + val unpaddedLenExt: Long = bytesToLongBigEndian(padded[2], padded[3], padded[4], padded[5]) + // A length that fits the u16 prefix must use it: rejects a second encoding of short messages. check(unpaddedLenExt in extMinPlaintextSize..extMaxPlaintextSize) { "Invalid size $unpaddedLenExt not between $extMinPlaintextSize and $extMaxPlaintextSize" } - check(padded.size == 6 + calcPaddedLen(unpaddedLenExt)) { + check(padded.size.toLong() == 6 + calcPaddedLen(unpaddedLenExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenExt)} != $unpaddedLenExt" } - padded.decodeToString(6, 6 + unpaddedLenExt) + padded.decodeToString(6, 6 + unpaddedLenExt.toInt()) } else { check(unpaddedLenPreExt in minPlaintextSize..maxPlaintextSize) { "Invalid size $unpaddedLenPreExt not between $minPlaintextSize and $maxPlaintextSize" } - check(padded.size == 2 + calcPaddedLen(unpaddedLenPreExt)) { + check(padded.size.toLong() == 2 + calcPaddedLen(unpaddedLenPreExt)) { "Invalid padding ${calcPaddedLen(unpaddedLenPreExt)} != $unpaddedLenPreExt" } @@ -235,15 +261,29 @@ class Nip44v2 { companion object { const val V: Int = 2 - fun decodePayload(payload: String): EncryptedInfo { - check(payload.length >= 132) { + // version (1) + nonce (32) + smallest padded plaintext with prefix (2 + 32) + mac (32) + const val MIN_DECODED_LENGTH: Int = 99 + + // base64 of [MIN_DECODED_LENGTH] bytes + const val MIN_PAYLOAD_LENGTH: Int = 132 + + fun decodePayload( + payload: String, + maxPayloadLength: Int = DEFAULT_MAX_PAYLOAD_LENGTH, + ): EncryptedInfo { + check(payload.isNotEmpty() && payload[0] != '#') { "Unknown encryption version ${payload.getOrNull(0)}" } + check(payload.length >= MIN_PAYLOAD_LENGTH) { "Invalid payload length ${payload.length} for $payload" } - check(payload[0] != '#') { "Unknown encryption version ${payload.get(0)}" } + // Before base64 decoding, so an oversized payload costs nothing to reject. + check(payload.length <= maxPayloadLength) { + "Payload length ${payload.length} exceeds the maximum of $maxPayloadLength" + } return try { val byteArray = Base64.decode(payload) - check(byteArray[0].toInt() == V) + check(byteArray.size >= MIN_DECODED_LENGTH) { "Invalid decoded length ${byteArray.size}" } + check(byteArray[0].toInt() == V) { "Unknown encryption version ${byteArray[0]}" } EncryptedInfo( nonce = byteArray.copyOfRange(1, 33), ciphertext = byteArray.copyOfRange(33, byteArray.size - 32), @@ -267,32 +307,22 @@ class Nip44v2 { byte2: Byte, ): Int = (byte1.toInt() and 0xFF shl 8 or (byte2.toInt() and 0xFF)) - private fun bytesToIntBigEndian( + private fun bytesToLongBigEndian( 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) + ): Long = + ((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() + companion object { + /** + * Default ceiling for a base64 payload: 64M chars (~48 MB decoded). Comfortably above the + * spec's 20,000,000-byte test vector while bounding what a hostile event can make us allocate. + */ + const val DEFAULT_MAX_PAYLOAD_LENGTH: Int = 64 * 1024 * 1024 } - - 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(), - ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt index 5ddbde8c18..f31161bb67 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2BaseTest.kt @@ -56,7 +56,7 @@ class Nip44v2BaseTest { fun paddingTest() { for (v in vectors.v2?.valid?.calcPaddedLen!!) { val actual = nip44v2.calcPaddedLen(v[0]) - assertEquals(v[1], actual) + assertEquals(v[1].toLong(), actual) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt new file mode 100644 index 0000000000..9ff794032c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2PaddingTest.kt @@ -0,0 +1,136 @@ +/* + * 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 + +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Padding and payload-size rules from NIP-44 v2 (extended length prefix, 64-bit padding math). + */ +class Nip44v2PaddingTest { + private val nip44v2 = Nip44v2() + + @Test + fun paddingUsesIntegerMathAboveFloatPrecision() { + // Float math rounds these up to the next bucket (25165824 / 41943040). + assertEquals(20_971_520L, nip44v2.calcPaddedLen(20_971_520)) + assertEquals(33_554_432L, nip44v2.calcPaddedLen(33_554_432)) + assertEquals(41_943_040L, nip44v2.calcPaddedLen(33_554_433)) + assertEquals(20_971_520L, nip44v2.calcPaddedLen(16_777_217)) + } + + @Test + fun paddingDoesNotOverflowNearTheJvmArrayLimit() { + assertEquals(1L shl 30, nip44v2.calcPaddedLen(1 shl 30)) + assertEquals(1_342_177_280L, nip44v2.calcPaddedLen((1 shl 30) + 1)) + assertEquals(1L shl 31, nip44v2.calcPaddedLen(Int.MAX_VALUE)) + // The spec's theoretical maximum: 2^32 - 1 pads to 2^32, which needs 64-bit math. + assertEquals(1L shl 32, nip44v2.calcPaddedLen(0xffffffffL)) + } + + @Test + fun paddingMatchesSpecFormulaAroundBoundaries() { + val probes = listOf(33L, 256L, 257L, 65_535L, 65_536L, 65_537L, 1L shl 24, (1L shl 24) + 1, 20_000_000L, 100_000_000L) + for (n in probes) { + assertEquals(specPaddedLen(n), nip44v2.calcPaddedLen(n), "calcPaddedLen($n)") + } + } + + @Test + fun padUsesExtendedPrefixFrom65536() { + val small = nip44v2.pad("a".repeat(65_535)) + assertEquals(2 + 65_536, small.size) + assertEquals(0xff.toByte(), small[0]) + assertEquals(0xff.toByte(), small[1]) + + val big = nip44v2.pad("a".repeat(65_536)) + assertEquals(6 + 65_536, big.size) + assertEquals(listOf(0, 0, 0, 1, 0, 0), big.copyOfRange(0, 6).toList()) + assertEquals("a".repeat(65_536), nip44v2.unpad(big)) + } + + @Test + fun unpadRejectsExtendedPrefixForShortLengths() { + // [0,0][u32 = 5]["hello"][zeros] would be a second encoding of a 5-byte plaintext. + val padded = ByteArray(6 + 32) + padded[5] = 5 + "hello".encodeToByteArray().copyInto(padded, 6) + assertFailsWith { nip44v2.unpad(padded) } + + // The largest length that must not use the extended prefix. + val padded2 = ByteArray(6 + 65_536) + padded2[4] = 0xff.toByte() + padded2[5] = 0xff.toByte() + assertFailsWith { nip44v2.unpad(padded2) } + } + + @Test + fun unpadRejectsZeroLength() { + assertFailsWith { nip44v2.unpad(ByteArray(2 + 32)) } + assertFailsWith { nip44v2.unpad(ByteArray(6 + 32)) } + } + + @Test + fun rejectsOversizedPayloadBeforeDecoding() { + val key = RandomInstance.bytes(32) + val payload = nip44v2.encrypt("a".repeat(1000), key).encodePayload() + + val limited = Nip44v2(maxPayloadLength = payload.length - 1) + val error = assertFailsWith { limited.decrypt(payload, key) } + assertTrue(error.message!!.contains("exceeds"), error.message) + + // Not base64 at all: must still fail on the size check, without reaching the decoder. + val garbage = "!".repeat(1000) + val error2 = assertFailsWith { Nip44v2.EncryptedInfo.decodePayload(garbage, maxPayloadLength = 500) } + assertTrue(error2.message!!.contains("exceeds"), error2.message) + + assertEquals("a".repeat(1000), Nip44v2(maxPayloadLength = payload.length).decrypt(payload, key)) + } + + @Test + fun defaultMaxPayloadAcceptsTheLongestSpecVector() { + // 20,000,000-byte plaintext from encrypt_decrypt_long_msg. + val rawLen = 1 + 32 + 6 + nip44v2.calcPaddedLen(20_000_000) + 32 + val base64Len = (rawLen + 2) / 3 * 4 + assertTrue(base64Len <= Nip44v2.DEFAULT_MAX_PAYLOAD_LENGTH) + } + + @Test + fun rejectsShortDecodedPayload() { + // Long enough as text (132 chars) but only 98 bytes once decoded. + val tooShort = Base64.encode(byteArrayOf(2) + ByteArray(97)) + assertEquals(132, tooShort.length) + assertFailsWith { Nip44v2.EncryptedInfo.decodePayload(tooShort) } + } + + private fun specPaddedLen(n: Long): Long { + if (n <= 32) return 32 + val bitLength = 64 - (n - 1).countLeadingZeroBits() + val nextPower = 1L shl bitLength + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * ((n - 1) / chunk + 1) + } +} diff --git a/quartz/src/commonTest/resources/nip44.vectors.json b/quartz/src/commonTest/resources/nip44.vectors.json index 4d105420f3..09b7a7882b 100644 --- a/quartz/src/commonTest/resources/nip44.vectors.json +++ b/quartz/src/commonTest/resources/nip44.vectors.json @@ -526,6 +526,30 @@ "repeat": 20000000, "plaintext_sha256": "aded0ea9b4d06589b13d00bab483faf479d61ed5de21f1760aa7018a28e330e5", "payload_sha256": "9e683311894d52e48a825837883c539263c7787c7fe024e1590d96776a31684b" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65535, + "plaintext_sha256": "6e1bebca6a8229364a162a72ef064826c4cd7457bf54f190ef782bd9deff3e42", + "payload_sha256": "6d8c2810d1e870fbaa1f0a0937126cca837a15f9260e27060c331d70a3c0bc84" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65536, + "plaintext_sha256": "bf718b6f653bebc184e1479f1935b8da974d701b893afcf49e701f3e2f9f9c5a", + "payload_sha256": "b7b4edb36ba92e267d322d56d9aebc22e7fa96ff52e3c12adc07f07a43cbc616" + }, + { + "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "nonce": "0000000000000000000000000000000000000000000000000000000000000001", + "pattern": "a", + "repeat": 65537, + "plaintext_sha256": "008ffc88d3c96a9f307524eb361e47c5222a887fc45fa0c1fb8d429c5c23b430", + "payload_sha256": "eeb7c7c5373894ea2c1547cfd3ccb15d5a0b2d619da852e5c79df792dcc9e435" } ] },