Skip to content

Commit 8a995de

Browse files
SakshiYeoleclaude
andcommitted
fix(ciphers): replace base58.py with a real Base58 implementation
ciphers/base58.py was a byte-identical copy of ciphers/base64_cipher.py, so the repository had no Base58 code. Replace it with an encoder and decoder for the Bitcoin alphabet: - base58_encode(bytes) -> bytes - base58_decode(str | bytes) -> bytes - each leading zero byte maps to a leading "1" and back - TypeError for wrong input types, ValueError for invalid characters The signatures mirror base64_cipher.py. Doctests cover empty input, leading zeros, a round trip, and the error paths. Fixes #15637 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 6e03a48 commit 8a995de

1 file changed

Lines changed: 76 additions & 116 deletions

File tree

‎ciphers/base58.py‎

Lines changed: 76 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,99 @@
1-
B64_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
2-
3-
4-
def base64_encode(data: bytes) -> bytes:
5-
"""Encodes data according to RFC4648.
6-
7-
The data is first transformed to binary and appended with binary digits so that its
8-
length becomes a multiple of 6, then each 6 binary digits will match a character in
9-
the B64_CHARSET string. The number of appended binary digits would later determine
10-
how many "=" signs should be added, the padding.
11-
For every 2 binary digits added, a "=" sign is added in the output.
12-
We can add any binary digits to make it a multiple of 6, for instance, consider the
13-
following example:
14-
"AA" -> 0010100100101001 -> 001010 010010 1001
15-
As can be seen above, 2 more binary digits should be added, so there's 4
16-
possibilities here: 00, 01, 10 or 11.
17-
That being said, Base64 encoding can be used in Steganography to hide data in these
18-
appended digits.
19-
20-
>>> from base64 import b64encode
21-
>>> a = b"This pull request is part of Hacktoberfest20!"
22-
>>> b = b"https://tools.ietf.org/html/rfc4648"
23-
>>> c = b"A"
24-
>>> base64_encode(a) == b64encode(a)
25-
True
26-
>>> base64_encode(b) == b64encode(b)
27-
True
28-
>>> base64_encode(c) == b64encode(c)
29-
True
30-
>>> base64_encode("abc")
1+
"""
2+
Base58 encoding and decoding (Bitcoin alphabet)
3+
4+
Base58 is Base64 without the look-alike characters 0, O, I, l and without + and /.
5+
Each leading zero byte is written as a leading "1" so that it survives the round trip.
6+
7+
https://en.wikipedia.org/wiki/Binary-to-text_encoding#Base58
8+
"""
9+
10+
B58_CHARSET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
11+
12+
13+
def base58_encode(data: bytes) -> bytes:
14+
"""
15+
Encode bytes as Base58.
16+
17+
>>> base58_encode(b"")
18+
b''
19+
>>> base58_encode(b"\\x00\\x01")
20+
b'12'
21+
>>> base58_encode(b"\\x00\\x00")
22+
b'11'
23+
>>> base58_encode(b"Hello World!")
24+
b'2NEpo7TZRRrLZSi2U'
25+
>>> base58_encode("abc")
3126
Traceback (most recent call last):
3227
...
3328
TypeError: a bytes-like object is required, not 'str'
3429
"""
35-
# Make sure the supplied data is a bytes-like object
3630
if not isinstance(data, bytes):
3731
msg = f"a bytes-like object is required, not '{data.__class__.__name__}'"
3832
raise TypeError(msg)
3933

40-
binary_stream = "".join(bin(byte)[2:].zfill(8) for byte in data)
41-
42-
padding_needed = len(binary_stream) % 6 != 0
43-
44-
if padding_needed:
45-
# The padding that will be added later
46-
padding = b"=" * ((6 - len(binary_stream) % 6) // 2)
47-
48-
# Append binary_stream with arbitrary binary digits (0's by default) to make its
49-
# length a multiple of 6.
50-
binary_stream += "0" * (6 - len(binary_stream) % 6)
51-
else:
52-
padding = b""
53-
54-
# Encode every 6 binary digits to their corresponding Base64 character
55-
return (
56-
"".join(
57-
B64_CHARSET[int(binary_stream[index : index + 6], 2)]
58-
for index in range(0, len(binary_stream), 6)
59-
).encode()
60-
+ padding
61-
)
62-
63-
64-
def base64_decode(encoded_data: str) -> bytes:
65-
"""Decodes data according to RFC4648.
66-
67-
This does the reverse operation of base64_encode.
68-
We first transform the encoded data back to a binary stream, take off the
69-
previously appended binary digits according to the padding, at this point we
70-
would have a binary stream whose length is multiple of 8, the last step is
71-
to convert every 8 bits to a byte.
72-
73-
>>> from base64 import b64decode
74-
>>> a = "VGhpcyBwdWxsIHJlcXVlc3QgaXMgcGFydCBvZiBIYWNrdG9iZXJmZXN0MjAh"
75-
>>> b = "aHR0cHM6Ly90b29scy5pZXRmLm9yZy9odG1sL3JmYzQ2NDg="
76-
>>> c = "QQ=="
77-
>>> base64_decode(a) == b64decode(a)
78-
True
79-
>>> base64_decode(b) == b64decode(b)
80-
True
81-
>>> base64_decode(c) == b64decode(c)
82-
True
83-
>>> base64_decode("abc")
34+
# Leading zero bytes add nothing to the integer value, so count them separately
35+
leading_zeros = len(data) - len(data.lstrip(b"\x00"))
36+
number = int.from_bytes(data, "big")
37+
38+
digits = []
39+
while number:
40+
number, remainder = divmod(number, 58)
41+
digits.append(B58_CHARSET[remainder])
42+
43+
return (B58_CHARSET[0] * leading_zeros + "".join(reversed(digits))).encode()
44+
45+
46+
def base58_decode(encoded_data: str | bytes) -> bytes:
47+
"""
48+
Decode Base58 (given as a string or bytes) back to bytes.
49+
50+
>>> base58_decode("")
51+
b''
52+
>>> base58_decode("12")
53+
b'\\x00\\x01'
54+
>>> base58_decode(b"2NEpo7TZRRrLZSi2U")
55+
b'Hello World!'
56+
>>> base58_decode(base58_encode(b"\\x00\\x00\\xff"))
57+
b'\\x00\\x00\\xff'
58+
>>> base58_decode("0OIl")
59+
Traceback (most recent call last):
60+
...
61+
ValueError: Invalid Base58 character: '0'
62+
>>> base58_decode(b"\\xff")
8463
Traceback (most recent call last):
8564
...
86-
AssertionError: Incorrect padding
65+
ValueError: Base58 encoded data should only contain ASCII characters
66+
>>> base58_decode(123)
67+
Traceback (most recent call last):
68+
...
69+
TypeError: argument should be a bytes-like object or ASCII string, not 'int'
8770
"""
88-
# Make sure encoded_data is either a string or a bytes-like object
89-
if not isinstance(encoded_data, bytes) and not isinstance(encoded_data, str):
71+
if not isinstance(encoded_data, (str, bytes)):
9072
msg = (
9173
"argument should be a bytes-like object or ASCII string, "
9274
f"not '{encoded_data.__class__.__name__}'"
9375
)
9476
raise TypeError(msg)
9577

