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.
This commit is contained in:
Taksh 2026-08-17 10:17:19 +05:30
parent 29baab62e2
commit d7e2559c9c
2 changed files with 20 additions and 0 deletions

View File

@ -28,6 +28,13 @@ object Bech32 {
* alphanumeric mode. Mixed case is invalid and is rejected.
*/
fun decode(bech32String: String): Pair<String, ByteArray> {
// 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" }

View File

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