commit dda8cad038aa195c14e885cde585e0d36f52243d
parent 44e8ef05badc4290d453aca55041706453108d3e
Author: Jared Tobin <jared@jtobin.io>
Date: Sat, 10 Oct 2026 15:23:23 +0400
lib: share bech32/bech32m decoding, fix length and case checks
Decoding and verification now live in the internal module,
parameterised by encoding, with the public modules as thin wrappers.
* The data part must hold at least the six checksum characters;
previously a five-character data part could decode.
* Mixed-case input is rejected and all-uppercase input is lowercased
before verification, per BIP173, for both encodings. Previously
bech32m decoding accepted mixed-case input whose checksum was
computed over an uppercase HRP, and both encodings rejected
all-uppercase input.
Diffstat:
3 files changed, 80 insertions(+), 40 deletions(-)
diff --git a/lib/Data/ByteString/Bech32.hs b/lib/Data/ByteString/Bech32.hs
@@ -25,8 +25,7 @@ import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Base32 as B32
import qualified Data.ByteString.Bech32.Internal as BI
-import qualified Data.ByteString.Internal as BSI
-import qualified Data.Char as C (toLower, isLower, isAlpha)
+import qualified Data.Char as C (toLower)
create_checksum :: BS.ByteString -> BS.ByteString -> BS.ByteString
create_checksum = BI.create_checksum BI.Bech32
@@ -51,28 +50,22 @@ encode (B8.map C.toLower -> hrp) (B32.encode -> dat) = do
-- | Decode a bech32-encoded 'ByteString' into its human-readable and data
-- parts.
--
+-- All-uppercase input is accepted, and its human-readable part is
+-- returned in lowercase. Mixed-case input produces 'Nothing'.
+--
-- >>> decode "hi1df6x7cnfdcs8wctnyp5x2un9wed5st"
-- Just ("hi","jtobin was here")
-- >>> decode "hey1df6x7cnfdcs8wctnyp5x2un9wed5st" -- s/hi/hey
-- Nothing
decode
- :: BS.ByteString -- ^ bech23-encoded bytestring
+ :: BS.ByteString -- ^ bech32-encoded bytestring
-> Maybe (BS.ByteString, BS.ByteString) -- ^ (hrp, data less checksum)
-decode bs@(BSI.PS _ _ l) = do
- guard (l <= 90)
- guard (B8.all (\a -> if C.isAlpha a then C.isLower a else True) bs)
- guard (verify bs)
- sep <- BS.elemIndexEnd 0x31 bs
- case BS.splitAt sep bs of
- (hrp, raw) -> do
- guard (BI.valid_hrp hrp)
- guard (BS.length raw >= 6)
- (_, BS.dropEnd 6 -> bech32dat) <- BS.uncons raw
- dat <- B32.decode bech32dat
- pure (hrp, dat)
+decode = BI.decode BI.Bech32
-- | Verify that a bech32 string has a valid checksum.
--
+-- All-uppercase input is accepted; mixed-case input is not.
+--
-- >>> verify "bc1d4ujqum5wf5kuecmu02w2"
-- True
-- >>> verify "bc1d4ujquw5wf5kuecmu02w2" -- s/m/w
diff --git a/lib/Data/ByteString/Bech32/Internal.hs b/lib/Data/ByteString/Bech32/Internal.hs
@@ -9,11 +9,14 @@ module Data.ByteString.Bech32.Internal (
, create_checksum
, verify
, valid_hrp
+ , decode
) where
+import Control.Monad (guard)
import Data.Bits ((.&.), (.|.))
import qualified Data.Bits as B
import qualified Data.ByteString as BS
+import qualified Data.ByteString.Base32 as B32
import Data.ByteString.Base32.Internal (enc_tab, dec_tab)
import qualified Data.ByteString.Internal as BI
import qualified Data.ByteString.Unsafe as BU
@@ -138,16 +141,66 @@ create_checksum enc hrp dat =
pokeElemOff dst 4 (fi (pm `B.shiftR` 5) .&. 0x1f :: Word8)
pokeElemOff dst 5 (fi pm .&. 0x1f :: Word8)
+-- | Lowercase an all-uppercase string. Mixed-case strings (some
+-- ASCII uppercase and some ASCII lowercase characters) are rejected,
+-- per BIP173.
+normalize_case :: BS.ByteString -> Maybe BS.ByteString
+normalize_case bs
+ | has_upper && BS.any is_lower bs = Nothing
+ | has_upper = Just $! BS.map to_lower bs
+ | otherwise = Just bs
+ where
+ has_upper = BS.any is_upper bs
+
+is_upper :: Word8 -> Bool
+is_upper c = c >= 0x41 && c <= 0x5A
+{-# INLINE is_upper #-}
+
+is_lower :: Word8 -> Bool
+is_lower c = c >= 0x61 && c <= 0x7A
+{-# INLINE is_lower #-}
+
+to_lower :: Word8 -> Word8
+to_lower c
+ | is_upper c = c + 0x20
+ | otherwise = c
+{-# INLINE to_lower #-}
+
+-- | Does a lowercase hrp and data part (5-bit values plus a 6-value
+-- checksum, as bech32 characters) carry a valid checksum?
+valid_checksum :: Encoding -> BS.ByteString -> BS.ByteString -> Bool
+valid_checksum enc hrp dat
+ | BS.length dat < 6 = False
+ | otherwise = case as_word5 dat of
+ Nothing -> False
+ Just ws -> polymod (hrp_expand hrp <> ws) == case enc of
+ Bech32 -> 1
+ Bech32m -> _BECH32M_CONST
+
+-- | Verify that a bech32 or bech32m string has a valid checksum.
+-- All-uppercase input is accepted; mixed-case input is not.
verify :: Encoding -> BS.ByteString -> Bool
-verify enc b32 = case BS.elemIndexEnd 0x31 b32 of
- Nothing -> False
- Just idx ->
- let (hrp, BU.unsafeDrop 1 -> dat) = BS.splitAt idx b32
- w5s = as_word5 dat
- in case w5s of
- Nothing -> False
- Just ws ->
- let bs = hrp_expand hrp <> ws
- in polymod bs == case enc of
- Bech32 -> 1
- Bech32m -> _BECH32M_CONST
+verify enc b32 = case normalize_case b32 of
+ Nothing -> False
+ Just bs -> case BS.elemIndexEnd 0x31 bs of
+ Nothing -> False
+ Just idx ->
+ let (hrp, BU.unsafeDrop 1 -> dat) = BS.splitAt idx bs
+ in valid_checksum enc hrp dat
+
+-- | Decode a bech32 or bech32m string into its (lowercase)
+-- human-readable part and base256 data part. All-uppercase input
+-- is accepted; mixed-case input is not.
+decode
+ :: Encoding
+ -> BS.ByteString -- ^ bech32-encoded bytestring
+ -> Maybe (BS.ByteString, BS.ByteString) -- ^ (hrp, data less checksum)
+decode enc b32 = do
+ guard (BS.length b32 <= 90)
+ bs <- normalize_case b32
+ sep <- BS.elemIndexEnd 0x31 bs
+ let (hrp, BU.unsafeDrop 1 -> raw) = BS.splitAt sep bs
+ guard (valid_hrp hrp)
+ guard (valid_checksum enc hrp raw)
+ dat <- B32.decode (BS.dropEnd 6 raw)
+ pure (hrp, dat)
diff --git a/lib/Data/ByteString/Bech32m.hs b/lib/Data/ByteString/Bech32m.hs
@@ -26,7 +26,6 @@ import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Base32 as B32
import qualified Data.ByteString.Bech32.Internal as BI
-import qualified Data.ByteString.Internal as BSI
import qualified Data.Char as C (toLower)
create_checksum :: BS.ByteString -> BS.ByteString -> BS.ByteString
@@ -52,27 +51,22 @@ encode (B8.map C.toLower -> hrp) (B32.encode -> dat) = do
-- | Decode a bech32m-encoded 'ByteString' into its human-readable and data
-- parts.
--
+-- All-uppercase input is accepted, and its human-readable part is
+-- returned in lowercase. Mixed-case input produces 'Nothing'.
+--
-- >>> decode "hi1df6x7cnfdcs8wctnyp5x2un9m9ac4f"
-- Just ("hi","jtobin was here")
-- >>> decode "hey1df6x7cnfdcs8wctnyp5x2un9m9ac4f" -- s/hi/hey
-- Nothing
decode
- :: BS.ByteString -- ^ bech23-encoded bytestring
+ :: BS.ByteString -- ^ bech32m-encoded bytestring
-> Maybe (BS.ByteString, BS.ByteString) -- ^ (hrp, data less checksum)
-decode bs@(BSI.PS _ _ l) = do
- guard (l <= 90)
- guard (verify bs)
- sep <- BS.elemIndexEnd 0x31 bs
- case BS.splitAt sep bs of
- (hrp, raw) -> do
- guard (BI.valid_hrp hrp)
- guard (BS.length raw >= 6)
- (_, BS.dropEnd 6 -> bech32dat) <- BS.uncons raw
- dat <- B32.decode bech32dat
- pure (hrp, dat)
+decode = BI.decode BI.Bech32m
-- | Verify that a bech32m string has a valid checksum.
--
+-- All-uppercase input is accepted; mixed-case input is not.
+--
-- >>> verify "bc1d4ujqum5wf5kuecwqlxtg"
-- True
-- >>> verify "bc1d4ujquw5wf5kuecwqlxtg" -- s/m/w