Repository navigation
Conversation
Coverage reportClick to see where and how coverage changed
The report is truncated to 25 files out of 68. To see the full report, please visit the workflow summary page. This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
🟡 Changes recommended
The BLS zero-key guard can be bypassed by longer inputs with a zeroed 32-byte prefix, allowing the native library to abort the process.
3 open issues
What changed in this PR
Hardens the wallet package’s key derivation, keystore and PEM loading, and signing behavior through stricter validation and identity checks.
Changes:
- Normalize BIP-39 inputs and validate address indices.
- Strengthen wallet-file parsing, secret-key handling and BLS error handling, with expanded regression coverage.
- Bump the SDK to 3.1.0 and pin
cryptographyto 50.0.2.
| File | Description |
|---|---|
requirements.txt |
Updates the cryptography pin. |
pyproject.toml |
Updates the SDK version and dependency pin. |
multiversx_sdk/wallet/validator_test.py |
Tests validator-key validation and signing failures. |
multiversx_sdk/wallet/validator_pem.py |
Validates PEM entry indices. |
multiversx_sdk/wallet/validator_keys.py |
Validates secret-key buffers and native-library results. |
multiversx_sdk/wallet/user_wallet.py |
Normalizes stored mnemonics and verifies keystore identity. |
multiversx_sdk/wallet/user_test.py |
Covers wallet, PEM, signer and user-key hardening. |
multiversx_sdk/wallet/user_signer.py |
Adds optional wallet address-index forwarding. |
multiversx_sdk/wallet/user_pem.py |
Checks entry indices and account identity. |
multiversx_sdk/wallet/user_keys.py |
Copies key buffers and uses constant-time equality. |
multiversx_sdk/wallet/pem_entry.py |
Adds structured parsing, strict decoding and label checks. |
multiversx_sdk/wallet/pem_entry_test.py |
Tests malformed PEM entries and label handling. |
multiversx_sdk/wallet/mnemonic.py |
Stores normalized mnemonic text. |
multiversx_sdk/wallet/mnemonic_test.py |
Tests normalization, reference vectors and index bounds. |
multiversx_sdk/wallet/libraries/bls_facade.py |
Adds a zero-key guard before native public-key generation. |
multiversx_sdk/wallet/libraries/bls_facade_test.py |
Adds subprocess coverage for zero-key crash prevention. |
multiversx_sdk/wallet/errors.py |
Defines keystore, PEM and secret-key errors. |
multiversx_sdk/wallet/crypto/decryptor.py |
Bounds scrypt parameters, validates IV length and verifies MACs. |
multiversx_sdk/wallet/crypto/decryptor_test.py |
Tests decryption validation and MAC failures. |
multiversx_sdk/wallet/crypto/constants.py |
Defines accepted scrypt parameter bounds. |
multiversx_sdk/wallet/core.py |
Normalizes BIP-39 inputs and validates derivation indices. |
Review effort: Max
| if private_key == bytes(VALIDATOR_SECRETKEY_LENGTH): | ||
| return b"" |
There was a problem hiding this comment.
The facade now rejects any private key that isn't exactly 32 non-zero bytes before calling the native library, for both public key generation and signing.
| # Only the delimiters are removed, so that dashes within the label are kept. | ||
| # The trailing delimiter is handled leniently (e.g. "---" instead of "-----"), as before: such files exist in the wild. | ||
| return line[len(prefix) :].rstrip("-").strip() |
There was a problem hiding this comment.
BEGIN my-key- / END my-key equivalence is by design, since both lines parse to the same label.
| secret_key = UserWallet.load_secret_key(path, password) | ||
| def from_wallet(cls, path: Path, password: str, address_index: Optional[int] = None) -> "UserSigner": | ||
| # "address_index" is only used for keystore files holding a mnemonic (same as "UserWallet.load_secret_key"). | ||
| secret_key = UserWallet.load_secret_key(path, password, address_index) |
There was a problem hiding this comment.
load_secret_key() now defaults the index only when it is None, so False, 0.0 and other falsy values raise InvalidAddressIndexError.


