mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 07:48:57 +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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
|
@@ -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
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("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"))
|
||||
|
@@ -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", "")
|
||||
|
@@ -11,3 +11,4 @@ class EntryType(str, Enum):
|
||||
TOTP = "totp"
|
||||
SSH = "ssh"
|
||||
SEED = "seed"
|
||||
PGP = "pgp"
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -18,6 +18,7 @@ websockets>=15.0.0
|
||||
tomli
|
||||
hypothesis
|
||||
mutmut==2.4.4
|
||||
pgpy==0.6.0
|
||||
pyotp>=2.8.0
|
||||
|
||||
freezegun
|
||||
|
@@ -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)
|
||||
|
@@ -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):
|
||||
|
@@ -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():
|
||||
|
@@ -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,
|
||||
|
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)
|
||||
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,
|
||||
|
Reference in New Issue
Block a user