Skip to content

Instantly share code, notes, and snippets.

@sipa
Last active June 29, 2024 02:18
Show Gist options
  • Save sipa/a9845b37c1b298a7301c33a04090b2eb to your computer and use it in GitHub Desktop.
Save sipa/a9845b37c1b298a7301c33a04090b2eb to your computer and use it in GitHub Desktop.
Bech32 insertion analysis

Analysis of insertion in Bech32 strings

In this document I analyze the error detecting properties of Bech32, including insertion/erasure, and see how things could be improved.

Bech32

The characters in Bech32 strings are interpreted as coefficients are polynomials over GF(32). The numbers in GF(32) will be represented here as integers in the range 0 to 31, inclusive. For details on the arithmetic on these numbers (which differs significantly from normal arithmetic), see this document.

Specifically, the polynomial corresponding to a Bech32 string can be found by first creating a list of coefficients as follows:

  • Start with a 1.
  • For every character c in the HRP, add asciival(c) >> 5 to the list.
  • Add a 0.
  • For every character c in the HRP, add asciival(c) & 31 to the list.
  • For every character in the data part, add its conversion (according to the Bech32 character set) to the list.

Then use that list as the coefficients of the polynomial, from high degree to low degree. For example "a12uel5l" would expand to [1,3,0,1,10,28,25,31,20,31], corresponding to the polynomial x9 + 3x8 + x6 + 10x5 + 28x4 + 25x3 + 31x2 + 20x + 31.

A valid Bech32 string is one where the polynomial p(x) corresponding to the string satisfies the equation p(x) mod g(x) = 1, where g(x) is the Bech32 generator, equal to x6 + 29x5 + 22x4 + 20x3 + 21x2 + 29x + 18. This is accomplished by choosing the last six characters of the data part as a checksum in such a way that this equation is true.

In what follows there will be manipulations of polynomials modulo g(x). g(x) is not primitive (it can be written as the product of 3 quadratic polynomials), and thus operations modulo g(x) do not constitute a field. In particular, not every element has an inverse, but many do.

Detection of substitution errors

Given a valid polynomial p(x) and an error polynomial e(x) (whose coefficients represent the per-character difference; for small errors only a few coefficients of e will be nonzero), we wonder when p(x) + e(x) is valid as well. We know that p(x) mod g(x) = 1, and want to know when (p(x) + e(x)) mod g(x) = 1. By subtracting the first equation from the second, we get e(x) mod g(x) = 0. In other words: errors are not detected when their error polynomial is a multiple of g(x).

Through the BCH construction we know that any polynomial of degree below 1023 with at most 3 non-zero coefficients will not be a multiple of g(x). This follows from how g(x) was constructed as the least common multiple of minimal polynomials of 3 successive powers of an order-1023 element in an extension field. Through exhaustive analysis, we also know that any polynomial with degree below 89 with at most 4 non-zero coefficients will also not be a multiple of g(x). This guarantees that any 4 errors within a window of 89 characters will always be detected.

Detection of swaps of adjacent characters

To determine how Bech32 performs w.r.t. swaps of adjacent characters, let's again look at the effect on the difference in polynomials.

If we have a valid string "a b c d e f g h" (where each letter is a variable representing one character), and swap the "e" and "f" ones, the per character differences with "a b c d f e g h" will be "0 0 0 0 (e-f) (f-e) 0 0". In GF(32), negation is the same as addition, so this is identical to "0 0 0 0 (e+f) (e+f) 0 0" and we observe that such a swap is in fact a substition error of the same value to two adjacent positions. This is generally the case for all adjacent swaps.

Translating the above to polynomials, this means that the error polynomial for a (number of) swaps is in fact the error polynomial for just the changes to the right-hand side character of the swap multiplied by (x + 1). If that right-hand-only polynomial has at most 4 non-zero terms (corresponding to 4 swaps), we know from the previous paragraph that it cannot be a multiple of g(x). Since (x + 1) is not a divisor of g(x), multiplying it with cannot change its modulus from nonzero to zero. As a result, any error that consists purely of up to 4 adjacent character swaps will also be detected.

Using exhaustive analysis, it can also be shown that one swap and two subsititions or two swaps and one substitution will also always be detected. I have not found an algebraic argument for this.

Detection of insertion errors

In this section we will analyze single-insertion errors. Specifically, given lengths LBegin (≥ 0), LEnd (≥ 0), and LInsert (≥ 1), then what is the probability over all strings Begin, End, and Insert of those lengths for which Begin || End is valid, that Begin || Insert || End is also valid? In other words, when is inserting an LInsert-character string in position LBegin from the beginning and LEnd from the end, not detected?

Note that since deletion is the reverse of insertion, it does not need to be analyzed separately: if inserting a string is not detected, then deleting the same string from the result is also not detected.

  • Let begin(x), end(x), and insert(x) be polynomials corresponding to the strings Begin, End, and Insert.
  • The old, valid polynomial corresponding to Begin || End is then old(x) = xLEnd begin(x) + end(x)
  • The new polynomial corresponding to Begin || Insert || End is new(x) = xLEnd + LInsert begin(x) + x LEnd insert(x) + end(x).
  • We know old(x) - 1 = 0 mod g(x).
  • We want to know when new(x) - 1 = 0 mod g(x).

Given the knowledge that old(x) - 1 = 0 mod g(x), we can either:

  • Eliminate begin(x):
    • new(x) - 1 = 0 mod g(x)
    • ⬄ (new(x) - 1) - xLInsert (old(x) - 1) = 0 mod g(x).
    • ⬄ (xLEnd + LInsert begin(x) + x LEnd insert(x) + end(x) - 1) - xLInsert (xLEnd begin(x) + end(x) - 1) = 0 mod g(x).
    • ⬄ x LEnd insert(x) + (1 - xLInsert) (end(x) - 1) = 0 mod g(x).
    • ⬄ end(x) = 1 + x LEnd insert(x) / (xLInsert - 1) mod g(x).
  • Eliminate end(x):
    • new(x) - 1 = 0 mod g(x)
    • ⬄ (new(x) - 1) - (old(x) - 1) = 0 mod g(x).
    • ⬄ xLEnd + LInsert begin(x) + x LEnd insert(x) + end(x) - xLEnd begin(x) - end(x) mod g(x).
    • ⬄ xLInsert begin(x) + insert(x) - begin(x) mod g(x).
    • ⬄ begin(x) = insert(x) / (1 - xLInsert) mod g(x).

In both results, the left and right side of the equation have independent distributions (because we pick insert(x) independently from old(x)). That means that if end(x) mod g(x), begin(x) mod g(x), or insert(x) mod g(x) is uniformly distributed over all 230 possibilities, the equation will only hold with probability 2-30</sup, which is acceptably low.

So when is this the case? Unfortunately, begin(x) and end(x) are not independently distributed. For example, when LBegin = 2, we know that begin(x) only takes 1024 possible values. As a result, because old(x) is a valid polynomial that incorporates both begin(x) and end(x), end(x) mod g(x) also only takes 1024 possible values.

When both LBegin and LEnd are at least 6 however, this is not an issue - both begin(x) mod g(x) and end(x) mod g(x) can then take on all values, and we're good.

When LInsert is at least 6, the right hand side is uniformly distributed and we're good as well.

When LEnd and LInsert are both less than 6, we can use the above equation end(x) = 1 + x LEnd insert(x) / (xLInsert - 1) mod g(x) to analyze the detection abilities. Simply iterate over all possible values of insert(x) (32LInsert possibilities, which is at most about 33 million), and see when the result matches a possible end(x) for the given LEnd value (e.g. when LEnd is 4, end(x) will have degree at most 4-1). The results are in the table below:

LEnd=0 LEnd=1 LEnd=2 LEnd=3 LEnd=4 LEnd=5
LInsert=1 ✔️ 0 2-10 2-15 2-20 2-25 ✔️ 2-30
LInsert=2 ✔️ 0 2-15 2-20 2-25 ✔️ 2-30 ✔️ 2-30
LInsert=3 ✔️ 0 2-20 2-25 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30
LInsert=4 ✔️ 0 2-25 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30
LInsert=5 ✔️ 0 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30

The crosses in this table exactly correspond to the known issue in Bech32. For example, LInsert=2 LEnd=3 corresponds to inserting 2 characters before the 3rd last character. If the last 3 characters are "qqp", then it is indeed possible to insert "qq" before them according to that issue. Over all 2 character inserts and all 3 character suffices, requiring them to be exactly "qqp" and "qq" fixes 5 characters, so has a probability of 32-5 = 2-25.

When LBegin and LInsert are both less than 6, we may worry about similar concerns. However, since begin(x) always contains the expansion of the prefix (and in BIP173 addresses, the witness version), we can't simply assume it is going to be uniform to begin with. This is an advantage and a disadvantage: the advantage is that even with LBegin very low (close to the length of the HRP's expansion), begin(x) won't be a low-degree polynomial, so on average, the same issue does not occur. The disadvantage is that to make sure, we need to run the analysis on begin(x) = insert(x) / (1 - xLInsert) mod g(x) specifically for every HRP/prefix we're interested in. When running the analysis for the "bc1" prefix used for BIP173, the behavior is as expected.