96-
# In case encoded_data is a bytes-like object, make sure it contains only
97-
# ASCII characters so we convert it to a string object
78+
# Accept bytes too, so the output of base58_encode can be decoded directly
9879
if isinstance(encoded_data, bytes):
9980
try:
100-
encoded_data = encoded_data.decode("utf-8")
81+
encoded_data = encoded_data.decode("ascii")
10182
except UnicodeDecodeError:
102-
raise ValueError("base64 encoded data should only contain ASCII characters")
103-
104-
padding = encoded_data.count("=")
105-
106-
# Check if the encoded string contains non base64 characters
107-
if padding:
108-
assert all(char in B64_CHARSET for char in encoded_data[:-padding]), (
109-
"Invalid base64 character(s) found."
110-
)
111-
else:
112-
assert all(char in B64_CHARSET for char in encoded_data), (
113-
"Invalid base64 character(s) found."
114-
)
115-
116-
# Check the padding
117-
assert len(encoded_data) % 4 == 0 and padding < 3, "Incorrect padding"
118-
119-
if padding:
120-
# Remove padding if there is one
121-
encoded_data = encoded_data[:-padding]
122-
123-
binary_stream = "".join(
124-
bin(B64_CHARSET.index(char))[2:].zfill(6) for char in encoded_data
125-
)[: -padding * 2]
126-
else:
127-
binary_stream = "".join(
128-
bin(B64_CHARSET.index(char))[2:].zfill(6) for char in encoded_data
129-
)
130-
131-
data = [
132-
int(binary_stream[index : index + 8], 2)
133-
for index in range(0, len(binary_stream), 8)
134-
]
135-
136-
return bytes(data)
83+
msg = "Base58 encoded data should only contain ASCII characters"
84+
raise ValueError(msg) from None
85+
86+
number = 0
87+
for char in encoded_data:
88+
if char not in B58_CHARSET:
89+
msg = f"Invalid Base58 character: {char!r}"
90+
raise ValueError(msg)
91+
number = number * 58 + B58_CHARSET.index(char)
92+
93+
# Each leading "1" stands for one zero byte that the integer cannot hold
94+
leading_ones = len(encoded_data) - len(encoded_data.lstrip(B58_CHARSET[0]))
95+
body = number.to_bytes((number.bit_length() + 7) // 8, "big")
96+
return b"\x00" * leading_ones + body
13797

13898

13999
if __name__ == "__main__":

0 commit comments

Comments
 (0)