# Copyright (c) 2026 Emanuele Bellocchia
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
Module for BIP38 encryption/decryption.
Reference: https://github.com/bitcoin/bips/blob/master/bip-0038.mediawiki
"""
# Imports
import os
from typing import Optional, Tuple
from bip_utils.base58 import Base58Decoder, Base58Encoder
from bip_utils.bip.bip38.bip38_addr import Bip38Addr, Bip38PubKeyModes
from bip_utils.ecc import Secp256k1, Secp256k1PrivateKey, Secp256k1PublicKey
from bip_utils.utils.crypto import AesEcbDecrypter, AesEcbEncrypter, DoubleSha256, Scrypt
from bip_utils.utils.misc import BitUtils, BytesUtils, IntegerUtils, StringUtils
class Bip38EcConst:
"""Class container for BIP38 EC constants."""
# Minimum/Maximum values for lot number
LOT_NUM_MIN_VAL: int = 0
LOT_NUM_MAX_VAL: int = 1048575
# Minimum/Maximum values for sequence number
SEQ_NUM_MIN_VAL: int = 0
SEQ_NUM_MAX_VAL: int = 4095
# Owner salt lengths
OWNER_SALT_WITH_LOT_SEQ_BYTE_LEN: int = 4
OWNER_SALT_NO_LOT_SEQ_BYTE_LEN: int = 8
# Intermediate passphrase encrypted length in byte
INT_PASS_ENC_BYTE_LEN: int = 49
# Magic for intermediate passphrase
INT_PASS_MAGIC_WITH_LOT_SEQ = b"\x2c\xe9\xb3\xe1\xff\x39\xe2\x51"
INT_PASS_MAGIC_NO_LOT_SEQ = b"\x2c\xe9\xb3\xe1\xff\x39\xe2\x53"
# Seedb byte length
SEED_B_BYTE_LEN: int = 24
# Encrypted length
ENC_BYTE_LEN: int = 39
# Encrypted prefix
ENC_KEY_PREFIX: bytes = b"\x01\x43"
# Bit number for flags in flagbyte
FLAG_BIT_COMPRESSED: int = 5
FLAG_BIT_LOT_SEQ: int = 2
# Parameters for scrypt algorithm for computing prefactor
SCRYPT_PREFACTOR_KEY_LEN: int = 32
SCRYPT_PREFACTOR_N: int = 16384
SCRYPT_PREFACTOR_P: int = 8
SCRYPT_PREFACTOR_R: int = 8
# Parameters for scrypt algorithm for deriving key halves
SCRYPT_HALVES_KEY_LEN: int = 64
SCRYPT_HALVES_N: int = 1024
SCRYPT_HALVES_P: int = 1
SCRYPT_HALVES_R: int = 1
class _Bip38EcUtils:
"""Class container for BIP38 EC utility functions."""
@staticmethod
def OwnerEntropyWithLotSeq(lot_num: int,
sequence_num: int) -> bytes:
"""
Compute the owner entropy as specified in BIP38 (with EC multiplication) with lot and sequence numbers.
Args:
lot_num (int) : Lot number
sequence_num (int): Sequence number
Returns:
bytes: Owner entropy
Raises:
ValueError: If lot or sequence number is not valid
"""
# Check lot and sequence numbers
if lot_num < Bip38EcConst.LOT_NUM_MIN_VAL or lot_num > Bip38EcConst.LOT_NUM_MAX_VAL:
raise ValueError(f"Invalid lot number ({lot_num})")
if sequence_num < Bip38EcConst.SEQ_NUM_MIN_VAL or sequence_num > Bip38EcConst.SEQ_NUM_MAX_VAL:
raise ValueError(f"Invalid sequence number ({sequence_num})")
# Generate random owner salt (4 bytes)
owner_salt = os.urandom(Bip38EcConst.OWNER_SALT_WITH_LOT_SEQ_BYTE_LEN)
# Compute lot sequence
lot_sequence = IntegerUtils.ToBytes((lot_num * (Bip38EcConst.SEQ_NUM_MAX_VAL + 1)) + sequence_num,
bytes_num=4)
# Compute owner entropy
return owner_salt + lot_sequence
@staticmethod
def OwnerEntropyNoLotSeq() -> bytes:
"""
Compute the owner entropy as specified in BIP38 (with EC multiplication) without lot and sequence numbers.
Returns:
bytes: Owner entropy
"""
# Generate random owner salt (8 bytes)
owner_salt = os.urandom(Bip38EcConst.OWNER_SALT_NO_LOT_SEQ_BYTE_LEN)
# Owner entropy is owner salt
return owner_salt
@staticmethod
def OwnerSaltFromEntropy(owner_entropy: bytes,
has_lot_seq: bool) -> bytes:
"""
Get owner salt from owner entropy.
Args:
owner_entropy (bytes): Owner entropy
has_lot_seq (bool) : True if lot and sequence numbers are present, false otherwise
Returns:
bytes: Owner salt
"""
return owner_entropy if not has_lot_seq else owner_entropy[:Bip38EcConst.OWNER_SALT_WITH_LOT_SEQ_BYTE_LEN]
@staticmethod
def PassFactor(passphrase: str,
owner_entropy: bytes,
has_lot_seq: bool) -> bytes:
"""
Compute the passfactor as specified in BIP38 (with EC multiplication).
Args:
passphrase (str) : Passphrase
owner_entropy (bytes): Owner entropy
has_lot_seq (bool) : True if lot and sequence numbers are present, false otherwise
Returns:
bytes: Passfactor
"""
# Compute the prefactor
prefactor = Scrypt.DeriveKey(StringUtils.NormalizeNfc(passphrase),
_Bip38EcUtils.OwnerSaltFromEntropy(owner_entropy, has_lot_seq),
key_len=Bip38EcConst.SCRYPT_PREFACTOR_KEY_LEN,
n=Bip38EcConst.SCRYPT_PREFACTOR_N,
r=Bip38EcConst.SCRYPT_PREFACTOR_P,
p=Bip38EcConst.SCRYPT_PREFACTOR_R)
# Compute the passfactor
if has_lot_seq:
passfactor = DoubleSha256.QuickDigest(prefactor + owner_entropy)
else:
passfactor = prefactor
return passfactor
@staticmethod
def PassPoint(passfactor: bytes) -> bytes:
"""
Compute the passpoint as specified in BIP38 (with EC multiplication).
Args:
passfactor (bytes): Passfactor
Returns:
bytes: Passpoint bytes in compressed format
"""
# Compute passpoint
passpoint = Secp256k1PublicKey.FromPoint(Secp256k1.Generator() * BytesUtils.ToInteger(passfactor))
# Return it as a compressed public key
return passpoint.RawCompressed().ToBytes()
@staticmethod
def DeriveKeyHalves(passpoint: bytes,
address_hash: bytes,
owner_entropy: bytes) -> Tuple[bytes, bytes]:
"""
Compute the scrypt as specified in BIP38 (without EC multiplication) and derive the two key halves.
Args:
passpoint (bytes) : Passpoint
address_hash (bytes) : Address hash
owner_entropy (bytes): Owner entropy
Returns:
tuple[bytes, bytes]: Derived key halves
"""
# Derive a key from passpoint, address hash and owner entropy
key = Scrypt.DeriveKey(passpoint,
address_hash + owner_entropy,
key_len=Bip38EcConst.SCRYPT_HALVES_KEY_LEN,
n=Bip38EcConst.SCRYPT_HALVES_N,
r=Bip38EcConst.SCRYPT_HALVES_R,
p=Bip38EcConst.SCRYPT_HALVES_P)
# Split the resulting 64 bytes in half
derived_half_1 = key[:Bip38EcConst.SCRYPT_HALVES_KEY_LEN // 2]
derived_half_2 = key[Bip38EcConst.SCRYPT_HALVES_KEY_LEN // 2:]
return derived_half_1, derived_half_2
class Bip38EcKeysGenerator:
"""
BIP38 keys generator class.
It generates intermediate codes and private keys using the algorithm specified in BIP38 with EC multiplication.
"""
@staticmethod
def GenerateIntermediatePassphrase(passphrase: str,
lot_num: Optional[int] = None,
sequence_num: Optional[int] = None) -> str:
"""
Generate an intermediate passphrase from the user passphrase as specified in BIP38.
Args:
passphrase (str) : Passphrase
lot_num (int, optional) : Lot number
sequence_num (int, optional): Sequence number
Returns:
str: Intermediate passphrase encoded in base58
"""
# Get if lot and sequence are used
has_lot_seq = lot_num is not None and sequence_num is not None
# Compute owner entropy and salt
# We can ignore the mypy warning because has_lot_seq checks for variables for being not None
owner_entropy = (_Bip38EcUtils.OwnerEntropyWithLotSeq(lot_num, sequence_num) # type: ignore [arg-type]
if has_lot_seq
else _Bip38EcUtils.OwnerEntropyNoLotSeq())
# Compute passpoint
passfactor = _Bip38EcUtils.PassFactor(passphrase, owner_entropy, has_lot_seq)
passpoint = _Bip38EcUtils.PassPoint(passfactor)
# Get magic
magic = Bip38EcConst.INT_PASS_MAGIC_WITH_LOT_SEQ if has_lot_seq else Bip38EcConst.INT_PASS_MAGIC_NO_LOT_SEQ
# Build and encode intermediate passphrase
return Base58Encoder.CheckEncode(magic + owner_entropy + passpoint)
@staticmethod
def GeneratePrivateKey(int_passphrase: str,
pub_key_mode: Bip38PubKeyModes) -> str:
"""
Generate a random encrypted private key from the intermediate passphrase.
Args:
int_passphrase (str) : Intermediate passphrase
pub_key_mode (Bip38PubKeyModes): Public key mode
Returns:
str: Encrypted private key
Raises:
Base58ChecksumError: If base58 checksum is not valid
ValueError: If the intermediate code is not valid
"""
# Decode intermediate passphrase
int_passphrase_bytes = Base58Decoder.CheckDecode(int_passphrase)
# Check length
if len(int_passphrase_bytes) != Bip38EcConst.INT_PASS_ENC_BYTE_LEN:
raise ValueError(f"Invalid intermediate code length ({len(int_passphrase_bytes)})")
# Get all the parts back
magic = int_passphrase_bytes[:8]
owner_entropy = int_passphrase_bytes[8:16]
passpoint = Secp256k1PublicKey.FromBytes(int_passphrase_bytes[16:])
# Check magic
if magic not in (Bip38EcConst.INT_PASS_MAGIC_NO_LOT_SEQ, Bip38EcConst.INT_PASS_MAGIC_WITH_LOT_SEQ):
raise ValueError(f"Invalid magic ({BytesUtils.ToHexString(magic)})")
# Generate seedb
seedb = os.urandom(Bip38EcConst.SEED_B_BYTE_LEN)
# Compute factorb from seedb
factorb = DoubleSha256.QuickDigest(seedb)
# Compute address hash
address_hash = Bip38Addr.AddressHash(
Secp256k1PublicKey.FromPoint(passpoint.Point() * BytesUtils.ToInteger(factorb)),
pub_key_mode
)
# Derive key halves from the passpoint, address hash and owner entropy
derived_half_1, derived_half_2 = _Bip38EcUtils.DeriveKeyHalves(passpoint.RawCompressed().ToBytes(),
address_hash,
owner_entropy)
# Encrypt seedb in two parts
encrypted_part_1, encrypted_part_2 = Bip38EcKeysGenerator.__EncryptSeedb(seedb,
derived_half_1,
derived_half_2)
# Get flagbyte by setting bits
flagbyte = Bip38EcKeysGenerator.__SetFlagbyteBits(magic, pub_key_mode)
# Concatenate all parts
enc_key_bytes = (Bip38EcConst.ENC_KEY_PREFIX + flagbyte + address_hash
+ owner_entropy + encrypted_part_1[:8] + encrypted_part_2)
# Encode in Base58Check
return Base58Encoder.CheckEncode(enc_key_bytes)
@staticmethod
def __EncryptSeedb(seedb: bytes,
derived_half_1: bytes,
derived_half_2: bytes) -> Tuple[bytes, bytes]:
"""
Encrypt seedb in two parts.
Args:
seedb (bytes) : Seedb
derived_half_1 (bytes): First half of derived key
derived_half_2 (bytes): Second half of derived key
Returns:
tuple[bytes, bytes]: Two encrypted parts
"""
# Use derived_half_2 as AES key
aes_enc = AesEcbEncrypter(derived_half_2)
aes_enc.AutoPad(False)
# Encrypt the first part: seedb[0...15] xor derived_half_1[0...15]
encrypted_part_1 = aes_enc.Encrypt(BytesUtils.Xor(seedb[:16], derived_half_1[:16]))
# Encrypt the second part: (encrypted_part_1[8...15] + seedb[16...23])) xor derivedhalf1[16...31]
encrypted_part_2 = aes_enc.Encrypt(BytesUtils.Xor(encrypted_part_1[8:] + seedb[16:], derived_half_1[16:]))
return encrypted_part_1, encrypted_part_2
@staticmethod
def __SetFlagbyteBits(magic: bytes,
pub_key_mode: Bip38PubKeyModes) -> bytes:
"""
Set flagbyte bits and return it.
Args:
magic (bytes) : Magic
pub_key_mode (Bip38PubKeyModes): Public key mode
Returns:
bytes: Flagbyte
"""
flagbyte_int = 0
if pub_key_mode == Bip38PubKeyModes.COMPRESSED:
flagbyte_int = BitUtils.SetBit(flagbyte_int, Bip38EcConst.FLAG_BIT_COMPRESSED)
if magic == Bip38EcConst.INT_PASS_MAGIC_WITH_LOT_SEQ:
flagbyte_int = BitUtils.SetBit(flagbyte_int, Bip38EcConst.FLAG_BIT_LOT_SEQ)
return IntegerUtils.ToBytes(flagbyte_int)
class Bip38EcDecrypter:
"""
BIP38 decrypter class.
It decrypts a private key using the algorithm specified in BIP38 with EC multiplication.
"""
@staticmethod
def Decrypt(priv_key_enc: str, # pylint: disable=too-many-locals
passphrase: str) -> Tuple[bytes, Bip38PubKeyModes]:
"""
Decrypt the specified private key.
Args:
priv_key_enc (str): Encrypted private key bytes
passphrase (str) : Passphrase
Returns:
tuple[bytes, Bip38PubKeyModes]: Decrypted private key (index 0), public key mode (index 1)
Raises:
Base58ChecksumError: If base58 checksum is not valid
ValueError: If the encrypted key is not valid
"""
# Decode private key
priv_key_enc_bytes = Base58Decoder.CheckDecode(priv_key_enc)
# Check encrypted length
if len(priv_key_enc_bytes) != Bip38EcConst.ENC_BYTE_LEN:
raise ValueError(f"Invalid encrypted length ({len(priv_key_enc_bytes)})")
# Get all the parts back
prefix = priv_key_enc_bytes[:2]
flagbyte = IntegerUtils.ToBytes(priv_key_enc_bytes[2])
address_hash = priv_key_enc_bytes[3:7]
owner_entropy = priv_key_enc_bytes[7:15]
encrypted_part_1_lower = priv_key_enc_bytes[15:23]
encrypted_part_2 = priv_key_enc_bytes[23:]
# Check prefix
if prefix != Bip38EcConst.ENC_KEY_PREFIX:
raise ValueError(f"Invalid prefix ({BytesUtils.ToHexString(prefix)})")
# Get flagbyte options
pub_key_mode, has_lot_seq = Bip38EcDecrypter.__GetFlagbyteOptions(flagbyte)
# Compute passfactor
passfactor = _Bip38EcUtils.PassFactor(passphrase, owner_entropy, has_lot_seq)
# Derive key halves from the passpoint, address hash and owner entropy
derived_half_1, derived_half_2 = _Bip38EcUtils.DeriveKeyHalves(_Bip38EcUtils.PassPoint(passfactor),
address_hash,
owner_entropy)
# Get factorb back by decrypting
factorb = Bip38EcDecrypter.__DecryptAndGetFactorb(encrypted_part_1_lower,
encrypted_part_2,
derived_half_1,
derived_half_2)
# Compute private key
priv_key_bytes = Bip38EcDecrypter.__ComputePrivateKey(passfactor, factorb)
# Verify the address hash
address_hash_got = Bip38Addr.AddressHash(Secp256k1PrivateKey.FromBytes(priv_key_bytes).PublicKey(),
pub_key_mode)
if address_hash != address_hash_got:
raise ValueError(
f"Invalid address hash (expected: {BytesUtils.ToHexString(address_hash)}, "
f"got: {BytesUtils.ToHexString(address_hash_got)})"
)
return priv_key_bytes, pub_key_mode
@staticmethod
def __DecryptAndGetFactorb(encrypted_part_1_lower: bytes,
encrypted_part_2: bytes,
derived_half_1: bytes,
derived_half_2: bytes) -> bytes:
"""
Decrypt and get back factorb.
Args:
encrypted_part_1_lower (bytes): Lower part of first encrypted part
encrypted_part_2 (bytes) : Second encrypted part
derived_half_1 (bytes) : First half of derived key
derived_half_2 (bytes) : Second half of derived key
Returns:
bytes: Factorb
"""
# Use derived_half_2 as AES key
aes_dec = AesEcbDecrypter(derived_half_2)
aes_dec.AutoUnPad(False)
# Decrypt the second part and get back the higher parts of seedb and encrypted half 1
decrypted_part_2 = BytesUtils.Xor(aes_dec.Decrypt(encrypted_part_2), derived_half_1[16:])
encrypted_part_1_higher = decrypted_part_2[:8]
seedb_part_2 = decrypted_part_2[8:]
# Decrypt the first part to get the lower part of seedb
seedb_part_1 = BytesUtils.Xor(aes_dec.Decrypt(encrypted_part_1_lower + encrypted_part_1_higher),
derived_half_1[:16])
# Rebuild the complete seedb
seedb = seedb_part_1 + seedb_part_2
# Compute factorb from seedb
return DoubleSha256.QuickDigest(seedb)
@staticmethod
def __ComputePrivateKey(passfactor: bytes,
factorb: bytes) -> bytes:
"""
Compute the private key from passfactor and factorb.
Args:
passfactor (bytes): Passfactor
factorb (bytes) : Factorb
Returns:
bytes: Private key
"""
# Private key: (passfactor * factorb) mod N
priv_key_int = (BytesUtils.ToInteger(passfactor) * BytesUtils.ToInteger(factorb)) % Secp256k1.Order()
return IntegerUtils.ToBytes(priv_key_int, bytes_num=Secp256k1PrivateKey.Length())
@staticmethod
def __GetFlagbyteOptions(flagbyte: bytes) -> Tuple[Bip38PubKeyModes, bool]:
"""
Get the options from the flagbyte.
Args:
flagbyte (bytes): Flagbyte
Returns:
tuple[Bip38PubKeyModes, bool]: Public key mode (index 0), has lot/sequence numbers (index 1)
"""
# Convert flagbyte to integer
flagbyte_int = BytesUtils.ToInteger(flagbyte)
# Get bit set in flagbyte
has_lot_seq = BitUtils.IsBitSet(flagbyte_int, Bip38EcConst.FLAG_BIT_LOT_SEQ)
pub_key_mode = (Bip38PubKeyModes.COMPRESSED
if BitUtils.IsBitSet(flagbyte_int, Bip38EcConst.FLAG_BIT_COMPRESSED)
else Bip38PubKeyModes.UNCOMPRESSED)
# Check flagbyte
flagbyte_int = BitUtils.ResetBit(flagbyte_int, Bip38EcConst.FLAG_BIT_LOT_SEQ)
flagbyte_int = BitUtils.ResetBit(flagbyte_int, Bip38EcConst.FLAG_BIT_COMPRESSED)
if flagbyte_int != 0:
raise ValueError(f"Invalid flagbyte ({BytesUtils.ToHexString(flagbyte)})")
return pub_key_mode, has_lot_seq