Skip to content

Commit

Permalink
bech32: Add DecodeUnsafe for bypassing checksum validation
Browse files Browse the repository at this point in the history
  • Loading branch information
conr2d committed Sep 23, 2021
1 parent 32fa4f6 commit bfb1666
Show file tree
Hide file tree
Showing 2 changed files with 275 additions and 0 deletions.
162 changes: 162 additions & 0 deletions bech32/tweak.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package bech32

import (
"strings"
)

// MaxLengthBIP173 is the maximum length of bech32-encoded address defined by
// BIP-173.
const MaxLengthBIP173 = 90

// VerifyChecksum verifies whether the bech32 string specified by the
// provided hrp and payload data (encoded as 5 bits per element byte slice) has
// the correct checksum suffix. The version of bech32 used (bech32 OG, or
// bech32m) is also returned to allow the caller to perform proper address
// validation (segwitv0 should use bech32, v1+ should use bech32m).
//
// For more details on the checksum verification, please refer to BIP 173.
func VerifyChecksum(hrp string, data []byte, checksum []byte) (Version, bool) {
polymod := bech32Polymod(hrp, data, checksum)

// Before BIP-350, we'd always check this against a static constant of
// 1 to know if the checksum was computed properly. As we want to
// generically support decoding for bech32m as well as bech32, we'll
// look up the returned value and compare it to the set of defined
// constants.
bech32Version, ok := ConstsToVersion[ChecksumConst(polymod)]
if ok {
return bech32Version, true
}

return VersionUnknown, false
}

// Normalize converts the uppercase letters to lowercase in string, because
// Bech32 standard uses only the lowercase for of string for checksum calculation.
// If conversion occurs during function call, `true` will be returned.
//
// Mixed case is NOT allowed.
func Normalize(bech *string) (bool, error) {
// OnlyASCII characters between 33 and 126 are allowed.
var hasLower, hasUpper bool
for i := 0; i < len(*bech); i++ {
if (*bech)[i] < 33 || (*bech)[i] > 126 {
return false, ErrInvalidCharacter((*bech)[i])
}

// The characters must be either all lowercase or all uppercase. Testing
// directly with ascii codes is safe here, given the previous test.
hasLower = hasLower || ((*bech)[i] >= 97 && (*bech)[i] <= 122)
hasUpper = hasUpper || ((*bech)[i] >= 65 && (*bech)[i] <= 90)
if hasLower && hasUpper {
return false, ErrMixedCase{}
}
}

// Bech32 standard uses only the lowercase for of strings for checksum
// calculation.
if hasUpper {
*bech = strings.ToLower(*bech)
return true, nil
}

return false, nil
}

// DecodeUnsafe decodes a bech32 encoded string, returning the human-readable
// part, the data part (excluding the checksum) and the checksum. This function
// does NOT validate against the BIP-173 maximum length allowed for bech32 strings
// and is meant for use in custom applications (such as lightning network payment
// requests), NOT on-chain addresses. This function assumes the given string
// includes lowercase letters only, so if not, you should call Normalize first.
//
// Note that the returned data is 5-bit (base32) encoded and the human-readable
// part will be lowercase.
func DecodeUnsafe(bech string) (string, []byte, []byte, error) {
// The string is invalid if the last '1' is non-existent, it is the
// first character of the string (no human-readable part) or one of the
// last 6 characters of the string (since checksum cannot contain '1').
one := strings.LastIndexByte(bech, '1')
if one < 1 || one+7 > len(bech) {
return "", nil, nil, ErrInvalidSeparatorIndex(one)
}

// The human-readable part is everything before the last '1'.
hrp := bech[:one]
data := bech[one+1:]

// Each character corresponds to the byte with value of the index in
// 'charset'.
decoded, err := toBytes(data)
if err != nil {
return "", nil, nil, err
}

return hrp, decoded[:len(decoded)-6], decoded[len(decoded)-6:], nil
}

// decodeWithLimit is a bech32 checksum version aware bounded string length
// decoder. This function will return the version of the decoded checksum
// constant so higher level validation can be performed to ensure the correct
// version of bech32 was used when encoding.
func decodeWithLimit(bech string, limit int) (string, []byte, Version, error) {
// The length of the string should not exceed the given limit.
if len(bech) < 8 || len(bech) > limit {
return "", nil, VersionUnknown, ErrInvalidLength(len(bech))
}

_, err := Normalize(&bech)
if err != nil {
return "", nil, VersionUnknown, err
}

hrp, data, checksum, err := DecodeUnsafe(bech)
if err != nil {
return "", nil, VersionUnknown, err
}

// Verify if the checksum (stored inside decoded[:]) is valid, given the
// previously decoded hrp.
bech32Version, ok := VerifyChecksum(hrp, data, checksum)
if !ok {
// Invalid checksum. Calculate what it should have been, so that the
// error contains this information.

// Extract the payload bytes and actual checksum in the string.
actual := bech[len(bech)-6:]

// Calculate the expected checksum, given the hrp and payload
// data. We'll actually compute _both_ possibly valid checksum
// to further aide in debugging.
var expectedBldr strings.Builder
expectedBldr.Grow(6)
writeBech32Checksum(hrp, data, &expectedBldr, Version0)
expectedVersion0 := expectedBldr.String()

var b strings.Builder
b.Grow(6)
writeBech32Checksum(hrp, data, &expectedBldr, VersionM)
expectedVersionM := expectedBldr.String()

err = ErrInvalidChecksum{
Expected: expectedVersion0,
ExpectedM: expectedVersionM,
Actual: actual,
}
return "", nil, VersionUnknown, err
}

// We exclude the last 6 bytes, which is the checksum.
return hrp, data, bech32Version, nil

}

