Merge 77fca90481b0d06db0c37bd6f768a53e9c8bfb07 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit 081bee9c04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 5 deletions

View File

@ -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<String, ByteArray> {
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" }

View File

@ -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))
}
}