Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,11 @@ Cryptnox Pro
.. image:: https://img.shields.io/pypi/v/cryptnoxpro
:target: https://pypi.org/project/cryptnoxpro


**Warning: This is a beta release of the software.
It is released for development purposes.
Use at your own risk.**

A command line user interface to manage and use of `Cryptnox Smart cards <https://www.cryptnox.com/>`_.

This provides basic wallets for `Bitcoin <https://bitcoin.org>`_ and
`Ethereum <https://ethereum.org>`_.

It is able to execute `cleos <https://eos.io/for-developers/build/cleos/>`_ commands and use
the keys on the card for signing.

To buy NFC enabled cards that are supported by this application go to:
`https://www.cryptnox.com/ <https://www.cryptnox.com/>`_

Expand Down
59 changes: 59 additions & 0 deletions cryptnoxpro/command/helper/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from tabulate import tabulate

from . import security
from cryptnoxpro.config import MAX_PASSPHRASE_LENGTH

InitData = namedtuple("InitData", ["name", "email", "pin", "puk"])

Expand Down Expand Up @@ -201,3 +202,61 @@ def _verify_input(card: cryptnoxpy.Card, method, text, verify_text,
print(error)

return value


def get_bip39_passphrase(confirm_required: bool = True) -> str:
print()
print_warning("BIP39 PASSPHRASE FEATURE")
print("A BIP39 passphrase is an optional extra password that provides additional security.")
print("Different passphrases create completely different wallets from the same mnemonic.")
print()
print("IMPORTANT WARNINGS:")
print(" • If you forget your passphrase, your funds are PERMANENTLY LOST")
print(" • Passphrases are case-sensitive and spaces matter")
print(" • There is NO way to recover funds without the exact passphrase")
print(" • Press ENTER to skip passphrase (standard BIP39 with empty passphrase)")
print()

use_passphrase = confirm("Do you want to use a BIP39 passphrase?")

if not use_passphrase:
return ""

print()
print("Enter your BIP39 passphrase:")
print("(Any UTF-8 characters are valid, but avoid unusual characters you might forget)")

if confirm_required:
passphrase = _verify_passphrase_input()
else:
passphrase = getpass("Passphrase: ")

if len(passphrase) > MAX_PASSPHRASE_LENGTH:
print("ERROR: Passphrase must be at most 100 characters. Please try again.")
raise ExitException("Passphrase exceeds 100 characters")

print()
print("Please write down your passphrase and store it securely.")
print("Without this exact passphrase, you CANNOT access your wallet.")
print()

if not confirm("Have you securely stored your passphrase?"):
raise ExitException("User needs to store passphrase securely")

return passphrase


def _verify_passphrase_input() -> str:
while True:
passphrase = getpass("Passphrase: ")
if len(passphrase) > MAX_PASSPHRASE_LENGTH:
print("ERROR: Passphrase must be at most 100 characters. Please try again.")
print()
continue
verify_passphrase = getpass("Confirm passphrase: ")

if passphrase == verify_passphrase:
return passphrase

print("ERROR: Passphrases do not match. Please try again.")
print()
142 changes: 131 additions & 11 deletions cryptnoxpro/command/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,21 @@ def _execute(self, card) -> int:
return result

@staticmethod
def _backup(card: cryptnoxpy.Card, pin_code: str = None, seed: bytes = None) -> int:
def _backup(card: cryptnoxpy.Card, pin_code: str = None, seed: bytes = None,
passphrase: str = '') -> int:
"""
Backup seed to AWS KMS and load onto card with optional BIP39 passphrase.

Note: The backup service stores only the raw seed entropy, not the passphrase.
The passphrase is used only when loading the mnemonic onto the card.
Users must remember their passphrase separately.

:param card: The Cryptnox card
:param pin_code: The card PIN code
:param seed: Optional pre-generated seed bytes
:param passphrase: Optional BIP39 passphrase
:return: 0 on success, -1 on error
"""
pin_code = pin_code or Seed._get_pin_code(card)

credentials_file = Path(get_download_folder()).joinpath('rootkey.csv')
Expand Down Expand Up @@ -121,7 +135,7 @@ def _backup(card: cryptnoxpy.Card, pin_code: str = None, seed: bytes = None) ->

print("Seed successfully saved. Writing card.")
mnemonic = cryptos.entropy_to_words(seed)
Seed._load_mnemonic(card, mnemonic, pin_code)
Seed._load_mnemonic(card, mnemonic, pin_code, passphrase=passphrase)

data = list(service.region.items())
data.append(("name", name))
Expand All @@ -131,6 +145,13 @@ def _backup(card: cryptnoxpy.Card, pin_code: str = None, seed: bytes = None) ->
backup_file.write_text(tabulate(data))
print(f"\nBackup data also saved to: {backup_file}")

if passphrase:
print()
ui.print_warning("IMPORTANT: BIP39 PASSPHRASE IN USE")
print("The backup service stores only the seed entropy, NOT your passphrase.")
print("You must remember and store your BIP39 passphrase separately!")
print("To restore this wallet, you will need BOTH the backup AND your passphrase.")

Seed._remove_credentials(credentials_file, service)

return 0
Expand Down Expand Up @@ -162,8 +183,23 @@ def _backup_service(access_key_id: str = '', secret_access_key: str = '') -> Uni

@staticmethod
def _chip(card: cryptnoxpy.Card) -> int:
"""
Generate seed directly in the card's secure chip.

Note: This method generates the seed entirely on the card's hardware and does not
use BIP39 mnemonics. Therefore, BIP39 passphrases do not apply to this seed
generation method. The seed cannot be exported or backed up as a mnemonic phrase.

:param card: The Cryptnox card
:return: 0 on success
"""
pin_code = Seed._get_pin_code(card)

print("Generating seed directly in card's secure chip...")
print("Note: This seed is generated on-chip and does not use BIP39 mnemonics.")
print("BIP39 passphrases do not apply to this generation method.")
print()

card.generate_seed(pin_code)
print("New key generated in card.")

Expand Down Expand Up @@ -191,6 +227,17 @@ def _credentials(credential_file: Path = None) -> Tuple[str, str]:
return access_key_id, secret_access_key

def _dual_seed(self, card: cryptnoxpy.Card) -> int:
"""
Generate the same seed on two cards using secure dual-card generation.

Note: This method generates seeds using a secure two-card protocol and does not
use BIP39 mnemonics. Therefore, BIP39 passphrases do not apply to this seed
generation method. The seed is generated securely on both cards and never leaves
the hardware.

:param card: The first Cryptnox card
:return: 0 on success, -1 or -2 on error
"""
try:
card.dual_seed_public_key()
except NotImplementedError as error:
Expand All @@ -199,6 +246,11 @@ def _dual_seed(self, card: cryptnoxpy.Card) -> int:
except cryptnoxpy.exceptions.DataValidationException:
pass

print("Dual seed generation process starting...")
print("Note: This method uses secure on-chip generation and does not use BIP39 mnemonics.")
print("BIP39 passphrases do not apply to this generation method.")
print()

pin_code = Seed._get_pin_code(card)

serial_number = card.serial_number
Expand Down Expand Up @@ -287,35 +339,59 @@ def _get_second_card(self, index: int, first_card_serial_number: int) -> cryptno
return card

@staticmethod
def _load_mnemonic(card: cryptnoxpy.Card, mnemonic: str, pin_code: str) -> None:
def _load_mnemonic(card: cryptnoxpy.Card, mnemonic: str, pin_code: str,
passphrase: str = '') -> None:
"""
Load mnemonic onto card with optional BIP39 passphrase.

:param card: The Cryptnox card
:param mnemonic: The BIP39 mnemonic phrase (12 or 24 words)
:param pin_code: The card PIN code
:param passphrase: Optional BIP39 passphrase (13th/25th word)
"""
if len(mnemonic.split(' ')) not in (12, 24):
raise ValueError('Only mnemonic passphrases of length 12 and 24 are supported')
seed = cryptos.bip39_mnemonic_to_seed(mnemonic)
seed = cryptos.bip39_mnemonic_to_seed(mnemonic, passphrase=passphrase)
card.load_seed(seed, pin_code)

@staticmethod
def _recover(card: cryptnoxpy.Card) -> int:
"""
Recover wallet from BIP39 mnemonic phrase with optional passphrase.

:param card: The Cryptnox card
:return: 0 on success, -1 on error
"""
pin_code = Seed._get_pin_code(card)

print("\nEnter the mnemonic root to recover (12 or 24 words):")
mnemonic = ui.input_with_exit("> ")

try:
seed = cryptos.bip39_mnemonic_to_seed(mnemonic)
passphrase = ui.get_bip39_passphrase(confirm_required=True)
except ui.ExitException as error:
print(error)
return -1

try:
seed = cryptos.bip39_mnemonic_to_seed(mnemonic, passphrase=passphrase)
except Exception as error:
print(error)
return -1

do_backup = ui.confirm('Do you wish to backup your seed to AWS KMS?')
try:
if do_backup:
Seed._backup(card, pin_code, seed)
Seed._backup(card, pin_code, seed, passphrase=passphrase)
else:
Seed._load_mnemonic(card, mnemonic, pin_code)
Seed._load_mnemonic(card, mnemonic, pin_code, passphrase=passphrase)
except Exception as error:
print(error)
return -1

print("Mnemonic loaded, please keep it safe for backup.")
if passphrase:
print("You MUST use the same passphrase when restoring this wallet.")

return 0

Expand All @@ -331,6 +407,15 @@ def _remove_credentials(credentials_file: Path, service: backup.AWS) -> None:

@staticmethod
def _restore(card: cryptnoxpy.Card) -> int:
"""
Restore seed from AWS KMS backup with optional BIP39 passphrase.

Note: The backup service stores only the seed entropy. If a BIP39 passphrase
was used when creating the wallet, it must be provided again during restore.

:param card: The Cryptnox card
:return: 0 on success, -1 on error
"""
pin_code = Seed._get_pin_code(card)
credentials_file = Path(get_download_folder()).joinpath('rootkey.csv')
access_key_id, secret_access_key = Seed._credentials(credentials_file)
Expand All @@ -357,8 +442,19 @@ def _restore(card: cryptnoxpy.Card) -> int:
print("There was an issue with the retrieved backup in converting it to a mnemonic.")
return -1

print("Seed successfully retrieved. Writing card.")
Seed._load_mnemonic(card, mnemonic, pin_code)
print("Seed successfully retrieved.")
print()
print("If you used a BIP39 passphrase when creating this wallet,")
print("you must provide the same passphrase now.")

try:
passphrase = ui.get_bip39_passphrase(confirm_required=False)
except ui.ExitException as error:
print(error)
return -1

print("Writing card.")
Seed._load_mnemonic(card, mnemonic, pin_code, passphrase=passphrase)
print("\nMnemonic loaded from backup service.")

Seed._remove_credentials(credentials_file, service)
Expand All @@ -367,16 +463,29 @@ def _restore(card: cryptnoxpy.Card) -> int:

@staticmethod
def _upload(card: cryptnoxpy.Card) -> int:
"""
Generate new seed, optionally with BIP39 passphrase, and upload to card.

:param card: The Cryptnox card
:return: 0 on success, -1 on error
"""
pin_code = Seed._get_pin_code(card)
seed = card.generate_random_number(32)
mnemonic = cryptos.entropy_to_words(seed)

try:
passphrase = ui.get_bip39_passphrase(confirm_required=True)
except ui.ExitException as error:
print(error)
return -1

do_backup = ui.confirm('Do you wish to backup your seed to AWS KMS?')

try:
if do_backup:
Seed._backup(card, pin_code, seed)
Seed._backup(card, pin_code, seed, passphrase=passphrase)
else:
Seed._load_mnemonic(card, mnemonic, pin_code)
Seed._load_mnemonic(card, mnemonic, pin_code, passphrase=passphrase)
except Exception as error:
print(error)
return -1
Expand All @@ -386,4 +495,15 @@ def _upload(card: cryptnoxpy.Card) -> int:

print("\nMnemonic loaded, please save this root mnemonic for backup.")

if passphrase:
print()
ui.print_warning("IMPORTANT: BIP39 PASSPHRASE IN USE")
print("You are using a BIP39 passphrase with this wallet.")
print("To restore this wallet, you will need BOTH:")
print(" 1. The mnemonic phrase shown above")
print(" 2. Your BIP39 passphrase")
print()
print("Store your passphrase separately and securely!")
print("Without the exact passphrase, you CANNOT access your funds.")

return 0
1 change: 1 addition & 0 deletions cryptnoxpro/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

_CONFIGURATION = {}
REMOTE_CONNECTIONS = []
MAX_PASSPHRASE_LENGTH = 100


def get_default_configuration() -> Dict:
Expand Down
Loading