bech32

Fast Haskell bech32, bech32m encoding/decoding (docs.ppad.tech/bech32).
git clone git://git.ppad.tech/bech32.git
Log | Files | Refs | README | LICENSE

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:
Mlib/Data/ByteString/Bech32.hs | 23++++++++---------------
Mlib/Data/ByteString/Bech32/Internal.hs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mlib/Data/ByteString/Bech32m.hs | 20+++++++-------------
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