Merge pull request #8 from PR0M3TH3AN/codex/extend-configmanager-to-store-bcrypt-hashed-pin

Add PIN protected settings
This commit is contained in:
thePR0M3TH3AN
2025-06-28 22:04:46 -04:00
committed by GitHub
4 changed files with 194 additions and 87 deletions

View File

@@ -173,6 +173,7 @@ pytest
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. The maximum size of the password index before encountering issues with Nostr backups is unknown. Additionally, the security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.

View File

@@ -3,6 +3,7 @@ import os
import sys
import logging
import signal
import getpass
from colorama import init as colorama_init
from termcolor import colored
import traceback
@@ -12,6 +13,7 @@ from nostr.client import NostrClient
colorama_init()
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
@@ -21,20 +23,22 @@ def configure_logging():
logger.removeHandler(handler)
# Ensure the 'logs' directory exists
log_directory = 'logs'
log_directory = "logs"
if not os.path.exists(log_directory):
os.makedirs(log_directory)
# Create handlers
c_handler = logging.StreamHandler(sys.stdout)
f_handler = logging.FileHandler(os.path.join(log_directory, 'main.log'))
f_handler = logging.FileHandler(os.path.join(log_directory, "main.log"))
# Set levels: only errors and critical messages will be shown in the console
c_handler.setLevel(logging.ERROR)
f_handler.setLevel(logging.DEBUG)
# Create formatters and add them to handlers
formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]')
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]"
)
c_handler.setFormatter(formatter)
f_handler.setFormatter(formatter)
@@ -43,8 +47,9 @@ def configure_logging():
logger.addHandler(f_handler)
# Set logging level for third-party libraries to WARNING to suppress their debug logs
logging.getLogger('monstr').setLevel(logging.WARNING)
logging.getLogger('nostr').setLevel(logging.WARNING)
logging.getLogger("monstr").setLevel(logging.WARNING)
logging.getLogger("nostr").setLevel(logging.WARNING)
def confirm_action(prompt: str) -> bool:
"""
@@ -54,13 +59,14 @@ def confirm_action(prompt: str) -> bool:
:return: True if user confirms, False otherwise.
"""
while True:
choice = input(colored(prompt, 'yellow')).strip().lower()
if choice in ['y', 'yes']:
choice = input(colored(prompt, "yellow")).strip().lower()
if choice in ["y", "yes"]:
return True
elif choice in ['n', 'no']:
elif choice in ["n", "no"]:
return False
else:
print(colored("Please enter 'Y' or 'N'.", 'red'))
print(colored("Please enter 'Y' or 'N'.", "red"))
def handle_switch_fingerprint(password_manager: PasswordManager):
"""
@@ -71,27 +77,33 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
try:
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
if not fingerprints:
print(colored("No fingerprints available to switch. Please add a new fingerprint first.", 'yellow'))
print(
colored(
"No fingerprints available to switch. Please add a new fingerprint first.",
"yellow",
)
)
return
print(colored("Available Fingerprints:", 'cyan'))
print(colored("Available Fingerprints:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1):
print(colored(f"{idx}. {fp}", 'cyan'))
print(colored(f"{idx}. {fp}", "cyan"))
choice = input("Select a fingerprint by number to switch: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection.", 'red'))
print(colored("Invalid selection.", "red"))
return
selected_fingerprint = fingerprints[int(choice)-1]
selected_fingerprint = fingerprints[int(choice) - 1]
if password_manager.select_fingerprint(selected_fingerprint):
print(colored(f"Switched to fingerprint {selected_fingerprint}.", 'green'))
print(colored(f"Switched to fingerprint {selected_fingerprint}.", "green"))
else:
print(colored("Failed to switch fingerprint.", 'red'))
print(colored("Failed to switch fingerprint.", "red"))
except Exception as e:
logging.error(f"Error during fingerprint switch: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to switch fingerprint: {e}", 'red'))
print(colored(f"Error: Failed to switch fingerprint: {e}", "red"))
def handle_add_new_fingerprint(password_manager: PasswordManager):
"""
@@ -104,7 +116,8 @@ def handle_add_new_fingerprint(password_manager: PasswordManager):
except Exception as e:
logging.error(f"Error adding new fingerprint: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to add new fingerprint: {e}", 'red'))
print(colored(f"Error: Failed to add new fingerprint: {e}", "red"))
def handle_remove_fingerprint(password_manager: PasswordManager):
"""
@@ -115,31 +128,41 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
try:
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
if not fingerprints:
print(colored("No fingerprints available to remove.", 'yellow'))
print(colored("No fingerprints available to remove.", "yellow"))
return
print(colored("Available Fingerprints:", 'cyan'))
print(colored("Available Fingerprints:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1):
print(colored(f"{idx}. {fp}", 'cyan'))
print(colored(f"{idx}. {fp}", "cyan"))
choice = input("Select a fingerprint by number to remove: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection.", 'red'))
print(colored("Invalid selection.", "red"))
return
selected_fingerprint = fingerprints[int(choice)-1]
confirm = confirm_action(f"Are you sure you want to remove fingerprint {selected_fingerprint}? This will delete all associated data. (Y/N): ")
selected_fingerprint = fingerprints[int(choice) - 1]
confirm = confirm_action(
f"Are you sure you want to remove fingerprint {selected_fingerprint}? This will delete all associated data. (Y/N): "
)
if confirm:
if password_manager.fingerprint_manager.remove_fingerprint(selected_fingerprint):
print(colored(f"Fingerprint {selected_fingerprint} removed successfully.", 'green'))
if password_manager.fingerprint_manager.remove_fingerprint(
selected_fingerprint
):
print(
colored(
f"Fingerprint {selected_fingerprint} removed successfully.",
"green",
)
)
else:
print(colored("Failed to remove fingerprint.", 'red'))
print(colored("Failed to remove fingerprint.", "red"))
else:
print(colored("Fingerprint removal cancelled.", 'yellow'))
print(colored("Fingerprint removal cancelled.", "yellow"))
except Exception as e:
logging.error(f"Error removing fingerprint: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to remove fingerprint: {e}", 'red'))
print(colored(f"Error: Failed to remove fingerprint: {e}", "red"))
def handle_list_fingerprints(password_manager: PasswordManager):
"""
@@ -150,16 +173,17 @@ def handle_list_fingerprints(password_manager: PasswordManager):
try:
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
if not fingerprints:
print(colored("No fingerprints available.", 'yellow'))
print(colored("No fingerprints available.", "yellow"))
return
print(colored("Available Fingerprints:", 'cyan'))
print(colored("Available Fingerprints:", "cyan"))
for fp in fingerprints:
print(colored(f"- {fp}", 'cyan'))
print(colored(f"- {fp}", "cyan"))
except Exception as e:
logging.error(f"Error listing fingerprints: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to list fingerprints: {e}", 'red'))
print(colored(f"Error: Failed to list fingerprints: {e}", "red"))
def handle_display_npub(password_manager: PasswordManager):
"""
@@ -168,15 +192,16 @@ def handle_display_npub(password_manager: PasswordManager):
try:
npub = password_manager.nostr_client.key_manager.get_npub()
if npub:
print(colored(f"\nYour Nostr Public Key (npub):\n{npub}\n", 'cyan'))
print(colored(f"\nYour Nostr Public Key (npub):\n{npub}\n", "cyan"))
logging.info("Displayed npub to the user.")
else:
print(colored("Nostr public key not available.", 'red'))
print(colored("Nostr public key not available.", "red"))
logging.error("Nostr public key not available.")
except Exception as e:
logging.error(f"Failed to display npub: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to display npub: {e}", 'red'))
print(colored(f"Error: Failed to display npub: {e}", "red"))
def handle_post_to_nostr(password_manager: PasswordManager):
"""
@@ -188,15 +213,16 @@ def handle_post_to_nostr(password_manager: PasswordManager):
if encrypted_data:
# Post to Nostr
password_manager.nostr_client.publish_json_to_nostr(encrypted_data)
print(colored("Encrypted index posted to Nostr successfully.", 'green'))
print(colored("Encrypted index posted to Nostr successfully.", "green"))
logging.info("Encrypted index posted to Nostr successfully.")
else:
print(colored("No data available to post.", 'yellow'))
print(colored("No data available to post.", "yellow"))
logging.warning("No data available to post to Nostr.")
except Exception as e:
logging.error(f"Failed to post to Nostr: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to post to Nostr: {e}", 'red'))
print(colored(f"Error: Failed to post to Nostr: {e}", "red"))
def handle_retrieve_from_nostr(password_manager: PasswordManager):
"""
@@ -207,16 +233,50 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
encrypted_data = password_manager.nostr_client.retrieve_json_from_nostr_sync()
if encrypted_data:
# Decrypt and save the index
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
print(colored("Encrypted index retrieved and saved successfully.", 'green'))
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data
)
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
else:
print(colored("Failed to retrieve data from Nostr.", 'red'))
print(colored("Failed to retrieve data from Nostr.", "red"))
logging.error("Failed to retrieve data from Nostr.")
except Exception as e:
logging.error(f"Failed to retrieve from Nostr: {e}")
logging.error(traceback.format_exc())
print(colored(f"Error: Failed to retrieve from Nostr: {e}", 'red'))
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
def handle_settings(password_manager: PasswordManager):
"""Display settings menu for relay list and PIN changes."""
cfg_mgr = password_manager.config_manager
if cfg_mgr is None:
print(colored("Configuration manager unavailable.", "red"))
return
try:
cfg = cfg_mgr.load_config()
except Exception as e:
print(colored(f"Error loading settings: {e}", "red"))
return
print("\nSettings:")
print("1. Set Nostr relays")
print("2. Change settings PIN")
print("3. Back")
choice = input("Select an option: ").strip()
if choice == "1":
relays = input("Enter comma-separated relay URLs: ").split(",")
relays = [r.strip() for r in relays if r.strip()]
cfg_mgr.set_relays(relays)
print(colored("Relays updated.", "green"))
elif choice == "2":
old_pin = getpass.getpass("Current PIN: ")
new_pin = getpass.getpass("New PIN: ")
if cfg_mgr.change_pin(old_pin, new_pin):
print(colored("PIN changed successfully.", "green"))
else:
print(colored("Incorrect current PIN.", "red"))
def display_menu(password_manager: PasswordManager):
"""
@@ -236,56 +296,65 @@ def display_menu(password_manager: PasswordManager):
10. Add a New Fingerprint
11. Remove an Existing Fingerprint
12. List All Fingerprints
13. Exit
13. Settings
14. Exit
"""
while True:
# Flush logging handlers
for handler in logging.getLogger().handlers:
handler.flush()
print(colored(menu, 'cyan'))
choice = input('Enter your choice (1-13): ').strip()
print(colored(menu, "cyan"))
choice = input("Enter your choice (1-14): ").strip()
if not choice:
print(colored("No input detected. Please enter a number between 1 and 13.", 'yellow'))
print(
colored(
"No input detected. Please enter a number between 1 and 14.",
"yellow",
)
)
continue # Re-display the menu without marking as invalid
if choice == '1':
if choice == "1":
password_manager.handle_generate_password()
elif choice == '2':
elif choice == "2":
password_manager.handle_retrieve_password()
elif choice == '3':
elif choice == "3":
password_manager.handle_modify_entry()
elif choice == '4':
elif choice == "4":
password_manager.handle_verify_checksum()
elif choice == '5':
elif choice == "5":
handle_post_to_nostr(password_manager)
elif choice == '6':
elif choice == "6":
handle_retrieve_from_nostr(password_manager)
elif choice == '7':
elif choice == "7":
handle_display_npub(password_manager)
elif choice == '8':
elif choice == "8":
password_manager.handle_backup_reveal_parent_seed()
elif choice == '9':
elif choice == "9":
if not password_manager.handle_switch_fingerprint():
print(colored("Failed to switch fingerprint.", 'red'))
elif choice == '10':
print(colored("Failed to switch fingerprint.", "red"))
elif choice == "10":
handle_add_new_fingerprint(password_manager)
elif choice == '11':
elif choice == "11":
handle_remove_fingerprint(password_manager)
elif choice == '12':
elif choice == "12":
handle_list_fingerprints(password_manager)
elif choice == '13':
elif choice == "13":
handle_settings(password_manager)
elif choice == "14":
logging.info("Exiting the program.")
print(colored("Exiting the program.", 'green'))
print(colored("Exiting the program.", "green"))
password_manager.nostr_client.close_client_pool()
sys.exit(0)
else:
print(colored("Invalid choice. Please select a valid option.", 'red'))
print(colored("Invalid choice. Please select a valid option.", "red"))
if __name__ == '__main__':
if __name__ == "__main__":
# Configure logging with both file and console handlers
configure_logging()
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
# Initialize PasswordManager and proceed with application logic
try:
password_manager = PasswordManager()
@@ -293,49 +362,49 @@ if __name__ == '__main__':
except Exception as e:
logger.error(f"Failed to initialize PasswordManager: {e}")
logger.error(traceback.format_exc()) # Log full traceback
print(colored(f"Error: Failed to initialize PasswordManager: {e}", 'red'))
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
sys.exit(1)
# Register signal handlers for graceful shutdown
def signal_handler(sig, frame):
"""
Handles termination signals to gracefully shutdown the NostrClient.
"""
print(colored("\nReceived shutdown signal. Exiting gracefully...", 'yellow'))
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool
logging.info("NostrClient closed successfully.")
except Exception as e:
logging.error(f"Error during shutdown: {e}")
print(colored(f"Error during shutdown: {e}", 'red'))
print(colored(f"Error during shutdown: {e}", "red"))
sys.exit(0)
# Register the signal handlers
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C
signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C
signal.signal(signal.SIGTERM, signal_handler) # Handle termination signals
# Display the interactive menu to the user
try:
display_menu(password_manager)
except KeyboardInterrupt:
logger.info("Program terminated by user via KeyboardInterrupt.")
print(colored("\nProgram terminated by user.", 'yellow'))
print(colored("\nProgram terminated by user.", "yellow"))
try:
password_manager.nostr_client.close_client_pool() # Gracefully close the ClientPool
logging.info("NostrClient closed successfully.")
except Exception as e:
logging.error(f"Error during shutdown: {e}")
print(colored(f"Error during shutdown: {e}", 'red'))
print(colored(f"Error during shutdown: {e}", "red"))
sys.exit(0)
except Exception as e:
logger.error(f"An unexpected error occurred: {e}")
logger.error(traceback.format_exc()) # Log full traceback
print(colored(f"Error: An unexpected error occurred: {e}", 'red'))
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
try:
password_manager.nostr_client.close_client_pool() # Attempt to close the ClientPool
logging.info("NostrClient closed successfully.")
except Exception as close_error:
logging.error(f"Error during shutdown: {close_error}")
print(colored(f"Error during shutdown: {close_error}", 'red'))
sys.exit(1)
print(colored(f"Error during shutdown: {close_error}", "red"))
sys.exit(1)

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import List
from typing import List, Optional
import getpass
import bcrypt
@@ -24,8 +26,15 @@ class ConfigManager:
self.fingerprint_dir = fingerprint_dir
self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME
def load_config(self) -> dict:
"""Load the configuration file, returning defaults if none exists."""
def load_config(self, require_pin: bool = True) -> dict:
"""Load the configuration file and optionally verify a stored PIN.
Parameters
----------
require_pin: bool, default True
If True and a PIN is configured, prompt the user to enter it and
verify against the stored hash.
"""
if not self.config_path.exists():
logger.info("Config file not found; returning defaults")
return {"relays": list(DEFAULT_NOSTR_RELAYS), "pin_hash": ""}
@@ -36,6 +45,14 @@ class ConfigManager:
# Ensure defaults for missing keys
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("pin_hash", "")
if require_pin and data.get("pin_hash"):
for _ in range(3):
pin = getpass.getpass("Enter settings PIN: ").strip()
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
break
print("Invalid PIN")
else:
raise ValueError("PIN verification failed")
return data
except Exception as exc:
logger.error(f"Failed to load config: {exc}")
@@ -49,23 +66,30 @@ class ConfigManager:
logger.error(f"Failed to save config: {exc}")
raise
def set_relays(self, relays: List[str]) -> None:
def set_relays(self, relays: List[str], require_pin: bool = True) -> None:
"""Update relay list and save."""
config = self.load_config()
config = self.load_config(require_pin=require_pin)
config["relays"] = relays
self.save_config(config)
def set_pin(self, pin: str) -> None:
"""Hash and store the provided PIN."""
pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
config = self.load_config()
config = self.load_config(require_pin=False)
config["pin_hash"] = pin_hash
self.save_config(config)
def verify_pin(self, pin: str) -> bool:
"""Check a provided PIN against the stored hash."""
config = self.load_config()
"""Check a provided PIN against the stored hash without prompting."""
config = self.load_config(require_pin=False)
stored = config.get("pin_hash", "").encode()
if not stored:
return False
return bcrypt.checkpw(pin.encode(), stored)
def change_pin(self, old_pin: str, new_pin: str) -> bool:
"""Update the stored PIN if the old PIN is correct."""
if self.verify_pin(old_pin):
self.set_pin(new_pin)
return True
return False

View File

@@ -17,13 +17,26 @@ def test_config_defaults_and_round_trip():
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
cfg = cfg_mgr.load_config()
cfg = cfg_mgr.load_config(require_pin=False)
assert cfg["relays"] == list(DEFAULT_RELAYS)
assert cfg["pin_hash"] == ""
cfg_mgr.set_pin("1234")
cfg_mgr.set_relays(["wss://example.com"])
cfg_mgr.set_relays(["wss://example.com"], require_pin=False)
cfg2 = cfg_mgr.load_config()
cfg2 = cfg_mgr.load_config(require_pin=False)
assert cfg2["relays"] == ["wss://example.com"]
assert bcrypt.checkpw(b"1234", cfg2["pin_hash"].encode())
def test_pin_verification_and_change():
with TemporaryDirectory() as tmpdir:
key = Fernet.generate_key()
enc_mgr = EncryptionManager(key, Path(tmpdir))
cfg_mgr = ConfigManager(enc_mgr, Path(tmpdir))
cfg_mgr.set_pin("1234")
assert cfg_mgr.verify_pin("1234")
assert not cfg_mgr.verify_pin("0000")
assert cfg_mgr.change_pin("1234", "5678")
assert cfg_mgr.verify_pin("5678")