mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
34
AGENTS.md
34
AGENTS.md
@@ -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.
|
- Review code for potential information leaks (e.g., verbose logging) before submitting.
|
||||||
|
|
||||||
Following these practices helps keep the code base consistent and secure.
|
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 BIP‑39 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.
|
||||||
|
30
README.md
30
README.md
@@ -59,6 +59,30 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
|||||||
|
|
||||||
## Installation
|
## 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.
|
Follow these steps to set up SeedPass on your local machine.
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
@@ -205,7 +229,8 @@ python src/main.py
|
|||||||
Enter your choice (1-7):
|
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
|
### Adding a 2FA Entry
|
||||||
|
|
||||||
@@ -287,7 +312,8 @@ Back in the Settings menu you can:
|
|||||||
* Choose `10` to set an additional backup location.
|
* Choose `10` to set an additional backup location.
|
||||||
* Select `11` to change the inactivity timeout.
|
* Select `11` to change the inactivity timeout.
|
||||||
* Choose `12` to lock the vault and require re-entry of your password.
|
* 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.
|
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
|
||||||
* Select `15` to return to the main menu.
|
* Select `15` to return to the main menu.
|
||||||
|
|
||||||
|
@@ -33,6 +33,7 @@ mutmut==2.4.4
|
|||||||
nostr-sdk==0.42.1
|
nostr-sdk==0.42.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
parso==0.8.4
|
parso==0.8.4
|
||||||
|
pgpy==0.6.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
pony==0.7.19
|
pony==0.7.19
|
||||||
portalocker==3.2.0
|
portalocker==3.2.0
|
||||||
|
99
scripts/install.ps1
Normal file
99
scripts/install.ps1
Normal 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
129
scripts/install.sh
Executable 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 "$@"
|
14
src/main.py
14
src/main.py
@@ -729,7 +729,10 @@ def display_menu(
|
|||||||
print("\nAdd Entry:")
|
print("\nAdd Entry:")
|
||||||
print("1. Password")
|
print("1. Password")
|
||||||
print("2. 2FA (TOTP)")
|
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()
|
sub_choice = input("Select entry type: ").strip()
|
||||||
password_manager.update_activity()
|
password_manager.update_activity()
|
||||||
if sub_choice == "1":
|
if sub_choice == "1":
|
||||||
@@ -739,6 +742,15 @@ def display_menu(
|
|||||||
password_manager.handle_add_totp()
|
password_manager.handle_add_totp()
|
||||||
break
|
break
|
||||||
elif sub_choice == "3":
|
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
|
break
|
||||||
else:
|
else:
|
||||||
print(colored("Invalid choice.", "red"))
|
print(colored("Invalid choice.", "red"))
|
||||||
|
@@ -58,9 +58,13 @@ class EntryManager:
|
|||||||
if self.index_file.exists():
|
if self.index_file.exists():
|
||||||
try:
|
try:
|
||||||
data = self.vault.load_index()
|
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():
|
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.")
|
logger.debug("Index loaded successfully.")
|
||||||
return data
|
return data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -132,6 +136,7 @@ class EntryManager:
|
|||||||
"url": url if url else "",
|
"url": url if url else "",
|
||||||
"blacklisted": blacklisted,
|
"blacklisted": blacklisted,
|
||||||
"type": EntryType.PASSWORD.value,
|
"type": EntryType.PASSWORD.value,
|
||||||
|
"kind": EntryType.PASSWORD.value,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +163,10 @@ class EntryManager:
|
|||||||
indices = [
|
indices = [
|
||||||
int(v.get("index", 0))
|
int(v.get("index", 0))
|
||||||
for v in entries.values()
|
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
|
return (max(indices) + 1) if indices else 0
|
||||||
|
|
||||||
@@ -183,6 +191,7 @@ class EntryManager:
|
|||||||
secret = TotpManager.derive_secret(parent_seed, index)
|
secret = TotpManager.derive_secret(parent_seed, index)
|
||||||
entry = {
|
entry = {
|
||||||
"type": EntryType.TOTP.value,
|
"type": EntryType.TOTP.value,
|
||||||
|
"kind": EntryType.TOTP.value,
|
||||||
"label": label,
|
"label": label,
|
||||||
"index": index,
|
"index": index,
|
||||||
"period": period,
|
"period": period,
|
||||||
@@ -191,6 +200,7 @@ class EntryManager:
|
|||||||
else:
|
else:
|
||||||
entry = {
|
entry = {
|
||||||
"type": EntryType.TOTP.value,
|
"type": EntryType.TOTP.value,
|
||||||
|
"kind": EntryType.TOTP.value,
|
||||||
"label": label,
|
"label": label,
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
"period": period,
|
"period": period,
|
||||||
@@ -209,34 +219,153 @@ class EntryManager:
|
|||||||
logger.error(f"Failed to generate otpauth URI: {e}")
|
logger.error(f"Failed to generate otpauth URI: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def add_ssh_key(self, notes: str = "") -> int:
|
def add_ssh_key(
|
||||||
"""Placeholder for adding an SSH key entry."""
|
self, parent_seed: str, index: int | None = None, notes: str = ""
|
||||||
index = self.get_next_index()
|
) -> int:
|
||||||
data = self.vault.load_index()
|
"""Add a new SSH key pair entry.
|
||||||
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_seed(self, notes: str = "") -> int:
|
The provided ``index`` serves both as the vault entry identifier and
|
||||||
"""Placeholder for adding a seed entry."""
|
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()
|
index = self.get_next_index()
|
||||||
|
|
||||||
data = self.vault.load_index()
|
data = self.vault.load_index()
|
||||||
data.setdefault("entries", {})
|
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._save_index(data)
|
||||||
self.update_checksum()
|
self.update_checksum()
|
||||||
self.backup_manager.create_backup()
|
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(
|
def get_totp_code(
|
||||||
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
|
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return the current TOTP code for the specified entry."""
|
"""Return the current TOTP code for the specified entry."""
|
||||||
entry = self.retrieve_entry(index)
|
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")
|
raise ValueError("Entry is not a TOTP entry")
|
||||||
if "secret" in entry:
|
if "secret" in entry:
|
||||||
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
|
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
|
||||||
@@ -248,7 +377,11 @@ class EntryManager:
|
|||||||
def get_totp_time_remaining(self, index: int) -> int:
|
def get_totp_time_remaining(self, index: int) -> int:
|
||||||
"""Return seconds remaining in the TOTP period for the given entry."""
|
"""Return seconds remaining in the TOTP period for the given entry."""
|
||||||
entry = self.retrieve_entry(index)
|
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")
|
raise ValueError("Entry is not a TOTP entry")
|
||||||
|
|
||||||
period = int(entry.get("period", 30))
|
period = int(entry.get("period", 30))
|
||||||
@@ -337,7 +470,7 @@ class EntryManager:
|
|||||||
)
|
)
|
||||||
return
|
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 entry_type == EntryType.TOTP.value:
|
||||||
if label is not None:
|
if label is not None:
|
||||||
@@ -414,14 +547,15 @@ class EntryManager:
|
|||||||
for idx_str, entry in sorted_items:
|
for idx_str, entry in sorted_items:
|
||||||
if (
|
if (
|
||||||
filter_kind is not None
|
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
|
continue
|
||||||
filtered_items.append((int(idx_str), entry))
|
filtered_items.append((int(idx_str), entry))
|
||||||
|
|
||||||
entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
|
entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
|
||||||
for idx, entry in filtered_items:
|
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:
|
if etype == EntryType.TOTP.value:
|
||||||
entries.append((idx, entry.get("label", ""), None, None, False))
|
entries.append((idx, entry.get("label", ""), None, None, False))
|
||||||
else:
|
else:
|
||||||
@@ -437,7 +571,7 @@ class EntryManager:
|
|||||||
|
|
||||||
logger.debug(f"Total entries found: {len(entries)}")
|
logger.debug(f"Total entries found: {len(entries)}")
|
||||||
for idx, entry in filtered_items:
|
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"))
|
print(colored(f"Index: {idx}", "cyan"))
|
||||||
if etype == EntryType.TOTP.value:
|
if etype == EntryType.TOTP.value:
|
||||||
print(colored(" Type: TOTP", "cyan"))
|
print(colored(" Type: TOTP", "cyan"))
|
||||||
@@ -484,7 +618,7 @@ class EntryManager:
|
|||||||
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
|
results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = []
|
||||||
|
|
||||||
for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
|
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:
|
if etype == EntryType.TOTP.value:
|
||||||
label = entry.get("label", "")
|
label = entry.get("label", "")
|
||||||
notes = entry.get("notes", "")
|
notes = entry.get("notes", "")
|
||||||
|
@@ -11,3 +11,4 @@ class EntryType(str, Enum):
|
|||||||
TOTP = "totp"
|
TOTP = "totp"
|
||||||
SSH = "ssh"
|
SSH = "ssh"
|
||||||
SEED = "seed"
|
SEED = "seed"
|
||||||
|
PGP = "pgp"
|
||||||
|
@@ -1021,6 +1021,95 @@ class PasswordManager:
|
|||||||
logging.error(f"Error during TOTP setup: {e}", exc_info=True)
|
logging.error(f"Error during TOTP setup: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to add TOTP: {e}", "red"))
|
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:
|
def handle_retrieve_entry(self) -> None:
|
||||||
"""
|
"""
|
||||||
Handles retrieving a password from the index by prompting the user for the index number
|
Handles retrieving a password from the index by prompting the user for the index number
|
||||||
@@ -1028,7 +1117,7 @@ class PasswordManager:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
index_input = input(
|
index_input = input(
|
||||||
"Enter the index number of the password to retrieve: "
|
"Enter the index number of the entry to retrieve: "
|
||||||
).strip()
|
).strip()
|
||||||
if not index_input.isdigit():
|
if not index_input.isdigit():
|
||||||
print(colored("Error: Index must be a number.", "red"))
|
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)
|
logging.error(f"Error generating TOTP code: {e}", exc_info=True)
|
||||||
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
|
print(colored(f"Error: Failed to generate TOTP code: {e}", "red"))
|
||||||
return
|
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")
|
website_name = entry.get("website")
|
||||||
length = entry.get("length")
|
length = entry.get("length")
|
||||||
@@ -1948,7 +2125,7 @@ class PasswordManager:
|
|||||||
# Entry counts by type
|
# Entry counts by type
|
||||||
data = self.entry_manager.vault.load_index()
|
data = self.entry_manager.vault.load_index()
|
||||||
entries = data.get("entries", {})
|
entries = data.get("entries", {})
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {etype.value: 0 for etype in EntryType}
|
||||||
for entry in entries.values():
|
for entry in entries.values():
|
||||||
etype = entry.get("type", EntryType.PASSWORD.value)
|
etype = entry.get("type", EntryType.PASSWORD.value)
|
||||||
counts[etype] = counts.get(etype, 0) + 1
|
counts[etype] = counts.get(etype, 0) + 1
|
||||||
|
@@ -25,8 +25,10 @@ from termcolor import colored
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
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 cryptography.hazmat.backends import default_backend
|
||||||
|
from bip_utils import Bip39SeedGenerator
|
||||||
|
|
||||||
from local_bip85.bip85 import BIP85
|
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)
|
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:
|
def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
|
||||||
"""Derive a new BIP39 seed phrase using BIP85."""
|
"""Derive a new BIP39 seed phrase using BIP85."""
|
||||||
return bip85.derive_mnemonic(index=idx, words_num=words)
|
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
|
||||||
|
@@ -18,6 +18,7 @@ websockets>=15.0.0
|
|||||||
tomli
|
tomli
|
||||||
hypothesis
|
hypothesis
|
||||||
mutmut==2.4.4
|
mutmut==2.4.4
|
||||||
|
pgpy==0.6.0
|
||||||
pyotp>=2.8.0
|
pyotp>=2.8.0
|
||||||
|
|
||||||
freezegun
|
freezegun
|
||||||
|
@@ -24,7 +24,13 @@ def test_backup_restore_workflow(monkeypatch):
|
|||||||
data1 = {
|
data1 = {
|
||||||
"schema_version": 2,
|
"schema_version": 2,
|
||||||
"entries": {
|
"entries": {
|
||||||
"0": {"website": "a", "length": 10, "type": "password", "notes": ""}
|
"0": {
|
||||||
|
"website": "a",
|
||||||
|
"length": 10,
|
||||||
|
"type": "password",
|
||||||
|
"kind": "password",
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
vault.save_index(data1)
|
vault.save_index(data1)
|
||||||
@@ -39,7 +45,13 @@ def test_backup_restore_workflow(monkeypatch):
|
|||||||
data2 = {
|
data2 = {
|
||||||
"schema_version": 2,
|
"schema_version": 2,
|
||||||
"entries": {
|
"entries": {
|
||||||
"0": {"website": "b", "length": 12, "type": "password", "notes": ""}
|
"0": {
|
||||||
|
"website": "b",
|
||||||
|
"length": 12,
|
||||||
|
"type": "password",
|
||||||
|
"kind": "password",
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
vault.save_index(data2)
|
vault.save_index(data2)
|
||||||
|
@@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys):
|
|||||||
def test_invalid_add_entry_submenu(monkeypatch, capsys):
|
def test_invalid_add_entry_submenu(monkeypatch, capsys):
|
||||||
called = {"add": False, "retrieve": False, "modify": False}
|
called = {"add": False, "retrieve": False, "modify": False}
|
||||||
pm, _ = _make_pm(called)
|
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(main, "timed_input", lambda *_: next(inputs))
|
||||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
|
@@ -31,6 +31,7 @@ def test_add_and_retrieve_entry():
|
|||||||
"url": "",
|
"url": "",
|
||||||
"blacklisted": False,
|
"blacklisted": False,
|
||||||
"type": "password",
|
"type": "password",
|
||||||
|
"kind": "password",
|
||||||
"notes": "",
|
"notes": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,14 +62,19 @@ def test_round_trip_entry_types(method, expected_type):
|
|||||||
entry_mgr.add_totp("example", TEST_SEED)
|
entry_mgr.add_totp("example", TEST_SEED)
|
||||||
index = 0
|
index = 0
|
||||||
else:
|
else:
|
||||||
with pytest.raises(NotImplementedError):
|
if method == "add_ssh_key":
|
||||||
getattr(entry_mgr, method)()
|
index = entry_mgr.add_ssh_key(TEST_SEED)
|
||||||
index = 0
|
elif method == "add_seed":
|
||||||
|
index = entry_mgr.add_seed(TEST_SEED)
|
||||||
|
else:
|
||||||
|
index = getattr(entry_mgr, method)()
|
||||||
|
|
||||||
entry = entry_mgr.retrieve_entry(index)
|
entry = entry_mgr.retrieve_entry(index)
|
||||||
assert entry["type"] == expected_type
|
assert entry["type"] == expected_type
|
||||||
|
assert entry["kind"] == expected_type
|
||||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||||
assert data["entries"][str(index)]["type"] == expected_type
|
assert data["entries"][str(index)]["type"] == expected_type
|
||||||
|
assert data["entries"][str(index)]["kind"] == expected_type
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_entry_defaults_to_password():
|
def test_legacy_entry_defaults_to_password():
|
||||||
|
@@ -58,6 +58,7 @@ def test_handle_add_totp(monkeypatch, capsys):
|
|||||||
entry = entry_mgr.retrieve_entry(0)
|
entry = entry_mgr.retrieve_entry(0)
|
||||||
assert entry == {
|
assert entry == {
|
||||||
"type": "totp",
|
"type": "totp",
|
||||||
|
"kind": "totp",
|
||||||
"label": "Example",
|
"label": "Example",
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"period": 30,
|
"period": 30,
|
||||||
|
27
src/tests/test_pgp_entry.py
Normal file
27
src/tests/test_pgp_entry.py
Normal 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
|
41
src/tests/test_seed_entry.py
Normal file
41
src/tests/test_seed_entry.py
Normal 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
|
30
src/tests/test_ssh_entry.py
Normal file
30
src/tests/test_ssh_entry.py
Normal 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
|
@@ -30,6 +30,7 @@ def test_add_totp_and_get_code():
|
|||||||
entry = entry_mgr.retrieve_entry(0)
|
entry = entry_mgr.retrieve_entry(0)
|
||||||
assert entry == {
|
assert entry == {
|
||||||
"type": "totp",
|
"type": "totp",
|
||||||
|
"kind": "totp",
|
||||||
"label": "Example",
|
"label": "Example",
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"period": 30,
|
"period": 30,
|
||||||
@@ -66,6 +67,7 @@ def test_add_totp_imported(tmp_path):
|
|||||||
entry = em.retrieve_entry(0)
|
entry = em.retrieve_entry(0)
|
||||||
assert entry == {
|
assert entry == {
|
||||||
"type": "totp",
|
"type": "totp",
|
||||||
|
"kind": "totp",
|
||||||
"label": "Imported",
|
"label": "Imported",
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
"period": 30,
|
"period": 30,
|
||||||
|
Reference in New Issue
Block a user