mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 14:58:56 +00:00
Merge pull request #8 from PR0M3TH3AN/codex/extend-configmanager-to-store-bcrypt-hashed-pin
Add PIN protected settings
This commit is contained in:
@@ -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.
|
||||
|
221
src/main.py
221
src/main.py
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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")
|
||||
|
Reference in New Issue
Block a user