feat(quartz): size-enforcing Hex.decode64/128 and encode64/128

Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:

- decode64 / decode128 throw IllegalArgumentException; the OrNull
  variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.

The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
This commit is contained in:
Claude
2026-08-01 00:14:17 +00:00
parent 50e7f43b9a
commit 9ea4b81c1f
2 changed files with 183 additions and 0 deletions
@@ -36,6 +36,8 @@ package com.vitorpamplona.quartz.utils
* val hex = Hex.encode(bytes) // ByteArray -> lower-case hex
* val bytes = Hex.decode(hex) // hex (any case) -> ByteArray
* if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id?
* val id = Hex.decode64(idHex) // exactly 64 chars or it throws
* val sig = Hex.decode128OrNull(sigHex) // exactly 128 chars or null
* ```
*/
object Hex {
@@ -188,6 +190,74 @@ object Hex {
}
}
/**
* Decodes a 32-byte pubkey/event id, accepting only exactly 64 hex chars
* (upper or lower case). Throws [IllegalArgumentException] on any other
* length or on non-hex characters — use [decode64OrNull] for untrusted
* input. Single pass: validation is folded into the decode, so this is
* faster than `isHex64` + [decode].
*/
fun decode64(hex: String): ByteArray = decode64OrNull(hex) ?: throw IllegalArgumentException("Invalid 64-char hex $hex")
/** Like [decode64] but returns null instead of throwing. */
fun decode64OrNull(hex: String): ByteArray? = if (hex.length == 64) decodeExactOrNull(hex, 32) else null
/**
* Decodes a 64-byte value (a Schnorr signature), accepting only exactly
* 128 hex chars (upper or lower case). Throws [IllegalArgumentException]
* on any other length or on non-hex characters — use [decode128OrNull]
* for untrusted input.
*/
fun decode128(hex: String): ByteArray = decode128OrNull(hex) ?: throw IllegalArgumentException("Invalid 128-char hex $hex")
/** Like [decode128] but returns null instead of throwing. */
fun decode128OrNull(hex: String): ByteArray? = if (hex.length == 128) decodeExactOrNull(hex, 64) else null
/**
* Decodes [hex] into [byteLen] bytes, or null if any char is not a hex
* digit. The caller has already checked `hex.length == 2 * byteLen`.
* Validation is free: the lookup table yields -1 for invalid chars, which
* keeps the OR-accumulator negative, so one sign check at the end covers
* every char with no branches inside the loop.
*/
private fun decodeExactOrNull(
hex: String,
byteLen: Int,
): ByteArray? =
try {
val out = ByteArray(byteLen)
var acc = 0
var c = 0
for (i in 0 until byteLen) {
val b = (hexToByte[hex[c++].code] shl 4) or hexToByte[hex[c++].code]
acc = acc or b
out[i] = b.toByte()
}
if (acc < 0) null else out
} catch (_: IndexOutOfBoundsException) {
// chars above 0xFF (e.g. emoji) fall outside the lookup table
null
}
/**
* Encodes a 32-byte pubkey/event id as a 64-char lower-case hex string.
* Throws [IllegalArgumentException] when [input] is not exactly 32 bytes.
*/
fun encode64(input: ByteArray): String {
require(input.size == 32) { "Expected 32 bytes, got ${input.size}" }
return encode(input)
}
/**
* Encodes a 64-byte value (a Schnorr signature) as a 128-char lower-case
* hex string. Throws [IllegalArgumentException] when [input] is not
* exactly 64 bytes.
*/
fun encode128(input: ByteArray): String {
require(input.size == 64) { "Expected 64 bytes, got ${input.size}" }
return encode(input)
}
/** Encodes [input] as a lower-case hex string (two chars per byte). */
fun encode(input: ByteArray): String {
val out = CharArray(input.size * 2)
@@ -0,0 +1,113 @@
/*
* 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.utils
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class HexExactSizeTest {
val id64 = "48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf"
val sig128 = id64 + "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
@Test
fun decode64RoundTrip() {
assertEquals(id64, Hex.encode64(Hex.decode64(id64)))
assertContentEquals(Hex.decode(id64), Hex.decode64(id64))
assertContentEquals(Hex.decode(id64), Hex.decode64OrNull(id64))
}
@Test
fun decode64AcceptsUpperCase() {
assertContentEquals(Hex.decode(id64), Hex.decode64(id64.uppercase()))
}
@Test
fun decode64RejectsWrongLengths() {
assertFailsWith<IllegalArgumentException> { Hex.decode64("") }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64.drop(1)) }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64.drop(2)) }
assertFailsWith<IllegalArgumentException> { Hex.decode64(id64 + "ab") }
assertFailsWith<IllegalArgumentException> { Hex.decode64(sig128) }
assertNull(Hex.decode64OrNull(""))
assertNull(Hex.decode64OrNull(id64.drop(2)))
assertNull(Hex.decode64OrNull(id64 + "ab"))
assertNull(Hex.decode64OrNull(sig128))
}
@Test
fun decode64RejectsInvalidChars() {
// every position, both a plain non-hex char and an emoji (code > 0xFF)
for (i in 0 until 64) {
val withG = id64.substring(0, i) + "g" + id64.substring(i + 1)
assertNull(Hex.decode64OrNull(withG), withG)
assertFailsWith<IllegalArgumentException> { Hex.decode64(withG) }
}
val withEmoji = "🥰" + id64.drop(2)
assertNull(Hex.decode64OrNull(withEmoji))
assertFailsWith<IllegalArgumentException> { Hex.decode64(withEmoji) }
}
@Test
fun decode128RoundTrip() {
assertEquals(sig128, Hex.encode128(Hex.decode128(sig128)))
assertContentEquals(Hex.decode(sig128), Hex.decode128(sig128))
assertContentEquals(Hex.decode(sig128), Hex.decode128OrNull(sig128.uppercase()))
}
@Test
fun decode128RejectsWrongLengthsAndInvalidChars() {
assertFailsWith<IllegalArgumentException> { Hex.decode128("") }
assertFailsWith<IllegalArgumentException> { Hex.decode128(id64) }
assertFailsWith<IllegalArgumentException> { Hex.decode128(sig128.drop(2)) }
assertFailsWith<IllegalArgumentException> { Hex.decode128(sig128 + "ab") }
assertNull(Hex.decode128OrNull(id64))
assertNull(Hex.decode128OrNull(sig128.dropLast(1) + "x"))
assertNull(Hex.decode128OrNull("🥰" + sig128.drop(2)))
}
@Test
fun encodeRejectsWrongSizes() {
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(31)) }
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(33)) }
assertFailsWith<IllegalArgumentException> { Hex.encode64(ByteArray(64)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(32)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(63)) }
assertFailsWith<IllegalArgumentException> { Hex.encode128(ByteArray(65)) }
}
@Test
fun randomsMatchGenericDecode() {
for (i in 0..1000) {
val id = RandomInstance.bytes(32)
assertEquals(Hex.encode(id), Hex.encode64(id))
assertContentEquals(id, Hex.decode64(Hex.encode64(id)))
val sig = RandomInstance.bytes(64)
assertEquals(Hex.encode(sig), Hex.encode128(sig))
assertContentEquals(sig, Hex.decode128(Hex.encode128(sig)))
}
}
}