Wallet hardening: fixes from the security review of
multiversx_sdk.walletThe changes stay within
multiversx_sdk/wallet. Public signatures change only by adding optional parameters, but stricter validation now rejects some inputs that were accepted before. See Behaviour changes.Changes
Key derivation
core.mnemonic_to_bip39seed()normalizes the mnemonic and the passphrase to NFKD before PBKDF2.Mnemonic.check()already normalized internally, but the seed was computed from the raw text, so some accepted mnemonics derived non-standard addresses.Mnemonic.__init__stores the normalized text.UserWallet.from_mnemonic()encrypts the normalized text.core.bip39seed_to_secret_key()validates the address index: an integer (notbool) with 0 ≤ index < 2^31, otherwiseInvalidAddressIndexError. The partial check inMnemonic.derive_key()was removed in favour of this one.from_walletindex.UserSigner.from_wallet(path, password, address_index=None)passes the index through toUserWallet.load_secret_key().UserWallet.load_secret_key()defaults the index to 0 only when it isNone(before:address_index or 0), so falsy values of the wrong type (False,0.0) reach the validation above instead of silently selecting account 0.Keystore files
Identity checks.
UserWallet.decrypt_secret_key():addressandbech32fieldsaddress(hex) andbech32against the derived public key; the bech32 comparison is by public key, so any HRP is acceptedA tampered IV now gets caught as well, since it changes the decrypted key. This mirrors mx-sdk-go's
LoadPrivateKeyFromJsonFile.Scrypt bounds.
decryptor.decrypt()checks the scrypt parameters before running the KDF:na power of 2 in [2, 2^18]rin [1, 8]pin [1, 4]dklen == 32intThe worst case allowed is about 256 MiB of memory. New constants are in
crypto/constants.py.Constant-time MAC. The MAC is checked with
HMAC.verify()(constant-time) instead of!=.PEM files
PemEntry.from_text_all()groups each entry by its BEGIN and END lines instead of splitting on-----. It checks that the labels match, rejects empty entries and text outside entries, and decodes base64 withvalidate=True.InvalidPemError(ValueError). The underlying error is not chained, since it could reveal bits of the secret content.to_text()rejects labels that would break the file structure (line breaks,-----) and labels that would not survive a save / load cycle (ending with-, or with leading or trailing whitespace). The parser stays lenient about the trailing delimiter and surrounding whitespace.UserPEMchecks that the embedded public key and a bech32 label match the secret key. Labels that aren't addresses are still accepted.UserPEMandValidatorPEMreject negative and out-of-range indices withIndexError(assert_index_is_valid).BLS (validator keys)
ValidatorSecretKeyrejects the all-zero key.BLSFacade.generate_public_key()andcompute_message_signature()returnb""for any private key that is not exactly 32 bytes or is all-zero, instead of calling into the native library. The zero key panics in Go and kills the process. Older builds of the library (e.g.libbls.so) only read the first 32 bytes of the key, so longer keys starting with 32 zero bytes crashed as well, and other longer keys were accepted.libblsis not modified.ValidatorSecretKey.sign()raisesCannotSignError, andgenerate_public_key()raisesInvalidSecretKeyError, when the native library returns an empty result.ValidatorPublicKeyraisesInvalidPublicKeyLengthErrorinstead ofInvalidSecretKeyLengthError.User keys
UserSecretKeyraisesTypeErrorfor anything other thanbytesorbytearray, and copies the buffer.UserSecretKey.__eq__useshmac.compare_digest.New errors in
wallet/errors.py:InvalidKeystoreFileError,InvalidPemError,InvalidSecretKeyError.Behaviour changes
address/bech32InvalidKeystoreFileErrorInvalidKeystoreFileErrorInvalidKeystoreFileError-1IndexErrormy-validatormyvalidatormy-validator-or has surrounding whitespaceInvalidPemErrorInvalidPemError(aValueError)sign()failure (invalid key, empty message)b""CannotSignErrorInvalidSecretKeyErrorBLSFacadewith a private key longer than 32 byteslibblsbuilds: process crash (zero-prefixed) or extra bytes ignoredb""on all platformsTruestruct.error/ index 1InvalidAddressIndexErrorFalse/0.0when loading a mnemonic keystoreInvalidAddressIndexErrorValidatorPublicKeywrong lengthInvalidSecretKeyLengthErrorInvalidPublicKeyLengthErrorTesting
mnemonic_test.py: NFKD cases, Trezor BIP-39 vectors, passphrase normalization, address index bounds, largest index cross-checked againsted25519-hd-keyuser_test.py: keystore identity checks, PEM identity and index checks,UserSecretKeytype, copy and equality,UserSigner.from_walletindex, address index of the wrong type (including falsy values)validator_test.py: zero key, signing failures, key altered after construction (zero and longer zero-prefixed buffers), PEM indices,ValidatorPublicKeylength errorpem_entry_test.py: malformed structure, bad encoding, labels (including labels that would not survive a save / load cycle)libraries/bls_facade_test.py: the zero key and longer zero-prefixed keys, run in a subprocess, no longer crash; keys of the wrong length are rejectedcrypto/decryptor_test.py: scrypt bounds, IV length, MAClibbls.so(Linux amd64, in Docker) andlibbls.dylib(x86_64); the wallet tests pass on Linux amd64 with the fix.pytest -m "not networkInteraction and not mainnet": 761 passed, 12 skipped.flake8andpyrightare clean on the changed files.