Merge pull request #964 from Chessing234/codex/bech32-canonical-padding

Reject noncanonical Bech32 padding and invalid prefix lengths
This commit is contained in:
callebtc 2026-10-25 01:14:21 +02:00 committed by GitHub
commit f7dc80fb2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 58 additions and 1 deletions

View File

@ -12,6 +12,7 @@ object Bech32 {
* Encode data with HRP (Human Readable Part)
*/
fun encode(hrp: String, data: ByteArray): String {
require(hrp.length in 1..83) { "Invalid HRP length" }
val values = convertBits(data, 8, 5, true).toList()
val checksum = createChecksum(hrp, values)
val combined = values + checksum
@ -25,7 +26,7 @@ object Bech32 {
*/
fun decode(bech32String: String): Pair<String, ByteArray> {
val separatorIndex = bech32String.lastIndexOf('1')
require(separatorIndex >= 0) { "No separator found" }
require(separatorIndex in 1..83) { "Invalid HRP length or missing separator" }
val hrp = bech32String.substring(0, separatorIndex)
val dataString = bech32String.substring(separatorIndex + 1)
@ -76,6 +77,9 @@ object Bech32 {
if (pad && bits > 0) {
result.add((acc shl (toBits - bits)) and maxv)
} else if (!pad) {
require(bits < fromBits) { "Excess padding" }
require(((acc shl (toBits - bits)) and maxv) == 0) { "Nonzero padding" }
}
return result.toIntArray()

View File

@ -0,0 +1,47 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class Bech32PaddingTest {
@Test
fun `a literal canonical npub decodes to its key`() {
val npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme"
val (hrp, key) = Bech32.decode(npub)
assertEquals("npub", hrp)
assertArrayEquals(ByteArray(32), key)
assertEquals(npub, Bech32.encode(hrp, key))
}
@Test
fun `valid checksum does not permit nonzero discarded padding`() {
// The last payload symbol differs by one bit from the canonical zero key.
val npub = "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqplkg8xt"
assertThrows(IllegalArgumentException::class.java) { Bech32.decode(npub) }
}
@Test
fun `a whole extra padding symbol cannot alias an empty payload`() {
assertThrows(IllegalArgumentException::class.java) { Bech32.decode("test1q6j8x73") }
}
@Test
fun `empty and oversized human readable prefixes are rejected`() {
for (value in listOf("10a06t8", "x".repeat(84) + "1e5y3yu")) {
assertThrows(IllegalArgumentException::class.java) { Bech32.decode(value) }
}
for (hrp in listOf("", "x".repeat(84))) {
assertThrows(IllegalArgumentException::class.java) { Bech32.encode(hrp, byteArrayOf()) }
}
}
@Test
fun `all byte lengths preserve their canonical padding`() {
for (length in 0..64) {
val bytes = ByteArray(length) { (it * 37).toByte() }
assertArrayEquals(bytes, Bech32.decode(Bech32.encode("test", bytes)).second)
}
}
}

View File

@ -36,6 +36,12 @@ randomization used by senders. Android caps outbound seal and gift-wrap
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
subscription window, while retaining its 48-hour receive lookback.
Bech32 byte decoding must reject nonzero discarded bits and a residual whole
five-bit symbol. A valid checksum alone does not make byte padding canonical.
Human-readable prefixes contain 1–83 characters; Nostr's extended data payloads
are not subject to Bitcoin address length limits. `Bech32PaddingTest` pins these
rules with literal checksum-valid positive and negative vectors.
## Rewrite acceptance gate
From a configured Android development environment, run: