Skip to content

Weakness in Bech32 Encoding Scheme #656

@wasdhjklxyz

Description

@wasdhjklxyz

TLDR

Public/private keys are encoded using Bech32 which has a weakness. The solution to this weakness seems to be frivolous to implement unless a new major version for age is considered.

Overview

The current implementation uses Bech32 to encode identities/recipients into strings. However, BIP 0350 states that there is a weakness in the original Bech32 implementation. During testing, I have not been able to parse malformed keys, using age.ParseX25519Recipient(), that does not result in an error. However, a portion of these malformed keys are able to get past the bech32.Decode() function without resulting in an error.

Bech32 Weakness

The following is directly taken from an issue in github:sipa/bech32. For further details please see the original issue or BIP 0350.

For certain Bech32 strings, deleting or inserting just a single character can produce a string that is still valid. There appear to be two main cases:

  1. Insertion: if a valid Bech32 string has the suffix p, inserting a single q character immediately before the p will produce another valid Bech32 string.
  2. Deletion: if a valid Bech32 string has the suffix qp, removing the q character will produce another valid Bech32 string.

Bech32m

The solution to Bech32's weakness is Bech32m, as outlined in BIP 0350. It simply modifies the checksum of the Bech32 specification, replacing the constant 1 that is xored into the checksum at the end with 0x2bc830a3.

Issues

During my implementation of Bech32m, I started to wonder if a pull request is necessary for this type of issue for the following reasons:

No Issues To-Date

Bech32m, more specifically BIP 0350 has been around since 2020 and no issues have stated anything about this.

Versioning

If Bech32m were to be implemented, age would have to start versioning how the keys are encoded as strings. Bech32 uses the segwit address format of:

  • The human-readable part "age" or "AGE-SECRET-KEY-"
  • One character identifying the witness version
  • The actual characters and checksum that make up the key

If Bech32m were to be implemented, a new witness version would have to be created (v2 in our case) meaning a parser for age keys would have to recognize if a string was encoded using Bech32 or Bech32m. This is all to cope with backwards compatability since all age keys are hard-coded using "1" as the witness version.

Building off of my "no issues to-date" reason, since everything has been "fine" so far, I don't see a reason to not support the use of Bech32 keys unless a new major version was used. In a new minor version, either a warning or some system to automatically upgrade old Bech32 encoded-keys to their Bech32m counterparts could be an additional feature. Simply put, Bech32 keys should still be supported, but deprecated, until a new major version of age is released. Even then I'm not sure.

Test Results

During my evaluation of Bech32's weakness, I found that only the insertion case, during the bech32.Decode() call would fail around 50% of the time. Since this is an internal function it is not a huge deal (?) and all calls to ParseX25519Identity() and ParseX25519Recipient() fail with malformed keys anyways, why bother?

One can make the argument that it is not failing where it is supposed to, and I agree; however, properly implementing a fix to this would require a full implementation of Bech32m, which I'm unsure is necessary or in scope for this project. I will say, I think rolling out a new witness version and generating new identities using Bech32m is the best way to handle this issue.

Testing & Examples

To find out if this flaw affects age, I have created a branch to demonstrate. To briefly summarize, I generated identities using age.GenerateX25519Identity() and checked the suffix of the generated identity/recipient's string for either p, qp, P, or QP (capitalization for identity strings). I then created varying cases for different ways Bech32's flaw could impact age.

Seeing how ParseIdentities() uses ParseX25519Identity() and ParseRecipients() uses ParseX25519Recipient() to parse files, I skipped out on using files during my tests.

Case 1

This case generates an identity who's recipient is vulnerable to the insertion case weakness. A malformed recipient is created and then passed to age.ParseX25519Recipient() to see if it either fails (PASS) or successfully (FAIL) parses the malformed key:

Original:   age1u2crjwz8wqmkguuutkhsgp9ua6d8w3el35mmgjntzeky05fwr94qc7dyjp
Malformed:  age1u2crjwz8wqmkguuutkhsgp9ua6d8w3el35mmgjntzeky05fwr94qc7dyjqp
+ (PASS) Failed to parse malformed key:  malformed recipient "age1u2crjwz8wqmkguuutkhsgp9ua6d8w3el35mmgjntzeky05fwr94qc7dyjqp": invalid X25519 public key

Case 2

This case is the exact same as Case 1, except it uses an identity instead of a recipient:

Original:   AGE-SECRET-KEY-1RDXQXZ6H98E04GDHH604W3RT9ZSFMHH479NAM0EKFM80DCATF8YQZCLR5P
Malformed:  AGE-SECRET-KEY-1RDXQXZ6H98E04GDHH604W3RT9ZSFMHH479NAM0EKFM80DCATF8YQZCLR5QP
+ (PASS) Failed to parse malformed key:  malformed secret key: invalid X25519 secret key

Case 3

This case is the exact same as Case 1, except it generates a recipient who is vulnerable to the deletion case weakness:

Original:   age1sdeggq7z3xkwqcdp36qk7mdfsxlx9ck6uz4ejjrx2f5ynkqse3jqx3svqp
Malformed:  age1sdeggq7z3xkwqcdp36qk7mdfsxlx9ck6uz4ejjrx2f5ynkqse3jqx3svp
+ (PASS) Failed to parse malformed key:  malformed recipient "age1sdeggq7z3xkwqcdp36qk7mdfsxlx9ck6uz4ejjrx2f5ynkqse3jqx3svp": illegal zero padding

Case 4

This case is the exact same as Case 3, except it uses an identity instead of a recipient:

Original:   AGE-SECRET-KEY-176KC3PYCYUNFNWQU6CUN4GQP5SA25LF6G83UM8VATJ58NDU2KSUSNDCSQP
Malformed:  AGE-SECRET-KEY-176KC3PYCYUNFNWQU6CUN4GQP5SA25LF6G83UM8VATJ58NDU2KSUSNDCSP
+ (PASS) Failed to parse malformed key:  malformed secret key: illegal zero padding

Case 5

This case generates an identity who's recipient is vulnerable to the insertion case weakness. A malformed recipient is created and then passed to bech32.Decode() to see if it either fails (PASS) or successfully (FAIL) decodes the malformed key:

Original:   age1atc2dz7kfpew7kagdff9tg0xqqjfszrs70py7ds6wanvf94ndgcqwmt4cp
Malformed:  age1atc2dz7kfpew7kagdff9tg0xqqjfszrs70py7ds6wanvf94ndgcqwmt4cqp
- (FAIL) Malformed key successfully decoded!

And in another run:

Original:   age1xgtlk6ngwh37czjnh2d4df0axgt84rs9gtx2wmyxdqux5knzeawqn8qq3p
Malformed:  age1xgtlk6ngwh37czjnh2d4df0axgt84rs9gtx2wmyxdqux5knzeawqn8qq3qp
+ (PASS) Failed to decode malformed key:  non-zero padding

Case 6

This case is the exact same as Case 5, except it uses an identity instead of a recipient:

Original:   AGE-SECRET-KEY-1JT0Y5MPYS3AZWRHJ58R22NUXCQPUAYYJ9ESD35S5Z7U4L5C0ZC4Q7ZEHCP
Malformed:  AGE-SECRET-KEY-1JT0Y5MPYS3AZWRHJ58R22NUXCQPUAYYJ9ESD35S5Z7U4L5C0ZC4Q7ZEHCQP
- (FAIL) Malformed key successfully decoded!

And in another run:

Original:   AGE-SECRET-KEY-1AYHJX2C2QEHCC0UMH4EDHRZ8F7UXJNGPZ5AT0VMSAQEXHWLN8QCQMDKQGP
Malformed:  AGE-SECRET-KEY-1AYHJX2C2QEHCC0UMH4EDHRZ8F7UXJNGPZ5AT0VMSAQEXHWLN8QCQMDKQGQP
+ (PASS) Failed to decode malformed key:  non-zero padding

Case 7

This case is the exact same as Case 5, except it generates a recipient who is vulnerable to the deletion case weakness:

Original:   age1r8fy0sdrgg49tpgwdljugrjnr3g2hsw0pdhk2fa58gqa3kmq648qzheqqp
Malformed:  age1r8fy0sdrgg49tpgwdljugrjnr3g2hsw0pdhk2fa58gqa3kmq648qzheqp
+ (PASS) Failed to decode malformed key:  illegal zero padding

Case 8

This case is the exact same as Case 7, except it uses an identity instead of a recipient:

Original:   AGE-SECRET-KEY-1Q3YUTH34DU94TGXZQ36QXDJWJC9LH8SVE5DKW4LV57W5D7RENY7Q9SXNQP
Malformed:  AGE-SECRET-KEY-1Q3YUTH34DU94TGXZQ36QXDJWJC9LH8SVE5DKW4LV57W5D7RENY7Q9SXNP
+ (PASS) Failed to decode malformed key:  illegal zero padding

Results

The following results show the pass rate for each of the above cases:

CASE #1: Pass rate: 100.0% (1000/1000) Errors: 0
CASE #2: Pass rate: 100.0% (1000/1000) Errors: 0
CASE #3: Pass rate: 100.0% (1000/1000) Errors: 0
CASE #4: Pass rate: 100.0% (1000/1000) Errors: 0
CASE #5: Pass rate: 50.1% (501/1000) Errors: 0
CASE #6: Pass rate: 47.8% (478/1000) Errors: 0
CASE #7: Pass rate: 100.0% (1000/1000) Errors: 0
CASE #8: Pass rate: 100.0% (1000/1000) Errors: 0

We can see that cases 1, 2, 3, 4, 7, and 8 have a pass rate of 100%, meaning even with malformed keys, age will not accept these keys as valid. However, cases 5 and 6 will pass or fail almost at the same probability of flipping a coin. Both of these cases share the commonality that they use the result of bech32.Decode() to determine a passing/failure status, with both malformed keys being a victim of the insertion weakness explained above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions