From f94816908c7e8d7805129127fe188cca6bdba6bd Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 6 Oct 2026 10:35:37 +0530 Subject: [PATCH] Reject noncanonical Bech32 padding and invalid prefix lengths --- .../java/com/bitchat/android/nostr/Bech32.kt | 6 ++- .../android/nostr/Bech32PaddingTest.kt | 47 +++++++++++++++++++ docs/client-rewrite-contracts.md | 6 +++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/bitchat/android/nostr/Bech32PaddingTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/Bech32.kt b/app/src/main/java/com/bitchat/android/nostr/Bech32.kt index 3e55ab67c..6daa22df0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/Bech32.kt +++ b/app/src/main/java/com/bitchat/android/nostr/Bech32.kt @@ -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 { 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() diff --git a/app/src/test/java/com/bitchat/android/nostr/Bech32PaddingTest.kt b/app/src/test/java/com/bitchat/android/nostr/Bech32PaddingTest.kt new file mode 100644 index 000000000..0600279db --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/Bech32PaddingTest.kt @@ -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) + } + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 05d5e1ad7..f93cd1025 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -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: