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..21d0dbfb 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,30 @@ 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') + // 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" } + 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" } 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..ffad6987 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/nostr/Bech32CaseTest.kt @@ -0,0 +1,66 @@ +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 + +/** + * 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 `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) + val (hrp, data) = Bech32.decode(npub) + assertEquals("npub", hrp) + assertEquals(npub, Bech32.encode(hrp, data)) + } +}