Add PIN protection to settings

This commit is contained in:
thePR0M3TH3AN
2025-06-28 22:04:30 -04:00
parent fb65f3ed7e
commit f9bc040736
4 changed files with 194 additions and 87 deletions

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")