Merge pull request #245 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-04 18:55:28 -04:00
committed by GitHub
19 changed files with 889 additions and 37 deletions

View File

@@ -38,3 +38,37 @@ This project is written in **Python**. Follow these instructions when working wi
- Review code for potential information leaks (e.g., verbose logging) before submitting.
Following these practices helps keep the code base consistent and secure.
## Integrating New Entry Types
SeedPass supports multiple `kind` values in its JSON entry files. When adding a
new `kind` (for example, SSH keys or BIP39 seeds) use the checklist below:
1. **Menu Updates** Extend the CLI menus in `main.py` so "Add Entry" offers
choices for the new types and retrieval operations handle them properly. The
current main menu looks like this:
```
Select an option:
1. Add Entry
2. Retrieve Entry
3. Search Entries
4. Modify an Existing Entry
5. 2FA Codes
6. Settings
7. Exit
```
2. **JSON Schema** Each entry file must include a `kind` field describing the
entry type. Add new values (`ssh`, `seed`, etc.) as needed and implement
handlers so older kinds continue to work.
3. **Best Practices** When introducing a new `kind`, follow the modular
architecture guidelines from `docs/json_entries.md`:
- Use clear, descriptive names.
- Keep handler code for each `kind` separate.
- Validate required fields and gracefully handle missing data.
- Add regression tests to ensure backward compatibility.
This procedure keeps the UI consistent and ensures new data types integrate
smoothly with existing functionality.

View File

@@ -59,6 +59,30 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
## Installation
### Quick Installer
Use the automated installer to download SeedPass and its dependencies in one step.
**Linux and macOS:**
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
```
*Install the beta branch:*
```bash
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
```
**Windows (PowerShell):**
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent))
```
*Install the beta branch:*
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta
```
### Manual Setup
Follow these steps to set up SeedPass on your local machine.
### 1. Clone the Repository
@@ -205,7 +229,8 @@ python src/main.py
Enter your choice (1-7):
```
When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**.
When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**,
**SSH Key**, **Seed Phrase**, or **PGP Key**.
### Adding a 2FA Entry
@@ -287,7 +312,8 @@ Back in the Settings menu you can:
* Choose `10` to set an additional backup location.
* Select `11` to change the inactivity timeout.
* Choose `12` to lock the vault and require re-entry of your password.
* Select `13` to view seed profile stats.
* Select `13` to view seed profile stats. The summary lists counts for
passwords, TOTP codes, SSH keys, seed phrases, and PGP keys.
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
* Select `15` to return to the main menu.

View File

@@ -33,6 +33,7 @@ mutmut==2.4.4
nostr-sdk==0.42.1
packaging==25.0
parso==0.8.4
pgpy==0.6.0
pluggy==1.6.0
pony==0.7.19
portalocker==3.2.0

99
scripts/install.ps1 Normal file
View File