// DecodeWithLimit decodes a bech32 encoded string, returning the human-readable part and
// the data part excluding the checksum.
//
// Note that the returned data is 5-bit (base32) encoded and the human-readable
// part will be lowercase.
func DecodeWithLimit(bech string, limit int) (string, []byte, error) {
hrp, data, _, err := decodeWithLimit(bech, limit)
return hrp, data, err
}
113 changes: 113 additions & 0 deletions bech32/tweak_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2017-2020 The btcsuite developers
// Copyright (c) 2019 The Decred developers
// Use of this source code is governed by an ISC
// license that can be found in the LICENSE file.

package bech32

import (
"strings"
"testing"
)

// TestBech32Tweak tests whether decoding and re-encoding the valid BIP-173 test
// vectors works and if decoding invalid test vectors fails for the correct
// reason.
func TestBech32Tweak(t *testing.T) {
tests := []struct {
str string
expectedError error
}{
{"A12UEL5L", nil},
{"a12uel5l", nil},
{"an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", nil},
{"abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", nil},
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", nil},
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", nil},
{"split1checkupstagehandshakeupstreamerranterredcaperred2y9e2w", ErrInvalidChecksum{"2y9e3w", "2y9e3wlc445v", "2y9e2w"}}, // invalid checksum
{"s lit1checkupstagehandshakeupstreamerranterredcaperredp8hs2p", ErrInvalidCharacter(' ')}, // invalid character (space) in hrp
{"spl\x7Ft1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", ErrInvalidCharacter(127)}, // invalid character (DEL) in hrp
{"split1cheo2y9e2w", ErrNonCharsetChar('o')}, // invalid character (o) in data part
{"split1a2y9w", ErrInvalidSeparatorIndex(5)}, // too short data part
{"1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", ErrInvalidSeparatorIndex(0)}, // empty hrp
{"11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", ErrInvalidLength(91)}, // too long

// Additional test vectors used in bitcoin core
{" 1nwldj5", ErrInvalidCharacter(' ')},
{"\x7f" + "1axkwrx", ErrInvalidCharacter(0x7f)},
{"\x801eym55h", ErrInvalidCharacter(0x80)},
{"an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", ErrInvalidLength(91)},
{"pzry9x0s0muk", ErrInvalidSeparatorIndex(-1)},
{"1pzry9x0s0muk", ErrInvalidSeparatorIndex(0)},
{"x1b4n0q5v", ErrNonCharsetChar(98)},
{"li1dgmt3", ErrInvalidSeparatorIndex(2)},
{"de1lg7wt\xff", ErrInvalidCharacter(0xff)},
{"A1G7SGD8", ErrInvalidChecksum{"2uel5l", "2uel5llqfn3a", "g7sgd8"}},
{"10a06t8", ErrInvalidLength(7)},
{"1qzzfhee", ErrInvalidSeparatorIndex(0)},
{"a12UEL5L", ErrMixedCase{}},
{"A12uEL5L", ErrMixedCase{}},
}

for i, test := range tests {
str := test.str
hrp, decoded, err := DecodeWithLimit(str, MaxLengthBIP173)
if test.expectedError != err {
t.Errorf("%d: expected decoding error %v "+
"instead got %v", i, test.expectedError, err)
continue
}

if err != nil {
// End test case here if a decoding error was expected.
continue
}

// Check that it encodes to the same string
encoded, err := Encode(hrp, decoded)
if err != nil {
t.Errorf("encoding failed: %v", err)
}

if encoded != strings.ToLower(str) {
t.Errorf("expected data to encode to %v, but got %v",
str, encoded)
}

// Flip a bit in the string an make sure it is caught.
pos := strings.LastIndexAny(str, "1")
flipped := str[:pos+1] + string((str[pos+1] ^ 1)) + str[pos+2:]
_, _, err = DecodeWithLimit(flipped, MaxLengthBIP173)
if err == nil {
t.Error("expected decoding to fail")
}
}
}

// BenchmarkDecodeUnsafe performs a benchmark for a decode cycle of a bech32
// string without normalization and checksum validation.
func BenchmarkDecodeUnsafe(b *testing.B) {
encoded := "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7"

b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _, err := DecodeUnsafe(encoded)
if err != nil {
b.Fatalf("error converting bits: %v", err)
}
}
}

// BenchmarkDecode performs a benchmark for a decode cycle of a bech32 string
// with normalization and checksum validation.
func BenchmarkDecode(b *testing.B) {
encoded := "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7"

b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, err := DecodeWithLimit(encoded, MaxLengthBIP173)
if err != nil {
b.Fatalf("error converting bits: %v", err)
}
}
}

0 comments on commit bfb1666

Please sign in to comment.