diff --git a/.gitignore b/.gitignore index 2928e76..7f2094b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ build/ .coverage htmlcov/ .tox/ +fixtures/ \ No newline at end of file diff --git a/examples/crypto.py b/examples/crypto.py index fdeb1db..c02c1e4 100644 --- a/examples/crypto.py +++ b/examples/crypto.py @@ -8,7 +8,8 @@ def sign_and_verify_arbitrary_data(data: bytes): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Public Key (hex): ", signer.get_keypair().public_key) diff --git a/examples/ft_token_transfer.py b/examples/ft_token_transfer.py index 4fde043..9ce82f0 100644 --- a/examples/ft_token_transfer.py +++ b/examples/ft_token_transfer.py @@ -7,7 +7,8 @@ def perform_ft_token_transfer(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Signer DID: ", signer.did) diff --git a/examples/nft_operations.py b/examples/nft_operations.py index 2844f35..ca4b3c0 100644 --- a/examples/nft_operations.py +++ b/examples/nft_operations.py @@ -9,7 +9,8 @@ def perform_nft_deployment(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Signer DID: ", signer.did) @@ -31,7 +32,8 @@ def perform_nft_execution(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Signer DID: ", signer.did) diff --git a/examples/rbt_token_transfer.py b/examples/rbt_token_transfer.py index 7fa953b..58895a6 100644 --- a/examples/rbt_token_transfer.py +++ b/examples/rbt_token_transfer.py @@ -7,7 +7,8 @@ def transfer_rbt_tokens(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Signer DID: ", signer.did) diff --git a/examples/smart_contract_operations.py b/examples/smart_contract_operations.py index 03c352e..26a7e01 100644 --- a/examples/smart_contract_operations.py +++ b/examples/smart_contract_operations.py @@ -9,7 +9,9 @@ def smart_contract_deployment(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" + ) print("Signer DID: ", signer.did) @@ -31,7 +33,8 @@ def smart_contract_execution(): signer = Signer( rubixClient=client, - mnemonic="" + mnemonic="", + alias="nero" ) print("Signer DID: ", signer.did) diff --git a/pyproject.toml b/pyproject.toml index decdf7d..2372b31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rubix-py" -version = "0.3.0" +version = "0.4.0" description = "Rubix Client SDK for Python" requires-python = ">=3.10" license = { text = "MIT" } @@ -23,7 +23,8 @@ dependencies = [ "mnemonic>=0.21", "ECPy>=1.2.5", "py-cid>=0.3.1", - "coincurve>=21.0.0" + "coincurve>=21.0.0", + "cryptography>=45.0.7", ] [project.urls] diff --git a/rubix/client.py b/rubix/client.py index 7a0bc76..0929ef1 100644 --- a/rubix/client.py +++ b/rubix/client.py @@ -3,7 +3,7 @@ from urllib.parse import urljoin class RubixClient: - def __init__(self, node_url: str, timeout: int = 300): + def __init__(self, node_url: str = "http://localhost:20000", timeout: int = 300): """ Initialize Rubix client. diff --git a/rubix/crypto/account.py b/rubix/crypto/account.py new file mode 100644 index 0000000..d045539 --- /dev/null +++ b/rubix/crypto/account.py @@ -0,0 +1,120 @@ +import os + +from dataclasses import dataclass +from .pem import secp256k1_pubkey_hex_to_pem, secp256k1_privkey_hex_to_pem, \ + secp256k1_privkey_pem_to_hex, secp256k1_pubkey_pem_to_hex +from .secp256k1 import Secp256k1Keypair + +@dataclass +class RubixAccount: + """ + Abstraction for accounts on Rubix + """ + did: str + keypair: Secp256k1Keypair + +# TODO: Use RubixAccount type param +def save_account_to_file(account_dir: str, public_key: bytes, private_key: bytes, + did: str, alias: str, passphrase: str = "mypassword") -> None: + alias_dir = os.path.join(account_dir, alias) + + if not os.path.exists(alias_dir): + os.makedirs(alias_dir) + else: + if not os.path.exists(alias_dir): + raise FileNotFoundError(f"alias directory does not exist: {alias_dir}") + + alias_subdir = [ + d for d in os.listdir(alias_dir) + if os.path.isdir(os.path.join(alias_dir, d)) + ] + + if len(alias_subdir) == 1: + raise FileExistsError(f"cannot create a key directory since one key directory already exists") + + if len(alias_subdir) > 1: + raise Exception(f"unexpected error: there seems to be multiple DID directories under alias_dir," + f"hence unable to proceed with key creation or import") + + key_dir = os.path.join(alias_dir, did) + if not os.path.exists(key_dir): + os.makedirs(key_dir) + + save_key_to_file(key_dir, public_key, private_key, passphrase=passphrase) + +def save_key_to_file(key_dir: str, public_key: bytes, private_key: bytes, + passphrase: str = "mypassword") -> None: + """ + save_account_to_file saves the Rubix Account in a configuration file. + + Args: + account_dir (str): The path to the configuration file. + public_key (bytes): The public key to save. + private_key (bytes): The private key to save. + did (str): The DID associated with the keys. + passphrase (str, optional): Passphrase for encrypting the private key. Defaults to + """ + + if public_key is None or len(public_key) == 0: + raise ValueError("Public key must not be empty") + if private_key is None or len(private_key) == 0: + raise ValueError("Private key must not be empty") + if key_dir == "": + raise ValueError("Config path must not be empty") + + secp256k1_pubkey_hex_to_pem(key_dir, public_key) + secp256k1_privkey_hex_to_pem(key_dir, private_key, passphrase=passphrase) + +def load_account_from_file(account_dir: str, alias: str, passphrase: str = "mypassword") -> RubixAccount: + alias_dir = os.path.join(account_dir, alias) + if not os.path.exists(alias_dir): + raise FileNotFoundError(f"alias directory does not exist: {alias_dir}") + + alias_subdir = [ + d for d in os.listdir(alias_dir) + if os.path.isdir(os.path.join(alias_dir, d)) + ] + + if len(alias_subdir) != 1: + raise ValueError(f"Expected one subdirectory in {alias_dir}, found {len(alias_subdir)}.") + + did = alias_subdir[0] + + key_dir_path = os.path.join(alias_dir, did) + keypair = load_key_from_file(key_dir_path, passphrase=passphrase) + + return RubixAccount( + did=did, + keypair=keypair + ) + +def load_key_from_file(key_dir: str, passphrase: str = "mypassword") -> Secp256k1Keypair: + """ + load_account_from_file loads the Secp256k1 keypair and DID from a configuration file. + + Args: + key_dir (str): The path to the Keypair files. + alias (str): The alias associated with the keys. + passphrase (str, optional): Passphrase for decrypting the private key. Defaults to + "mypassword". + + Returns: + Secp256k1Keypair: The loaded keypair. + """ + + if key_dir == "": + raise ValueError("Config path must not be empty") + + if not os.path.exists(key_dir): + raise FileNotFoundError(f"Key directory does not exist: {key_dir}") + + pub_key_path = os.path.join(key_dir, "pubKey.pem") + priv_key_path = os.path.join(key_dir, "privKey.pem") + + pub_hex = secp256k1_pubkey_pem_to_hex(pub_key_path) + priv_hex = secp256k1_privkey_pem_to_hex(priv_key_path, passphrase=passphrase) + + return Secp256k1Keypair( + private_key=priv_hex, + public_key=pub_hex + ) diff --git a/rubix/crypto/pem.py b/rubix/crypto/pem.py new file mode 100644 index 0000000..131896e --- /dev/null +++ b/rubix/crypto/pem.py @@ -0,0 +1,184 @@ +import os +import base64 +import textwrap +import secrets + +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from pathlib import Path + +def secp256k1_pubkey_hex_to_pem(file_path: str, public_key_bytes: bytes) -> None: + """ + secp256k1_to_pem writes the secp256k1 public key to a PEM file. + + Args: + file_path (str): The path to the PEM file. + public_key_bytes (bytes): The secp256k1 public key to write. + """ + if file_path == "": + raise ValueError("File path must not be empty") + if public_key_bytes is None: + raise ValueError("Public key must not be None") + + pub_key_file = "pubKey.pem" + + b64 = base64.b64encode(public_key_bytes).decode("ascii") + wrapped = "\n".join(textwrap.wrap(b64, 64)) + + pem_text = ( + "-----BEGIN PUBLIC KEY-----\n" + f"{wrapped}\n" + "-----END PUBLIC KEY-----\n" + ) + + complete_key_path = os.path.join(file_path, pub_key_file) + try: + Path(complete_key_path).write_text(pem_text, encoding="utf-8") + except Exception as e: + raise IOError(f"Failed to write public key to a PEM file: {e}") + + +def secp256k1_pubkey_pem_to_hex(filename: str) -> str: + """ + Extract the compressed secp256k1 public key hex from the PEM file. + + Args: + filename (str): The path to the PEM file. + + Returns: + str: The compressed secp256k1 public key in hexadecimal format. + """ + + pem = Path(filename).read_text(encoding="utf-8").strip() + + start = "-----BEGIN PUBLIC KEY-----" + end = "-----END PUBLIC KEY-----" + + if start not in pem or end not in pem: + raise ValueError("Not a valid PUBLIC KEY PEM.") + + body = pem.split(start)[1].split(end)[0].strip() + body = "".join(body.split()) # remove whitespace + + try: + pub_bytes = base64.b64decode(body) + except: + raise ValueError("Invalid base64 inside PEM.") + + if len(pub_bytes) != 33 or pub_bytes[0] not in (2, 3): + raise ValueError("Decoded key is not a compressed secp256k1 key.") + + return pub_bytes.hex() + +def _derive_key(passphrase: str, salt: bytes, iterations: int = 200_000) -> bytes: + """ + Derive a 32-byte key from passphrase + salt using PBKDF2-HMAC-SHA256. + """ + if not isinstance(passphrase, (bytes, str)): + raise TypeError("passphrase must be bytes or str") + if isinstance(passphrase, str): + passphrase = passphrase.encode("utf-8") + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=iterations, + ) + return kdf.derive(passphrase) + +def secp256k1_privkey_hex_to_pem(priv_key_dir: str, priv_hex_bytes: bytes, passphrase: str = "mypassword") -> None: + """ + Encrypt the 32-byte private key (hex string) and write it inside a ENCRYPTED PRIVATE KEY PEM-like block. + + Args: + priv_hex_bytes: Private Key in bytes + priv_key_dir: directory path to write PEM + passphrase: passphrase to encrypt with (default "mypassword") + """ + if not isinstance(priv_hex_bytes, bytes): + raise TypeError("priv_hex must be bytes or str") + + priv_hex = priv_hex_bytes.hex() + + priv_hex = priv_hex.strip().lower() + if len(priv_hex) != 64: + raise ValueError("Private key hex must be exactly 64 hex characters (32 bytes).") + + try: + priv_bytes = bytes.fromhex(priv_hex) + except Exception as e: + raise ValueError("Invalid private key hex.") from e + + if len(priv_bytes) != 32: + raise ValueError("Private key must be 32 bytes.") + + # Generate salt and nonce + salt = secrets.token_bytes(16) # 16 bytes salt for PBKDF2 + nonce = secrets.token_bytes(12) # 12 bytes nonce for AESGCM + + # Derive 32-byte key + key = _derive_key(passphrase, salt) + + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(nonce, priv_bytes, associated_data=None) + # Store: salt || nonce || ciphertext + stored = salt + nonce + ciphertext + + b64 = base64.b64encode(stored).decode("ascii") + wrapped = "\n".join(textwrap.wrap(b64, 64)) + pem_text = ( + "-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + f"{wrapped}\n" + "-----END ENCRYPTED PRIVATE KEY-----\n" + ) + + complete_priv_key_dir = os.path.join(priv_key_dir, "privKey.pem") + Path(complete_priv_key_dir).write_text(pem_text, encoding="utf-8") + +def secp256k1_privkey_pem_to_hex(filename: str, passphrase: str = "mypassword") -> str: + """ + Read the PEM written by privhex_to_hex, decrypt with passphrase, and return the private key hex. + + Args: + filename (str): The path to the PEM file. + passphrase (str): The passphrase used for decryption (default "mypassword"). + + Returns: + str: The decrypted private key in hexadecimal format. + """ + pem = Path(filename).read_text(encoding="utf-8") + start = "-----BEGIN ENCRYPTED PRIVATE KEY-----" + end = "-----END ENCRYPTED PRIVATE KEY-----" + + if start not in pem or end not in pem: + raise ValueError("PEM does not contain expected ENCRYPTED PRIVATE KEY headers.") + + body = pem.split(start, 1)[1].split(end, 1)[0].strip() + b64 = "".join(body.split()) + + try: + stored = base64.b64decode(b64) + except Exception as e: + raise ValueError("PEM body is not valid base64.") from e + + # Extract salt(16), nonce(12), ciphertext(rest) + if len(stored) < 16 + 12 + 16: + # must be at least salt+nonce+tag (tag is 16 bytes for GCM) even if empty ciphertext + raise ValueError("Stored data is too short to be valid.") + + salt = stored[:16] + nonce = stored[16:28] + ciphertext = stored[28:] + + key = _derive_key(passphrase, salt) + aesgcm = AESGCM(key) + try: + priv_bytes = aesgcm.decrypt(nonce, ciphertext, associated_data=None) + except Exception as e: + raise ValueError("Failed to decrypt private key (wrong passphrase or corrupted data).") from e + + if len(priv_bytes) != 32: + raise ValueError("Decrypted data is not a 32-byte private key.") + + return priv_bytes.hex() \ No newline at end of file diff --git a/rubix/crypto/secp256k1.py b/rubix/crypto/secp256k1.py index f2b0882..3df1958 100644 --- a/rubix/crypto/secp256k1.py +++ b/rubix/crypto/secp256k1.py @@ -112,6 +112,11 @@ def public_key(self) -> str: """Returns the public key in hexadecimal format.""" return self.__public_key + @property + def private_key(self) -> str: + """Returns the private key in hexadecimal format.""" + return self.__private_key + def sign(self, message: bytes) -> bytes: """Signs a message using secp256k1 private key. diff --git a/rubix/signer.py b/rubix/signer.py index 2895c85..e28b5c5 100644 --- a/rubix/signer.py +++ b/rubix/signer.py @@ -1,44 +1,98 @@ import base64 import os +from pathlib import Path from .client import RubixClient from .crypto.bip39 import generate_bip39_mnemonic, get_seed_from_mnemonic from .crypto.secp256k1 import Secp256k1Keypair from .did import create_did +from .crypto.account import save_account_to_file, load_account_from_file + +CONFIG_ACCOUNTS_DIR = "account" class Signer: """ - Singer provides abstraction for user keys management + Singer provides abstraction for user keys management """ - def __init__(self, rubixClient: RubixClient, mnemonic: str = ""): + def __init__(self, rubixClient: RubixClient, alias: str, mnemonic: str = "", config_path: str = "", + passphrase: str = "mypassword"): + """ + Initializes Signer instance. + + Args: + rubixClient (RubixClient): An instance of RubixClient for API interactions. + alias (str): Alias for Rubix Account. + mnemonic (str, optional): 24-word mnemonic phrase for key generation or import. Defaults to "". + config_path (str, optional): SDK config path. Defaults to "" + and internally is set to /.rubix_sdk. + passphrase (str, optional): Passphrase for encrypting/decrypting the private key. + Defaults to "mypassword". It is HIGHLY RECOMMENDED to provide a passphrase + """ + # Set config path + self.__config_path = "" + if config_path == "": + home_dir = Path.home() + default_config_path = os.path.join(home_dir, ".rubix_sdk") + self.__config_path = default_config_path + else: + self.__config_path = config_path + # Set Rubix Client if rubixClient is None: raise ValueError("RubixClient instance is required") - self.__client: RubixClient = rubixClient # Set or generate Mnemonic self.__mnemonic = "" - if mnemonic == "": mnemonic_str = generate_bip39_mnemonic() if mnemonic_str is None or mnemonic_str.strip() == "": - raise ValueError("Failed to generate mnemonic phrase.") - + raise ValueError("Failed to generate mnemonic phrase.") self.__mnemonic = mnemonic_str else: - self.__mnemonic = mnemonic - - # Get the secp256k1 keypair from mnemonic - seed = get_seed_from_mnemonic(self.__mnemonic) - self.__keypair = Secp256k1Keypair.from_mnemonic_seed(seed) + self.__mnemonic = mnemonic - did = create_did(self.__keypair, self.__client.node_url) - if did is None or did.strip() == "": - raise ValueError("Failed to create DID from mnemonic seed.") + # Check if alias has been provided for their account + if alias == "": + raise ValueError("alias must be provided to initiate Signer") - self.did = did - self.quorum_type = 2 + complete_account_dir = os.path.join(self.__config_path, CONFIG_ACCOUNTS_DIR) + + # If the alias directory doesn't exists, create it with keypair. The keypair + # could either come from a mnemonic or be newly generated. + complete_key_path = os.path.join(complete_account_dir, alias) + if not os.path.exists(complete_key_path): + # Get the secp256k1 keypair from mnemonic + seed = get_seed_from_mnemonic(self.__mnemonic) + self.__keypair = Secp256k1Keypair.from_mnemonic_seed(seed) + + # Request DID creation from Rubix node + created_did = create_did(self.__keypair, self.__client.node_url) + if created_did is None or created_did.strip() == "": + raise ValueError("Failed to create DID from mnemonic seed.") + + self.did = created_did + self.quorum_type = 2 + + # Save keys to config file + save_account_to_file( + account_dir=complete_account_dir, + public_key=bytes.fromhex(self.__keypair.public_key), + private_key=bytes.fromhex(self.__keypair.private_key), + did=self.did, + alias=alias, + passphrase=passphrase + ) + else: + # Load keys from config file + rubixAcccount = load_account_from_file( + account_dir=complete_account_dir, + alias=alias, + passphrase=passphrase + ) + self.__keypair = rubixAcccount.keypair + self.did = rubixAcccount.did + self.quorum_type = 2 def __quorum_type(self) -> int: """Returns the quorum type for transaction""" diff --git a/tests/crypto/test_key_storage.py b/tests/crypto/test_key_storage.py new file mode 100644 index 0000000..2512dce --- /dev/null +++ b/tests/crypto/test_key_storage.py @@ -0,0 +1,48 @@ +import os +import shutil +import pytest + +from rubix.crypto.account import load_account_from_file, save_account_to_file + +def test_save_and_load_keys(): + """Test saving and loading of Secp256k1 keys to and from files.""" + tmppath = os.path.abspath(os.path.join(os.getcwd(), 'tests', 'fixtures')) + + # Sample keys and DID + public_key = bytes.fromhex("03b8edb32c69e16b8d30de87b48aedc4fa09f1643fbaa3e85dbb1932498ea94b0a") + private_key = bytes.fromhex("e32a09e939376358c37c8780beb632f5cf2fa12c8a53bc77984e60964fd59c78") + did = "bafybmicbopex4ydytrtremmculwpo7e5p2uvbkuw2ds775xmkcsc5lglai" + passphrase = "testpassphrase" + + # Save keys to temporary directory + save_account_to_file(str(tmppath), public_key, private_key, did, "nero", passphrase=passphrase) + + # Load keys back + account = load_account_from_file(str(tmppath), "nero", passphrase=passphrase) + + # Verify loaded keys + assert account.keypair.public_key == public_key.hex() + assert account.keypair.private_key == private_key.hex() + + with pytest.raises(Exception): + load_account_from_file(str(tmppath), "nero", passphrase="wrongpassphrase") + + shutil.rmtree(tmppath) + +def test_multiple_dir_in_alias_dir(): + tmppath = os.path.abspath(os.path.join(os.getcwd(), 'tests', 'fixtures')) + + # Sample keys and DID + public_key = bytes.fromhex("03b8edb32c69e16b8d30de87b48aedc4fa09f1643fbaa3e85dbb1932498ea94b0a") + private_key = bytes.fromhex("e32a09e939376358c37c8780beb632f5cf2fa12c8a53bc77984e60964fd59c78") + did = "bafybmicbopex4ydytrtremmculwpo7e5p2uvbkuw2ds775xmkcsc5lglai" + passphrase = "testpassphrase" + + # Save keys to temporary directory + save_account_to_file(str(tmppath), public_key, private_key, did, "nero", passphrase=passphrase) + + with pytest.raises(Exception): + save_account_to_file(str(tmppath), public_key, private_key, "bafybmiffsqtxnrinhp4c7sa3y3pju5whxdcf3ndzjxqdbeglgw3yzcnkkm", "nero", passphrase=passphrase) + + shutil.rmtree(tmppath) + \ No newline at end of file diff --git a/tests/crypto/test_secp256k1.py b/tests/crypto/test_secp256k1.py index 8efbe71..84de755 100644 --- a/tests/crypto/test_secp256k1.py +++ b/tests/crypto/test_secp256k1.py @@ -57,4 +57,3 @@ def test_secp256k1_verify_invalid_message(): is_valid = secp256k1_verify(bytes.fromhex(keypair.public_key), message_2, signature) assert is_valid is False - diff --git a/tests/test_client.py b/tests/test_client.py index 524f0ea..ccba6ed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,3 +8,8 @@ def test_client_creation_with_valid_url(): client = RubixClient(valid_node_url) assert client is not None + + client_default_url = RubixClient() + + assert client_default_url is not None + assert client_default_url.node_url == "http://localhost:20000", "Default node URL should be http://localhost:20000"