@@ -0,0 +1,99 @@
#
# SeedPass Universal Installer for Windows
#
# Supports installing from a specific branch using the -Branch parameter.
# Example: .\install.ps1 -Branch beta
param(
[string]$Branch = "main" # The git branch to install from
)
# --- Configuration ---
$RepoUrl = "https://github.com/PR0M3TH3AN/SeedPass.git"
$AppRootDir = Join-Path $env:USERPROFILE ".seedpass"
$InstallDir = Join-Path $AppRootDir "app"
$VenvDir = Join-Path $InstallDir "venv"
$LauncherDir = Join-Path $InstallDir "bin"
$LauncherName = "seedpass.cmd"
# --- Helper Functions ---
function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
function Write-Success { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green }
function Write-Warning { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow }
function Write-Error {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
# --- Main Script ---
# 1. Check for prerequisites
Write-Info "Installing SeedPass from branch: '$Branch'"
Write-Info "Checking for prerequisites..."
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error "Git is not installed. Please install it from https://git-scm.com/ and ensure it's in your PATH." }
$pythonExe = Get-Command python -ErrorAction SilentlyContinue
if (-not $pythonExe) { Write-Error "Python 3 is not installed or not in your PATH. Please install it from https://www.python.org/" }
# 2. Clone or update the repository
if (Test-Path (Join-Path $InstallDir ".git")) {
Write-Info "SeedPass directory found. Fetching updates and switching to '$Branch' branch..."
try {
Set-Location $InstallDir
git fetch origin
git checkout $Branch
git pull origin $Branch --ff-only
} catch { Write-Error "Failed to update repository. Error: $_" }
} else {
Write-Info "Cloning SeedPass '$Branch' branch..."
try {
if (-not(Test-Path $AppRootDir)) { New-Item -ItemType Directory -Path $AppRootDir | Out-Null }
git clone --branch $Branch $RepoUrl $InstallDir
Set-Location $InstallDir
} catch { Write-Error "Failed to clone repository. Error: $_" }
}
# 3. Set up Python virtual environment
Write-Info "Setting up Python virtual environment..."
if (-not (Test-Path $VenvDir)) {
try { python -m venv $VenvDir } catch { Write-Error "Failed to create virtual environment. Error: $_" }
}
# 4. Install/Update Python dependencies
Write-Info "Installing/updating Python dependencies..."
try {
& "$VenvDir\Scripts\pip.exe" install --upgrade pip
& "$VenvDir\Scripts\pip.exe" install -r "src\requirements.txt"
} catch {
Write-Warning "Failed to install Python dependencies. If errors mention C++, install Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/"
Write-Error "Dependency installation failed. Error: $_"
}
# 5. Create launcher script
Write-Info "Creating launcher script..."
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
$LauncherPath = Join-Path $LauncherDir $LauncherName
$LauncherContent = @"
@echo off
setlocal
call "%~dp0..\venv\Scripts\activate.bat"
python "%~dp0..\src\main.py" %*
endlocal
"@
Set-Content -Path $LauncherPath -Value $LauncherContent -Force
# 6. Add launcher directory to User's PATH if needed
Write-Info "Checking if '$LauncherDir' is in your PATH..."
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
if (($UserPath -split ';') -notcontains $LauncherDir) {
Write-Info "Adding '$LauncherDir' to your user PATH."
$NewPath = "$LauncherDir;$UserPath".Trim(";")
[System.Environment]::SetEnvironmentVariable("Path", $NewPath, "User")
Write-Warning "PATH has been updated. You MUST open a new terminal for the 'seedpass' command to be available."
} else {
Write-Info "'$LauncherDir' is already in your user PATH."
}
Write-Success "Installation/update complete!"
Write-Info "To run the application, please open a NEW terminal window and type: seedpass"

129
scripts/install.sh Executable file
View File

