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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 17:18:38 +00:00
parent 460e587264
commit 7b91dd234e
6 changed files with 277 additions and 60 deletions
@@ -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"
}
]
},
@@ -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")
}
}
}
@@ -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(),
)
}
@@ -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)
}
}
@@ -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<Byte>(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<IllegalStateException> { 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<IllegalStateException> { nip44v2.unpad(padded2) }
}
@Test
fun unpadRejectsZeroLength() {
assertFailsWith<IllegalStateException> { nip44v2.unpad(ByteArray(2 + 32)) }
assertFailsWith<IllegalStateException> { 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<IllegalStateException> { 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<IllegalStateException> { 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<IllegalStateException> { 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)
}
}
@@ -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"
}
]
},