From b542a1b8740faeabffde04a04f5ba4dd1a5e6ecc Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 08:31:37 +0530 Subject: [PATCH 1/4] fix(nostr): accept an uppercase npub, reject a mixed-case one BIP-173 defines a bech32 string as all lowercase or all uppercase, and declares mixed case invalid. The decoder only looked up lowercase characters in the charset, so an all-uppercase npub - the form QR encoders emit, because uppercase fits the denser alphanumeric mode - failed with "Invalid character" before the checksum was ever checked. Normalise case once, after rejecting a mixed-case string outright. ContactIdentityResolver already worked around this by lowercasing at the call site; NostrClient.sendPrivateMessage does not, so an npub that came in uppercase could not be messaged. --- .../java/com/bitchat/android/nostr/Bech32.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 3e55ab67..7ad59f57 100644 --- a/app/src/main/java/com/bitchat/android/nostr/Bech32.kt +++ b/app/src/main/java/com/bitchat/android/nostr/Bech32.kt @@ -22,14 +22,23 @@ object Bech32 { /** * Decode bech32 string * Returns (hrp, data) pair + * + * Per BIP-173 a bech32 string is either all lowercase or all uppercase; + * the uppercase form is what QR encoders emit, since it fits the compact + * alphanumeric mode. Mixed case is invalid and is rejected. */ fun decode(bech32String: String): Pair { - val separatorIndex = bech32String.lastIndexOf('1') + val hasLower = bech32String.any { it.isLowerCase() } + val hasUpper = bech32String.any { it.isUpperCase() } + require(!(hasLower && hasUpper)) { "Mixed case" } + val normalized = bech32String.lowercase() + + val separatorIndex = normalized.lastIndexOf('1') require(separatorIndex >= 0) { "No separator found" } - - val hrp = bech32String.substring(0, separatorIndex) - val dataString = bech32String.substring(separatorIndex + 1) - + + val hrp = normalized.substring(0, separatorIndex) + val dataString = normalized.substring(separatorIndex + 1) + // Validate HRP contains only ASCII require(hrp.all { it.code < 128 }) { "Invalid HRP characters" } From 29baab62e280763bac6d5a168ae6a1442f6895aa Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 08:31:37 +0530 Subject: [PATCH 2/4] test(nostr): cover bech32 case handling Uppercase decodes to the same payload as lowercase, mixed case is rejected, a corrupted uppercase string still fails on the checksum rather than on the charset, and encode/decode round-trips. --- .../bitchat/android/nostr/Bech32CaseTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt diff --git a/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt b/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt new file mode 100644 index 00000000..1f66c593 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt @@ -0,0 +1,53 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * BIP-173 allows a bech32 string to be all lowercase or all uppercase, and + * forbids mixing the two. The uppercase form is what QR encoders emit — it fits + * the alphanumeric mode, which is denser — so an npub scanned from a QR code + * can arrive uppercased. + */ +class Bech32CaseTest { + + private val pubkey = ByteArray(32) { (it + 1).toByte() } + + @Test + fun `uppercase decodes to the same payload as lowercase`() { + val npub = Bech32.encode("npub", pubkey) + val (lowerHrp, lowerData) = Bech32.decode(npub) + val (upperHrp, upperData) = Bech32.decode(npub.uppercase()) + + assertEquals("npub", lowerHrp) + assertEquals("npub", upperHrp) + assertArrayEquals(pubkey, lowerData) + assertArrayEquals(pubkey, upperData) + } + + @Test + fun `mixed case is rejected`() { + val npub = Bech32.encode("npub", pubkey) + val mixed = npub.substring(0, 6).uppercase() + npub.substring(6) + + assertThrows(IllegalArgumentException::class.java) { Bech32.decode(mixed) } + } + + @Test + fun `a corrupted uppercase string still fails the checksum`() { + val npub = Bech32.encode("npub", pubkey).uppercase() + val flipped = npub.dropLast(1) + if (npub.last() == 'Q') 'P' else 'Q' + + assertThrows(IllegalArgumentException::class.java) { Bech32.decode(flipped) } + } + + @Test + fun `round trip through encode and decode is stable`() { + val npub = Bech32.encode("npub", pubkey) + val (hrp, data) = Bech32.decode(npub) + assertEquals("npub", hrp) + assertEquals(npub, Bech32.encode(hrp, data)) + } +} From d7e2559c9c2d41c909e807b21021bf3376c86d38 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:17:19 +0530 Subject: [PATCH 3/4] fix(nostr): reject non-ascii characters before folding case BIP-173 restricts a bech32 string to printable US-ASCII (33..126). The check has to run before the case test and before lowercase(): U+212A KELVIN SIGN reads as uppercase, so an otherwise all-uppercase string carrying one shows no mixed case, and lowercase() folds it to an ASCII 'k' -- an npub with a homoglyph in place of a K decoded to the same pubkey as the genuine one. The added test builds that exact string and expects a rejection; it fails on the previous commit. --- .../main/java/com/bitchat/android/nostr/Bech32.kt | 7 +++++++ .../com/bitchat/android/nostr/Bech32CaseTest.kt | 13 +++++++++++++ 2 files changed, 20 insertions(+) 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 7ad59f57..21d0dbfb 100644 --- a/app/src/main/java/com/bitchat/android/nostr/Bech32.kt +++ b/app/src/main/java/com/bitchat/android/nostr/Bech32.kt @@ -28,6 +28,13 @@ object Bech32 { * alphanumeric mode. Mixed case is invalid and is rejected. */ fun decode(bech32String: String): Pair { + // BIP-173 restricts every character to printable US-ASCII, 33..126. + // This has to run before the case test and before lowercase(): a + // non-ASCII uppercase homoglyph such as U+212A KELVIN SIGN reads as + // uppercase, so an otherwise all-uppercase string carrying one shows no + // mixed case, and lowercase() then folds it into an ASCII 'k'. + require(bech32String.all { it.code in 33..126 }) { "Invalid character" } + val hasLower = bech32String.any { it.isLowerCase() } val hasUpper = bech32String.any { it.isUpperCase() } require(!(hasLower && hasUpper)) { "Mixed case" } diff --git a/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt b/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt index 1f66c593..ffad6987 100644 --- a/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt +++ b/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.nostr import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assume.assumeTrue import org.junit.Test /** @@ -43,6 +44,18 @@ class Bech32CaseTest { assertThrows(IllegalArgumentException::class.java) { Bech32.decode(flipped) } } + @Test + fun `a non-ascii homoglyph is rejected rather than folded to ascii`() { + val npub = Bech32.encode("npub", pubkey).uppercase() + // U+212A KELVIN SIGN is uppercase and lowercases to an ASCII 'k', so an + // all-uppercase string carrying one passes a naive mixed-case test and + // then folds into a perfectly valid npub. + val kelvin = npub.replace("K", "\u212A") + assumeTrue(kelvin != npub) + + assertThrows(IllegalArgumentException::class.java) { Bech32.decode(kelvin) } + } + @Test fun `round trip through encode and decode is stable`() { val npub = Bech32.encode("npub", pubkey)