@@ -0,0 +1,129 @@
#!/bin/bash
#
# SeedPass Universal Installer for Linux and macOS
#
# Supports installing from a specific branch using the -b or --branch flag.
# Example: ./install.sh -b beta
set -e
# --- Configuration ---
REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git"
APP_ROOT_DIR="$HOME/.seedpass"
INSTALL_DIR="$APP_ROOT_DIR/app"
VENV_DIR="$INSTALL_DIR/venv"
LAUNCHER_DIR="$HOME/.local/bin"
LAUNCHER_PATH="$LAUNCHER_DIR/seedpass"
BRANCH="main" # Default branch
# --- Helper Functions ---
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)"
echo " -h, --help Display this help message"
exit 0
}
# --- Main Script ---
main() {
# Parse command-line arguments
while [[ "$#" -gt 0 ]]; do
case "$1" in
-b|--branch)
if [ -n "$2" ]; then
BRANCH="$2"
shift 2
else
print_error "Error: --branch requires a non-empty option argument."
fi
;;
-h|--help)
usage
;;
*)
print_error "Unknown parameter passed: $1"; usage
;;
esac
done
# 1. Detect OS
OS_NAME=$(uname -s)
print_info "Installing SeedPass from branch: '$BRANCH'"
print_info "Detected Operating System: $OS_NAME"
# 2. Check for prerequisites
print_info "Checking for prerequisites (git, python3, pip)..."
if ! command -v git &> /dev/null; then print_error "Git is not installed. Please install it."; fi
if ! command -v python3 &> /dev/null; then print_error "Python 3 is not installed. Please install it."; fi
if ! python3 -m ensurepip --default-pip &> /dev/null && ! command -v pip3 &> /dev/null; then print_error "pip for Python 3 is not available. Please install it."; fi
if ! python3 -c "import venv" &> /dev/null; then
print_warning "Python 'venv' module not found. Attempting to install..."
if [ "$OS_NAME" = "Linux" ]; then
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y python3-venv;
elif command -v dnf &> /dev/null; then sudo dnf install -y python3-virtualenv;
else print_error "Could not auto-install python3-venv. Please install it for your distribution."; fi
else print_error "Python 'venv' module is missing."; fi
fi
# 3. Install OS-specific dependencies
print_info "Checking for build dependencies..."
if [ "$OS_NAME" = "Linux" ]; then
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip;
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi
elif [ "$OS_NAME" = "Darwin" ]; then
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
brew install pkg-config
fi
# 4. Clone or update the repository
if [ -d "$INSTALL_DIR/.git" ]; then
print_info "SeedPass directory found. Fetching updates and switching to '$BRANCH' branch..."
cd "$INSTALL_DIR"
git fetch origin
git checkout "$BRANCH"
git pull origin "$BRANCH" --ff-only
else
print_info "Cloning SeedPass '$BRANCH' branch to '$INSTALL_DIR'..."
mkdir -p "$APP_ROOT_DIR"
git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# 5. Set up Python virtual environment
print_info "Setting up Python virtual environment in '$VENV_DIR'..."
if [ ! -d "$VENV_DIR" ]; then python3 -m venv "$VENV_DIR"; fi
# shellcheck source=/dev/null
source "$VENV_DIR/bin/activate"
# 6. Install/Update Python dependencies
print_info "Installing/updating Python dependencies from src/requirements.txt..."
pip install --upgrade pip
pip install -r src/requirements.txt
deactivate
# 7. Create launcher script
print_info "Creating launcher script at '$LAUNCHER_PATH'..."
mkdir -p "$LAUNCHER_DIR"
cat > "$LAUNCHER_PATH" << EOF2
#!/bin/bash
source "$VENV_DIR/bin/activate"
exec python3 "$INSTALL_DIR/src/main.py" "\$@"
EOF2
chmod +x "$LAUNCHER_PATH"
# 8. Final instructions
print_success "Installation/update complete!"
print_info "You can now run the application by typing: seedpass"
if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then
print_warning "Directory '$LAUNCHER_DIR' is not in your PATH."
print_warning "Please add 'export PATH=\"$HOME/.local/bin:$PATH\"' to your shell's config file (e.g., ~/.bashrc, ~/.zshrc) and restart your terminal."
fi
}
main "$@"

View File

