|
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") |
31 | 26 | Traceback (most recent call last): |
32 | 27 | ... |
33 | 28 | TypeError: a bytes-like object is required, not 'str' |
34 | 29 | """ |
35 | | - # Make sure the supplied data is a bytes-like object |
36 | 30 | if not isinstance(data, bytes): |
37 | 31 | msg = f"a bytes-like object is required, not '{data.__class__.__name__}'" |
38 | 32 | raise TypeError(msg) |
39 | 33 |
|
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") |
84 | 63 | Traceback (most recent call last): |
85 | 64 | ... |
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' |
87 | 70 | """ |
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)): |
90 | 72 | msg = ( |
91 | 73 | "argument should be a bytes-like object or ASCII string, " |
92 | 74 | f"not '{encoded_data.__class__.__name__}'" |
93 | 75 | ) |
94 | 76 | raise TypeError(msg) |
95 | 77 |
|
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 |
98 | 79 | if isinstance(encoded_data, bytes): |
99 | 80 | try: |
100 | | - encoded_data = encoded_data.decode("utf-8") |
| 81 | + encoded_data = encoded_data.decode("ascii") |
101 | 82 | 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 |
137 | 97 |
|
138 | 98 |
|
139 | 99 | if __name__ == "__main__": |
|
0 commit comments