Improving detection of insertion errors

To see how we can do better, let's change the Bech32 equation from p(x) = 1 mod g(x) to the more general p(x) = m(x) mod g(x). The low-LEnd equation above then becomes end(x) = m(x) + x LEnd insert(x) / (xLInsert - 1) mod g(x). The low-LBegin equation is unaffected.

If we pick m(x) = 31x5 + 31x4 + 31x3 + 31x2 + 31x + 31, and run the low-LEnd analysis, we get the much more satisfying table below:

LEnd=0 LEnd=1 LEnd=2 LEnd=3 LEnd=4 LEnd=5
LInsert=1 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 2-30
LInsert=2 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 2-30 ✔️ 2-30
LInsert=3 ✔️ 0 ✔️ 0 ✔️ 0 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30
LInsert=4 ✔️ 0 ✔️ 0 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30
LInsert=5 ✔️ 0 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30 ✔️ 2-30

This would address all known weaknesses in the scheme, without worsening any known detection qualities. To see why, note that the substituation/swap detection property only depends on the choice of the generator g(x), and that the detection abilities for low-LBegin values are independent from the choice of m(x) (they depend on the HRP expansion instead)*.

It is also only a small code change. The BIP173 reference could would need to be modified from

def bech32_verify_checksum(hrp, data):
  return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1

def bech32_create_checksum(hrp, data):
  values = bech32_hrp_expand(hrp) + data
  polymod = bech32_polymod(values + [0,0,0,0,0,0]) ^ 1
  return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]

into

M = 0x3FFFFFFF

def bech32_verify_checksum(hrp, data):
  return bech32_polymod(bech32_hrp_expand(hrp) + data) == M

def bech32_create_checksum(hrp, data):
  values = bech32_hrp_expand(hrp) + data
  polymod = bech32_polymod(values + [0,0,0,0,0,0]) ^ M
  return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]

Conclusion

Using various types of analysis, it is possible to determine that Bech32 behaves well in the presence of substitution and character swapping errors. Furthermore, among a wide class of single-insertion or single-deletion errors (of potentially multiple characters), the possibility of inserting/deleting "q" characters right before a final "p" is the only unexpected deviation from its intended detection abilities (not more than 1 in a billion failure rate for other errors than up to 4 substitutions).

Finally, it is possible to modify a constant in Bech32 to obtain a variant that fixes this issue, without worsening any of the other qualities that were analyzed.