@@ -729,7 +729,10 @@ def display_menu(
print("\nAdd Entry:")
print("1. Password")
print("2. 2FA (TOTP)")
print("3. Back")
print("3. SSH Key")
print("4. Seed Phrase")
print("5. PGP Key")
print("6. Back")
sub_choice = input("Select entry type: ").strip()
password_manager.update_activity()
if sub_choice == "1":
@@ -739,6 +742,15 @@ def display_menu(
password_manager.handle_add_totp()
break
elif sub_choice == "3":
password_manager.handle_add_ssh_key()
break
elif sub_choice == "4":
password_manager.handle_add_seed()
break
elif sub_choice == "5":
password_manager.handle_add_pgp()
break
elif sub_choice == "6":
break
else:
print(colored("Invalid choice.", "red"))

View File

@@ -58,9 +58,13 @@ class EntryManager:
if self.index_file.exists():
try:
data = self.vault.load_index()
# Ensure legacy entries without a type are treated as passwords
# Normalize legacy fields
for entry in data.get("entries", {}).values():
entry.setdefault("type", EntryType.PASSWORD.value)
if "type" not in entry and "kind" in entry:
entry["type"] = entry["kind"]
if "kind" not in entry:
entry["kind"] = entry.get("type", EntryType.PASSWORD.value)
entry.setdefault("type", entry["kind"])
logger.debug("Index loaded successfully.")
return data
except Exception as e:
@@ -132,6 +136,7 @@ class EntryManager:
"url": url if url else "",
"blacklisted": blacklisted,
"type": EntryType.PASSWORD.value,
"kind": EntryType.PASSWORD.value,
"notes": notes,
}
@@ -158,7 +163,10 @@ class EntryManager:
indices = [
int(v.get("index", 0))
for v in entries.values()
if v.get("type") == EntryType.TOTP.value
if (
v.get("type") == EntryType.TOTP.value
or v.get("kind") == EntryType.TOTP.value
)
]
return (max(indices) + 1) if indices else 0
@@ -183,6 +191,7 @@ class EntryManager:
secret = TotpManager.derive_secret(parent_seed, index)
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label,
"index": index,
"period": period,
@@ -191,6 +200,7 @@ class EntryManager:
else:
entry = {
"type": EntryType.TOTP.value,
"kind": EntryType.TOTP.value,
"label": label,
"secret": secret,
"period": period,
@@ -209,34 +219,153 @@ class EntryManager:
logger.error(f"Failed to generate otpauth URI: {e}")
raise
def add_ssh_key(self, notes: str = "") -> int:
"""Placeholder for adding an SSH key entry."""
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
raise NotImplementedError("SSH key entry support not implemented yet")
def add_ssh_key(
self, parent_seed: str, index: int | None = None, notes: str = ""
) -> int:
"""Add a new SSH key pair entry.
The provided ``index`` serves both as the vault entry identifier and
derivation index for the key. If not supplied, the next available index
is used. Only metadata is stored keys are derived on demand.
"""
if index is None:
index = self.get_next_index()
def add_seed(self, notes: str = "") -> int:
"""Placeholder for adding a seed entry."""
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes}
data["entries"][str(index)] = {
"type": EntryType.SSH.value,
"kind": EntryType.SSH.value,
"index": index,
"notes": notes,
}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
raise NotImplementedError("Seed entry support not implemented yet")
return index
def get_ssh_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]:
"""Return the PEM formatted SSH key pair for the given entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value):
raise ValueError("Entry is not an SSH key entry")
from password_manager.password_generation import derive_ssh_key_pair
key_index = int(entry.get("index", index))
return derive_ssh_key_pair(parent_seed, key_index)
def add_pgp_key(
self,
parent_seed: str,
index: int | None = None,
key_type: str = "ed25519",
user_id: str = "",
notes: str = "",
) -> int:
"""Add a new PGP key entry."""
if index is None:
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.PGP.value,
"kind": EntryType.PGP.value,
"index": index,
"key_type": key_type,
"user_id": user_id,
"notes": notes,
}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
return index
def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str]:
"""Return the armored PGP private key and fingerprint for the entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value):
raise ValueError("Entry is not a PGP key entry")
from password_manager.password_generation import derive_pgp_key
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
key_idx = int(entry.get("index", index))
key_type = entry.get("key_type", "ed25519")
user_id = entry.get("user_id", "")
return derive_pgp_key(bip85, key_idx, key_type, user_id)
def add_seed(
self,
parent_seed: str,
index: int | None = None,
words_num: int = 24,
notes: str = "",
) -> int:
"""Add a new derived seed phrase entry."""
if index is None:
index = self.get_next_index()
data = self.vault.load_index()
data.setdefault("entries", {})
data["entries"][str(index)] = {
"type": EntryType.SEED.value,
"kind": EntryType.SEED.value,
"index": index,
"words": words_num,
"notes": notes,
}
self._save_index(data)
self.update_checksum()
self.backup_manager.create_backup()
return index
def get_seed_phrase(self, index: int, parent_seed: str) -> str:
"""Return the mnemonic seed phrase for the given entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (
etype != EntryType.SEED.value and kind != EntryType.SEED.value
):
raise ValueError("Entry is not a seed entry")
from password_manager.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
words = int(entry.get("words", 24))
seed_index = int(entry.get("index", index))
return derive_seed_phrase(bip85, seed_index, words)
def get_totp_code(
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
) -> str:
"""Return the current TOTP code for the specified entry."""
entry = self.retrieve_entry(index)
if not entry or entry.get("type") != EntryType.TOTP.value:
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
):
raise ValueError("Entry is not a TOTP entry")
if "secret" in entry:
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
@@ -248,7 +377,11 @@ class EntryManager:
def get_totp_time_remaining(self, index: int) -> int:
"""Return seconds remaining in the TOTP period for the given entry."""
entry = self.retrieve_entry(index)
if not entry or entry.get("type") != EntryType.TOTP.value:
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
if not entry or (
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
):
raise ValueError("Entry is not a TOTP entry")
period = int(entry.get("period", 30))
@@ -337,7 +470,7 @@ class EntryManager:
)
return
entry_type = entry.get("type", EntryType.PASSWORD.value)
entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if entry_type == EntryType.TOTP.value:
if label is not None:
@@ -414,14 +547,15 @@ class EntryManager:
for idx_str, entry in sorted_items:
if (
filter_kind is not None
and entry.get("type", EntryType.PASSWORD.value) != filter_kind
and entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
!= filter_kind
):
continue
filtered_items.append((int(idx_str), entry))
entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
for idx, entry in filtered_items:
etype = entry.get("type", EntryType.PASSWORD.value)
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if etype == EntryType.TOTP.value:
entries.append((idx, entry.get("label", ""), None, None, False))
else:
@@ -437,7 +571,7 @@ class EntryManager:
logger.debug(f"Total entries found: {len(entries)}")
for idx, entry in filtered_items:
etype = entry.get("type", EntryType.PASSWORD.value)
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
print(colored(f"Index: {idx}", "cyan"))
if etype == EntryType.TOTP.value:
print(colored(" Type: TOTP", "cyan"))
@@ -484,7 +618,7 @@ class EntryManager:
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
etype = entry.get("type", EntryType.PASSWORD.value)
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if etype == EntryType.TOTP.value:
label = entry.get("label", "")
notes = entry.get("notes", "")

View File

@@ -11,3 +11,4 @@ class EntryType(str, Enum):
TOTP = "totp"
SSH = "ssh"
SEED = "seed"
PGP = "pgp"

View File

@@ -1021,6 +1021,95 @@ class PasswordManager:
logging.error(f"Error during TOTP setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
def handle_add_ssh_key(self) -> None:
"""Add an SSH key pair entry and display the derived keys."""
try:
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_ssh_key(self.parent_seed, notes=notes)
priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair(
index, self.parent_seed
)
self.is_dirty = True
self.last_update = time.time()
print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green"))
print(colored("Public Key:", "cyan"))
print(pub_pem)
print(colored("Private Key:", "cyan"))
print(priv_pem)
try:
self.sync_vault()
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during SSH key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add SSH key: {e}", "red"))
def handle_add_seed(self) -> None:
"""Add a derived BIP-39 seed phrase entry."""
try:
words_input = input("Word count (12 or 24, default 24): ").strip()
notes = input("Notes (optional): ").strip()
if words_input and words_input not in {"12", "24"}:
print(colored("Invalid word count. Choose 12 or 24.", "red"))
return
words = int(words_input) if words_input else 24
index = self.entry_manager.add_seed(
self.parent_seed, words_num=words, notes=notes
)
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green"))
print(colored("Seed Phrase:", "cyan"))
print(colored(phrase, "yellow"))
try:
self.sync_vault()
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during seed phrase setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add seed phrase: {e}", "red"))
def handle_add_pgp(self) -> None:
"""Add a PGP key entry and display the generated key."""
try:
key_type = (
input("Key type (ed25519 or rsa, default ed25519): ").strip().lower()
or "ed25519"
)
user_id = input("User ID (optional): ").strip()
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_pgp_key(
self.parent_seed,
key_type=key_type,
user_id=user_id,
notes=notes,
)
priv_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
self.is_dirty = True
self.last_update = time.time()
print(colored(f"\n[+] PGP key entry added with ID {index}.\n", "green"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key)
try:
self.sync_vault()
except Exception as nostr_error: # pragma: no cover - best effort
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
except Exception as e:
logging.error(f"Error during PGP key setup: {e}", exc_info=True)
print(colored(f"Error: Failed to add PGP key: {e}", "red"))
def handle_retrieve_entry(self) -> None:
"""
Handles retrieving a password from the index by prompting the user for the index number
@@ -1028,7 +1117,7 @@ class PasswordManager:
"""
try:
index_input = input(
"Enter the index number of the password to retrieve: "
"Enter the index number of the entry to retrieve: "
).strip()
if not index_input.isdigit():
print(colored("Error: Index must be a number.", "red"))
@@ -1093,6 +1182,94 @@ class PasswordManager:
logging.error(f"Error generating TOTP code: {e}", exc_info=True)
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
return
if entry_type == EntryType.SSH.value:
notes = entry.get("notes", "")
try:
priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair(
index, self.parent_seed
)
if self.secret_mode_enabled:
copy_to_clipboard(priv_pem, self.clipboard_clear_delay)
print(
colored(
f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
print(colored("Public Key:", "cyan"))
print(pub_pem)
else:
print(colored("\n[+] Retrieved SSH Key Pair:\n", "green"))
print(colored("Public Key:", "cyan"))
print(pub_pem)
print(colored("Private Key:", "cyan"))
print(priv_pem)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving SSH key pair: {e}", exc_info=True)
print(colored(f"Error: Failed to derive SSH keys: {e}", "red"))
return
if entry_type == EntryType.SEED.value:
notes = entry.get("notes", "")
try:
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
if self.secret_mode_enabled:
copy_to_clipboard(phrase, self.clipboard_clear_delay)
print(
colored(
f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored("\n[+] Retrieved Seed Phrase:\n", "green"))
print(colored(phrase, "yellow"))
if confirm_action("Show derived entropy as hex? (Y/N): "):
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
words = int(entry.get("words", 24))
bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32)
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy(
index=int(entry.get("index", index)),
bytes_len=bytes_len,
app_no=39,
words_len=words,
)
print(colored(f"Entropy: {entropy.hex()}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to derive seed phrase: {e}", "red"))
return
if entry_type == EntryType.PGP.value:
notes = entry.get("notes", "")
try:
priv_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
)
if self.secret_mode_enabled:
copy_to_clipboard(priv_key, self.clipboard_clear_delay)
print(
colored(
f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored("\n[+] Retrieved PGP Key:\n", "green"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
print(priv_key)
if notes:
print(colored(f"Notes: {notes}", "cyan"))
except Exception as e:
logging.error(f"Error deriving PGP key: {e}", exc_info=True)
print(colored(f"Error: Failed to derive PGP key: {e}", "red"))
return
website_name = entry.get("website")
length = entry.get("length")
@@ -1948,7 +2125,7 @@ class PasswordManager:
# Entry counts by type
data = self.entry_manager.vault.load_index()
entries = data.get("entries", {})
counts: dict[str, int] = {}
counts: dict[str, int] = {etype.value: 0 for etype in EntryType}
for entry in entries.values():
etype = entry.get("type", EntryType.PASSWORD.value)
counts[etype] = counts.get(etype, 0) + 1

View File

@@ -25,8 +25,10 @@ from termcolor import colored
from pathlib import Path
import shutil
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.backends import default_backend
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
@@ -340,6 +342,123 @@ def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]:
"""Derive an Ed25519 SSH key pair from the seed phrase and index."""
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes)
entropy = derive_ssh_key(bip85, index)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(entropy)
priv_pem = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
public_key = private_key.public_key()
pub_pem = public_key.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv_pem, pub_pem
def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
"""Derive a new BIP39 seed phrase using BIP85."""
return bip85.derive_mnemonic(index=idx, words_num=words)
def derive_pgp_key(
bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = ""
) -> tuple[str, str]:
"""Derive a deterministic PGP private key and return it with its fingerprint."""
from pgpy import PGPKey, PGPUID
from pgpy.packet.packets import PrivKeyV4
from pgpy.packet.fields import (
EdDSAPriv,
RSAPriv,
ECPoint,
ECPointFormat,
EllipticCurveOID,
MPI,
)
from pgpy.constants import (
PubKeyAlgorithm,
KeyFlags,
HashAlgorithm,
SymmetricKeyAlgorithm,
CompressionAlgorithm,
)
from Crypto.PublicKey import RSA
from Crypto.Util.number import inverse
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
import hashlib
import datetime
entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32)
created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
if key_type.lower() == "rsa":
class DRNG:
def __init__(self, seed: bytes) -> None:
self.seed = seed
def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic
out = b""
while len(out) < n:
self.seed = hashlib.sha256(self.seed).digest()
out += self.seed
return out[:n]
rsa_key = RSA.generate(2048, randfunc=DRNG(entropy))
keymat = RSAPriv()
keymat.n = MPI(rsa_key.n)
keymat.e = MPI(rsa_key.e)
keymat.d = MPI(rsa_key.d)
keymat.p = MPI(rsa_key.p)
keymat.q = MPI(rsa_key.q)
keymat.u = MPI(inverse(keymat.p, keymat.q))
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.RSAEncryptOrSign
pkt.keymaterial = keymat
else:
priv = ed25519.Ed25519PrivateKey.from_private_bytes(entropy)
public = priv.public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
)
keymat = EdDSAPriv()
keymat.oid = EllipticCurveOID.Ed25519
keymat.s = MPI(int.from_bytes(entropy, "big"))
keymat.p = ECPoint.from_values(
keymat.oid.key_size, ECPointFormat.Native, public
)
keymat._compute_chksum()
pkt = PrivKeyV4()
pkt.pkalg = PubKeyAlgorithm.EdDSA
pkt.keymaterial = keymat
pkt.created = created
pkt.update_hlen()
key = PGPKey()
key._key = pkt
uid = PGPUID.new(user_id)
key.add_uid(
uid,
usage=[
KeyFlags.Sign,
KeyFlags.EncryptCommunications,
KeyFlags.EncryptStorage,
],
hashes=[HashAlgorithm.SHA256],
ciphers=[SymmetricKeyAlgorithm.AES256],
compression=[CompressionAlgorithm.ZLIB],
)
return str(key), key.fingerprint

View File

@@ -18,6 +18,7 @@ websockets>=15.0.0
tomli
hypothesis
mutmut==2.4.4
pgpy==0.6.0
pyotp>=2.8.0
freezegun

View File

@@ -24,7 +24,13 @@ def test_backup_restore_workflow(monkeypatch):
data1 = {
"schema_version": 2,
"entries": {
"0": {"website": "a", "length": 10, "type": "password", "notes": ""}
"0": {
"website": "a",
"length": 10,
"type": "password",
"kind": "password",
"notes": "",
}
},
}
vault.save_index(data1)
@@ -39,7 +45,13 @@ def test_backup_restore_workflow(monkeypatch):
data2 = {
"schema_version": 2,
"entries": {
"0": {"website": "b", "length": 12, "type": "password", "notes": ""}
"0": {
"website": "b",
"length": 12,
"type": "password",
"kind": "password",
"notes": "",
}
},
}
vault.save_index(data2)

View File

@@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
def test_invalid_add_entry_submenu(monkeypatch, capsys):
called = {"add": False, "retrieve": False, "modify": False}
pm, _ = _make_pm(called)
inputs = iter(["1", "4", "3", "7"])
inputs = iter(["1", "7", "6", "7"])
monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs))
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
with pytest.raises(SystemExit):

View File

@@ -31,6 +31,7 @@ def test_add_and_retrieve_entry():
"url": "",
"blacklisted": False,
"type": "password",
"kind": "password",
"notes": "",
}
@@ -61,14 +62,19 @@ def test_round_trip_entry_types(method, expected_type):
entry_mgr.add_totp("example", TEST_SEED)
index = 0
else:
with pytest.raises(NotImplementedError):
getattr(entry_mgr, method)()
index = 0
if method == "add_ssh_key":
index = entry_mgr.add_ssh_key(TEST_SEED)
elif method == "add_seed":
index = entry_mgr.add_seed(TEST_SEED)
else:
index = getattr(entry_mgr, method)()
entry = entry_mgr.retrieve_entry(index)
assert entry["type"] == expected_type
assert entry["kind"] == expected_type
data = enc_mgr.load_json_data(entry_mgr.index_file)
assert data["entries"][str(index)]["type"] == expected_type
assert data["entries"][str(index)]["kind"] == expected_type
def test_legacy_entry_defaults_to_password():

View File

@@ -58,6 +58,7 @@ def test_handle_add_totp(monkeypatch, capsys):
entry = entry_mgr.retrieve_entry(0)
assert entry == {
"type": "totp",
"kind": "totp",
"label": "Example",
"index": 0,
"period": 30,

View File

@@ -0,0 +1,27 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
def test_pgp_key_determinism():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx = entry_mgr.add_pgp_key(TEST_SEED, key_type="ed25519", user_id="Test")
key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED)
key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED)
assert fp1 == fp2
assert key1 == key2

View File

@@ -0,0 +1,41 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
from password_manager.password_generation import derive_seed_phrase
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
def test_seed_phrase_determinism():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
idx_12 = entry_mgr.add_seed(TEST_SEED, words_num=12)
idx_24 = entry_mgr.add_seed(TEST_SEED, words_num=24)
phrase12_a = entry_mgr.get_seed_phrase(idx_12, TEST_SEED)
phrase12_b = entry_mgr.get_seed_phrase(idx_12, TEST_SEED)
phrase24_a = entry_mgr.get_seed_phrase(idx_24, TEST_SEED)
phrase24_b = entry_mgr.get_seed_phrase(idx_24, TEST_SEED)
seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate()
bip85 = BIP85(seed_bytes)
expected12 = derive_seed_phrase(bip85, idx_12, 12)
expected24 = derive_seed_phrase(bip85, idx_24, 24)
assert phrase12_a == phrase12_b == expected12
assert phrase24_a == phrase24_b == expected24
assert len(phrase12_a.split()) == 12
assert len(phrase24_a.split()) == 24

View File

@@ -0,0 +1,30 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.vault import Vault
from password_manager.config_manager import ConfigManager
def test_add_and_retrieve_ssh_key_pair():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
index = entry_mgr.add_ssh_key(TEST_SEED)
entry = entry_mgr.retrieve_entry(index)
assert entry == {"type": "ssh", "kind": "ssh", "index": index, "notes": ""}
priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)
assert priv1 == priv2
assert pub1 == pub2

View File

@@ -30,6 +30,7 @@ def test_add_totp_and_get_code():
entry = entry_mgr.retrieve_entry(0)
assert entry == {
"type": "totp",
"kind": "totp",
"label": "Example",
"index": 0,
"period": 30,
@@ -66,6 +67,7 @@ def test_add_totp_imported(tmp_path):
entry = em.retrieve_entry(0)
assert entry == {
"type": "totp",
"kind": "totp",
"label": "Imported",
"secret": secret,
"period": 30,