-
Notifications
You must be signed in to change notification settings - Fork 271
Perf: eliminate hot-loop overhead in seed stretching, base58 validation, and XOR operations #685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0b4a9d7
2a8b162
a2deaeb
fca1530
adcc91e
ef2c3ac
48ba630
9b1222e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -84,20 +84,28 @@ def _mk_block(size: int = BLOCK_SIZE) -> bytearray: | |
|
|
||
|
|
||
| def _xor_bytes1x16(a: Sequence[int], b: Sequence[int], dst: bytearray) -> None: | ||
| for i in range(BLOCK_SIZE): | ||
| dst[i] = a[i] ^ b[i] | ||
| # Use integer XOR to process all 16 bytes at once instead of a Python loop | ||
| int_a = int.from_bytes(a, 'big') | ||
| int_b = int.from_bytes(b, 'big') | ||
| dst[:] = (int_a ^ int_b).to_bytes(BLOCK_SIZE, 'big') | ||
|
|
||
|
|
||
| def _xor_bytes4x16( | ||
| a: Sequence[int], b: Sequence[int], c: Sequence[int], d: Sequence[int], dst: bytearray | ||
| ) -> None: | ||
| for i in range(BLOCK_SIZE): | ||
| dst[i] = a[i] ^ b[i] ^ c[i] ^ d[i] | ||
| # Use integer XOR to process all 16 bytes at once instead of a Python loop | ||
| int_a = int.from_bytes(a, 'big') | ||
| int_b = int.from_bytes(b, 'big') | ||
| int_c = int.from_bytes(c, 'big') | ||
| int_d = int.from_bytes(d, 'big') | ||
| dst[:] = (int_a ^ int_b ^ int_c ^ int_d).to_bytes(BLOCK_SIZE, 'big') | ||
|
|
||
|
|
||
| def _xor_bytes(a: Sequence[int], b: Sequence[int], dst: bytearray) -> None: | ||
| for i in range(len(dst)): | ||
| dst[i] = a[i] ^ b[i] | ||
| n = len(dst) | ||
| int_a = int.from_bytes(a[:n], 'big') | ||
| int_b = int.from_bytes(b[:n], 'big') | ||
| dst[:] = (int_a ^ int_b).to_bytes(n, 'big') | ||
|
|
||
|
|
||
| def _uint32(i: int) -> int: | ||
|
|
@@ -764,17 +772,13 @@ def _aez_decrypt(key: bytes, ad_list: Iterable[bytes], tau: int, ciphertext: byt | |
| x = bytearray(len(ciphertext)) | ||
| if len(ciphertext) == tau: | ||
| state.aez_prf(delta, tau, x) | ||
| mismatch = 0 | ||
| for i in range(tau): | ||
| mismatch |= x[i] ^ ciphertext[i] | ||
| if mismatch != 0: | ||
| # Use bytes comparison instead of byte-by-byte XOR loop | ||
| if x[:tau] != ciphertext[:tau]: | ||
| return None | ||
| return bytes() | ||
| state.decipher(delta, ciphertext, x) | ||
| mismatch = 0 | ||
| for i in range(tau): | ||
| mismatch |= x[len(ciphertext) - tau + i] | ||
| if mismatch != 0: | ||
| # Check if trailing tau bytes are all zero | ||
| if any(x[-tau:]): | ||
| return None | ||
|
Comment on lines
+780
to
782
|
||
| return bytes(x[: len(ciphertext) - tau]) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -257,21 +257,13 @@ def compress_pubkey(uncompressed_pubkey): | |||||
|
|
||||||
|
|
||||||
| def load_pathlist(pathlistFile): | ||||||
| pathlist_file = open(pathlistFile, "r") | ||||||
| pathlist_lines = pathlist_file.readlines() | ||||||
| pathlist = [] | ||||||
| for path in pathlist_lines: | ||||||
| if path[0] == '#' or len(path.strip()) == 0: | ||||||
| continue | ||||||
| pathlist.append(path.split("#")[0].strip()) | ||||||
| pathlist_file.close() | ||||||
| return pathlist | ||||||
| with open(pathlistFile, "r") as pathlist_file: | ||||||
| return [line.split("#")[0].strip() for line in pathlist_file | ||||||
| if line.strip() and line[0] != '#'] | ||||||
|
|
||||||
| def load_passphraselist(passphraselistFile): | ||||||
| passphraselist_file = open(passphraselistFile, "r") | ||||||
| passphraselist = passphraselist_file.read().splitlines() | ||||||
| passphraselist_file.close() | ||||||
| return passphraselist | ||||||
| with open(passphraselistFile, "r") as passphraselist_file: | ||||||
| return passphraselist_file.read().splitlines() | ||||||
|
|
||||||
| import hmac | ||||||
| import hashlib | ||||||
|
|
@@ -650,10 +642,7 @@ def return_verified_password_or_false(self, mnemonic_ids_list): | |||||
| for count, mnemonic_ids in enumerate(mnemonic_ids_list, 1): | ||||||
| # In the event that a tokenlist based recovery is happening, convert the list from string sback to ints | ||||||
| if (type(mnemonic_ids[0]) == str): | ||||||
| new_mnemonic_ids = [] | ||||||
| for word in mnemonic_ids: | ||||||
| new_mnemonic_ids.append(self._words.index(word)) | ||||||
| mnemonic_ids = new_mnemonic_ids | ||||||
| mnemonic_ids = [self._words.index(word) for word in mnemonic_ids] | ||||||
|
|
||||||
| # Compute the binary seed from the word list the Electrum1 way | ||||||
| seed = "" | ||||||
|
|
@@ -663,15 +652,11 @@ def return_verified_password_or_false(self, mnemonic_ids_list): | |||||
| + num_words2 * ( (mnemonic_ids[i + 2] - mnemonic_ids[i + 1]) % num_words )) | ||||||
| # | ||||||
|
|
||||||
| # Convert to bytes once before the stretching loop to avoid | ||||||
| # repeated type checks across 100,000 iterations | ||||||
| seed = seed.encode() | ||||||
| unstretched_seed = seed | ||||||
| for i in range(100000): # Electrum1's seed stretching | ||||||
|
|
||||||
| #Check the types of the seed and stretched_seed variables and force back to bytes (Allows most code to stay as-is for Py3) | ||||||
| if type(seed) is str: | ||||||
| seed = seed.encode() | ||||||
| if type(unstretched_seed) is str: | ||||||
| unstretched_seed = unstretched_seed.encode() | ||||||
|
|
||||||
| seed = l_sha256(seed + unstretched_seed).digest() | ||||||
|
|
||||||
| # If a master public key was provided, check the pubkey derived from the seed against it | ||||||
|
|
@@ -688,7 +673,12 @@ def return_verified_password_or_false(self, mnemonic_ids_list): | |||||
| try: master_pubkey_bytes = coincurve.PublicKey.from_valid_secret(seed).format(compressed=False)[1:] | ||||||
| except ValueError: continue | ||||||
|
|
||||||
| for seq_num in range(self._address_start_index, self._address_start_index + self._addrs_to_generate): | ||||||
| # Cache instance attributes as locals for the inner loop | ||||||
| l_known_hash160s = self._known_hash160s | ||||||
| l_address_start_index = self._address_start_index | ||||||
| l_addrs_to_generate = self._addrs_to_generate | ||||||
|
|
||||||
| for seq_num in range(l_address_start_index, l_address_start_index + l_addrs_to_generate): | ||||||
| # Compute the next deterministic private/public key pair the Electrum1 way. | ||||||
| # FYI we derive a privkey first, and then a pubkey from that because it's | ||||||
| # likely faster than deriving a pubkey directly from the base point and | ||||||
|
|
@@ -704,7 +694,7 @@ def return_verified_password_or_false(self, mnemonic_ids_list): | |||||
|
|
||||||
| # Compute the hash160 of the *uncompressed* public key, and check for a match | ||||||
|
|
||||||
| if ripemd160(l_sha256(d_pubkey).digest()) in self._known_hash160s: | ||||||
| if ripemd160(l_sha256(d_pubkey).digest()) in l_known_hash160s: | ||||||
| return mnemonic_ids, count # found it | ||||||
|
|
||||||
| return False, count | ||||||
|
|
@@ -936,18 +926,15 @@ def mn_mod(a, b): | |||||
|
|
||||||
| @staticmethod | ||||||
| def words_to_bytes(words): | ||||||
| byte_array = [] | ||||||
| for word in words: | ||||||
| byte_array.extend(word.to_bytes(4, byteorder='big', signed=False)) | ||||||
| byte_array = bytearray(len(words) * 4) | ||||||
| for i, word in enumerate(words): | ||||||
| byte_array[i*4:(i+1)*4] = word.to_bytes(4, byteorder='big', signed=False) | ||||||
| return byte_array | ||||||
|
|
||||||
| @staticmethod | ||||||
| def bytes_to_words(byte_array): | ||||||
| words = [] | ||||||
| for i in range(0, len(byte_array), 4): | ||||||
| word = int.from_bytes(byte_array[i:i+4], byteorder='big', signed=False) | ||||||
| words.append(word) | ||||||
| return words | ||||||
| return [int.from_bytes(byte_array[i:i+4], byteorder='big', signed=False) | ||||||
|
||||||
| return [int.from_bytes(byte_array[i:i+4], byteorder='big', signed=False) | |
| return [int.from_bytes(bytes(byte_array[i:i+4]), byteorder='big', signed=False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The authentication check in
_aez_decrypt()was changed from a full-length XOR accumulation tox[:tau] != ciphertext[:tau]. Plain bytes comparison is not constant-time and can reintroduce a timing side-channel for tag verification. Use a constant-time comparison (e.g.,hmac.compare_digest) to preserve the original security properties while still avoiding Python-level loops where possible.