import random
# The binary field
B = GF(2)
# The ring of polynomials over the binary field
BP.<bp> = B[]
# The field GF(32) (represented as polynomials over the binary field modulo x^5 + x^3 + 1)
F.<f> = GF(32, modulus=bp^5+bp^3+1, repr='int')
# The ring of polynomials over GF(32)
FP.<fp> = F[]
def fromnum(n):
ret = FP(0)
for b in range(2 + (Integer(n).nbits() // 5)):
ret += F.fetch_int((n >> (5 * b)) & 31) * fp^b
return ret
# The Bech32 generator
G = fp^6 + F.fetch_int(29)*fp^5 + F.fetch_int(22)*fp^4 + F.fetch_int(20)*fp^3 + F.fetch_int(21)*fp^2 + F.fetch_int(20)*fp + F.fetch_int(18)
def to_vec(p):
return vector([(p % G)[j] for j in range(6)])
def to_mat(p, columns):
return Matrix([[((p * fp^j) % G)[i] for j in range(columns)] for i in range(6)])
M = (fp^5 + fp^4 + fp^3 + fp^2 + fp^1 + 1) * F.fetch_int(31)
N = FP(1)
def bads(M, N):
ret = []
for lenB in range(1, 6):
I = (fp^lenB + F.fetch_int(1)).inverse_mod(G)
P = ((M*fp^lenB + N) * I) % G
for lenC in range(6):
CTE = to_vec((M*fp^lenB + N) * I)
LIN = to_mat((fp^lenC) * I, lenB)
num = 0
try:
SOLB = LIN[lenC:6].solve_right(CTE[lenC:6])
SOLC = LIN*SOLB + CTE
num = 32**(lenB - LIN[lenC:6].rank())
except ValueError:
SOLB = None
SOLC = None
num = 0
if num * 32**6 > 32**(lenB+lenC):
ret.append("short=%s long=%s lenB=%i lenC=%i n=%s solb=%s solc=%s" % (M, N, lenB, lenC, num / (32**(lenB+lenC)), SOLB, SOLC))
return ret
def badss(M, N):
return bads(M, N) + bads(N, M) + bads(M, M)
print(badss(M, N))
print(badss(fromnum(0x3fefffff), N))
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <map>
#include <random>
namespace {
uint32_t mulx(uint32_t chk) {
uint32_t top = chk >> 25;
return (chk & 0x1ffffff) << 5 ^
(-((top >> 0) & 1) & 0x3b6a57b2UL) ^
(-((top >> 1) & 1) & 0x26508e6dUL) ^
(-((top >> 2) & 1) & 0x1ea119faUL) ^
(-((top >> 3) & 1) & 0x3d4233ddUL) ^
(-((top >> 4) & 1) & 0x2a1462b3UL);
}
std::map<uint32_t, std::pair<int, int>> TABLE1;
std::map<uint32_t, std::pair<std::pair<int, int>, std::pair<int, int>>> TABLE2;
void build_table() {
for (uint32_t e = 1; e < 32; ++e) {
uint32_t v = e;
for (int p = 0; p < 89; ++p) {
TABLE1[v] = std::pair<int, int>{p, e};
v = mulx(v);
}
}
for (const auto& x1 : TABLE1) {
TABLE2[x1.first] = std::pair<std::pair<int, int>, std::pair<int, int>>{x1.second, {-1, 0}};
for (const auto& x2 : TABLE1) {
if (x1.second.first < x2.second.first) {
TABLE2[x1.first ^ x2.first] = std::pair<std::pair<int, int>, std::pair<int, int>>{x1.second, x2.second};
}
}
}
}
bool tryit(uint32_t s) {
for (const auto& x1 : TABLE1) {
uint32_t v = s ^ x1.first;
auto it = TABLE2.find(v);
if (it == TABLE2.end()) continue;
if (x1.second.first == it->second.first.first && it->second.second.first == -1) continue;
printf("%x produced by (%i, %i), (%i, %i), (%i, %i)\n", (unsigned)s, (int)x1.second.first, (int)x1.second.second, (int)it->second.first.first, (int)it->second.first.second, (int)it->second.second.first, (int)it->second.second.second);
return false;
}
return true;
}
}
int main(void) {
build_table();
/*
std::random_device rng;
std::uniform_int_distribution<uint32_t> dist(0, (1 << 30) - 1);
setbuf(stdout, NULL);
uint64_t cnt = 0;
printf("Running\n");
while (true) {
++cnt;
uint32_t s = dist(rng);
if (tryit(s)) {
} else {
printf("0x%x: ", (unsigned)s);
printf("bad\n");
// printf("bad\n");
}
if ((cnt & 0xfff) == 0) printf("... %lx\n", (unsigned long)cnt);
}
*/
for (const uint32_t m : {1, 0x3fffffff, 0x3fffffff ^ (1 << 0), 0x3fffffff ^ (1 << 1), 0x3fffffff ^ (1 << 2), 0x3fffffff ^ (1 << 3),
0x3fffffff ^ (1 << 4), 0x3fffffff ^ (1 << 5), 0x3fffffff ^ (1 << 6), 0x3fffffff ^ (1 << 7),
0x3fffffff ^ (1 << 8), 0x3fffffff ^ (1 << 9), 0x3fffffff ^ (1 << 10), 0x3fffffff ^ (1 << 11),
0x3fffffff ^ (1 << 12), 0x3fffffff ^ (1 << 13), 0x3fffffff ^ (1 << 14), 0x3fffffff ^ (1 << 15),
0x3fffffff ^ (1 << 16), 0x3fffffff ^ (1 << 17), 0x3fffffff ^ (1 << 18), 0x3fffffff ^ (1 << 19),
0x3fffffff ^ (1 << 20), 0x3fffffff ^ (1 << 21), 0x3fffffff ^ (1 << 22), 0x3fffffff ^ (1 << 23),
0x3fffffff ^ (1 << 24), 0x3fffffff ^ (1 << 25), 0x3fffffff ^ (1 << 26), 0x3fffffff ^ (1 << 27), 0x3fffffff ^ (1 << 28), 0x3fffffff ^ (1 << 29)}) {
printf("%x: %s\n", (unsigned)m, tryit(m ^ 1) ? "good" : "bad");
}
return 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment