mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 23:38:49 +00:00
Compare commits
101 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
bda90cec03 | ||
![]() |
28f27de8e8 | ||
![]() |
e4093d7334 | ||
![]() |
b83ec2621e | ||
![]() |
78cd847c25 | ||
![]() |
b3c7d796e1 | ||
![]() |
764631b8ba | ||
![]() |
09d1bf51fc | ||
![]() |
d415eca8bd | ||
![]() |
176ba6befd | ||
![]() |
87215a10cc | ||
![]() |
e0b253ea63 | ||
![]() |
5826e18189 | ||
![]() |
9d5593a1f5 | ||
![]() |
f7eaf2897f | ||
![]() |
96cad49e55 | ||
![]() |
182085b639 | ||
![]() |
5fdfc7ca5a | ||
![]() |
97bdd2483d | ||
![]() |
9e2d469743 | ||
![]() |
a3caf16dd4 | ||
![]() |
6336fb3fe4 | ||
![]() |
f47baf4132 | ||
![]() |
30dd09b0b4 | ||
![]() |
7e0505a729 | ||
![]() |
26f1ba4482 | ||
![]() |
479c034573 | ||
![]() |
0741744f99 | ||
![]() |
b88a93df29 | ||
![]() |
ae9e6ba0d4 | ||
![]() |
b80abff895 | ||
![]() |
387bfad220 | ||
![]() |
073b8c4d47 | ||
![]() |
c17bb8f8d8 | ||
![]() |
e8ade741ad | ||
![]() |
ea9665383e | ||
![]() |
e5a8dde59d | ||
![]() |
78368c0e2f | ||
![]() |
c6398b3c99 | ||
![]() |
6b7815f28e | ||
![]() |
07f1843739 | ||
![]() |
04548c44f5 | ||
![]() |
144447fb3d | ||
![]() |
6f21c5cb9d | ||
![]() |
a610272552 | ||
![]() |
76bdd4fde0 | ||
![]() |
cc68f05130 | ||
![]() |
1e5d115f80 | ||
![]() |
0a011f108b | ||
![]() |
54aa609b62 | ||
![]() |
f701124fb1 | ||
![]() |
8370dec5c3 | ||
![]() |
6cca270bd6 | ||
![]() |
73898972f1 | ||
![]() |
04dc4e05da | ||
![]() |
9369bac70f | ||
![]() |
d7547810fe | ||
![]() |
bceaa99228 | ||
![]() |
f46de144a9 | ||
![]() |
23f672575e | ||
![]() |
113fd1181a | ||
![]() |
40bd009b6e | ||
![]() |
bcb38ce79f | ||
![]() |
d831f1b1a2 | ||
![]() |
dbd051a1b0 | ||
![]() |
27d8b8ffa1 | ||
![]() |
bfc0331057 | ||
![]() |
503159ff6d | ||
![]() |
22cc302288 | ||
![]() |
d10f5288c3 | ||
![]() |
5a3b80b4f6 | ||
![]() |
0bfc641815 | ||
![]() |
3e004d3932 | ||
![]() |
2705adf90b | ||
![]() |
dfa560a270 | ||
![]() |
d4b3db7386 | ||
![]() |
754dce086c | ||
![]() |
ca67cf1f92 | ||
![]() |
3de84ec484 | ||
![]() |
2eae65872f | ||
![]() |
6d24ffb2ec | ||
![]() |
e52e2629fe | ||
![]() |
3bcf3312df | ||
![]() |
a61a064d2e | ||
![]() |
fffd287032 | ||
![]() |
b8e6ae3e36 | ||
![]() |
4d559d0339 | ||
![]() |
bdf83fabd8 | ||
![]() |
8fca2b3346 | ||
![]() |
1b4e4773f1 | ||
![]() |
d4bcc7e726 | ||
![]() |
0b4eec55a0 | ||
![]() |
31265edc69 | ||
![]() |
c946f30258 | ||
![]() |
7ececbccbb | ||
![]() |
000a607bbc | ||
![]() |
984e61de8f | ||
![]() |
513f6df459 | ||
![]() |
3719797013 | ||
![]() |
db85caceda | ||
![]() |
41cf6830a8 |
11
.github/workflows/python-ci.yml
vendored
11
.github/workflows/python-ci.yml
vendored
@@ -81,10 +81,15 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
|
||||
run: echo "NOSTR_E2E=1" >> $GITHUB_ENV
|
||||
- name: Run tests with coverage
|
||||
timeout-minutes: 16
|
||||
shell: bash
|
||||
run: |
|
||||
pytest ${STRESS_ARGS} --cov=src --cov-report=xml --cov-report=term-missing \
|
||||
--cov-fail-under=20 src/tests
|
||||
run: scripts/run_ci_tests.sh
|
||||
- name: Upload pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-log-${{ matrix.os }}
|
||||
path: pytest.log
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -40,4 +40,7 @@ src/seedpass.egg-info/PKG-INFO
|
||||
src/seedpass.egg-info/SOURCES.txt
|
||||
src/seedpass.egg-info/dependency_links.txt
|
||||
src/seedpass.egg-info/entry_points.txt
|
||||
src/seedpass.egg-info/top_level.txt
|
||||
src/seedpass.egg-info/top_level.txt
|
||||
|
||||
# Allow vendored dependencies to be committed
|
||||
!src/vendor/
|
||||
|
60
README.md
60
README.md
@@ -31,6 +31,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Managing Multiple Seeds](#managing-multiple-seeds)
|
||||
- [Additional Entry Types](#additional-entry-types)
|
||||
- [Building a standalone executable](#building-a-standalone-executable)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
@@ -331,6 +332,15 @@ When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to
|
||||
2. Choose how many seconds to keep passwords on the clipboard.
|
||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
Selecting an item from **List Entries** or **Search Entries** first displays the
|
||||
entry's metadata such as the label, username, tags and notes. Passwords, seed
|
||||
phrases and other sensitive fields remain hidden until you choose to reveal
|
||||
them. When you opt to show the secret, the details view presents the same action
|
||||
menu as **Retrieve Entry** so you can edit, archive or display QR codes for the
|
||||
entry.
|
||||
|
||||
### Additional Entry Types
|
||||
|
||||
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:
|
||||
@@ -360,8 +370,9 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
|
||||
|
||||
- **Add a New Seed Profile:**
|
||||
1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
|
||||
2. Choose to enter an existing seed or generate a new one.
|
||||
3. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
2. Choose to paste in a full seed, enter one word at a time, or generate a new seed.
|
||||
3. If you enter the seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. SeedPass then shows the completed phrase for confirmation so you can fix any mistakes before it is stored.
|
||||
4. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
|
||||
- **Switch Between Seed Profiles:**
|
||||
1. From the **Profiles** menu, select "Switch Seed Profile".
|
||||
@@ -369,8 +380,12 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
|
||||
3. Enter the number corresponding to the seed profile you wish to switch to.
|
||||
4. Enter the master password associated with that seed profile.
|
||||
|
||||
- **List All Seed Profiles:**
|
||||
- **List All Seed Profiles:**
|
||||
In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
|
||||
- **Set Seed Profile Name:**
|
||||
In the **Profiles** menu, choose "Set Seed Profile Name" to assign an optional
|
||||
label to the currently selected profile. The name is stored locally and shown
|
||||
alongside the fingerprint in menus.
|
||||
|
||||
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
|
||||
|
||||
@@ -459,7 +474,9 @@ subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with
|
||||
the main application. The fingerprint is printed after creation and the
|
||||
encrypted index is published to Nostr. Use that same seed phrase to load
|
||||
SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your
|
||||
vault stays in sync across machines.
|
||||
vault stays in sync across machines. If no snapshot exists or the download
|
||||
cannot be decrypted (for example when using a brand-new seed), SeedPass
|
||||
automatically initializes an empty index instead of exiting.
|
||||
|
||||
### Automatically Updating the Script Checksum
|
||||
|
||||
@@ -486,6 +503,41 @@ python -m mutmut results
|
||||
```
|
||||
|
||||
Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead.
|
||||
## Development Workflow
|
||||
|
||||
1. Install all development dependencies:
|
||||
```bash
|
||||
pip install -r src/requirements.txt
|
||||
```
|
||||
|
||||
2. When `src/runtime_requirements.txt` changes, rerun:
|
||||
```bash
|
||||
scripts/vendor_dependencies.sh
|
||||
```
|
||||
Commit the updated `src/vendor/` directory. The application automatically adds this folder to `sys.path` so the bundled packages are found.
|
||||
|
||||
3. Before committing, format and test the code:
|
||||
```bash
|
||||
black .
|
||||
pytest
|
||||
```
|
||||
|
||||
|
||||
## Building a standalone executable
|
||||
|
||||
1. Run the vendoring script to bundle runtime dependencies:
|
||||
|
||||
```bash
|
||||
scripts/vendor_dependencies.sh
|
||||
```
|
||||
|
||||
2. Build the binary with PyInstaller:
|
||||
|
||||
```bash
|
||||
pyinstaller SeedPass.spec
|
||||
```
|
||||
|
||||
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
38
SeedPass.spec
Normal file
38
SeedPass.spec
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['src/main.py'],
|
||||
pathex=['src', 'src/vendor'],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='SeedPass',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
@@ -156,6 +156,14 @@ $ seedpass entry get "email"
|
||||
Code: 123456
|
||||
```
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
Picking an entry from `entry list` or `entry search` displays its metadata first
|
||||
so you can review the label, username and notes. Sensitive fields are hidden
|
||||
until you confirm you want to reveal them. After showing the secret, the details
|
||||
view offers the same actions as `entry get`—edit the entry, archive it or show
|
||||
QR codes for supported types.
|
||||
|
||||
### `vault` Commands
|
||||
|
||||
- **`seedpass vault export`** – Export the entire vault to an encrypted JSON file.
|
||||
|
@@ -357,7 +357,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
|
||||
|
||||
- **Add a New Seed Profile:**
|
||||
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
|
||||
- Choose to enter an existing seed or generate a new one.
|
||||
- Choose to paste in a full seed, enter one word at a time, or generate a new seed.
|
||||
- When entering a seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. You'll review the completed phrase after the last word and can correct mistakes before it is saved.
|
||||
- If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
|
||||
- **Switch Between Seed Profiles:**
|
||||
@@ -368,6 +369,8 @@ SeedPass allows you to manage multiple seed profiles (previously referred to as
|
||||
|
||||
- **List All Seed Profiles:**
|
||||
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
|
||||
- **Set Seed Profile Name:**
|
||||
- In the **Profiles** menu, choose "Set Seed Profile Name" to assign a label to the current profile. The name is stored locally and shown next to the fingerprint.
|
||||
|
||||
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
|
||||
|
||||
|
@@ -40,7 +40,7 @@
|
||||
</li>
|
||||
<li role="none"><a href="#disclaimer" role="menuitem">Disclaimer</a>
|
||||
</li>
|
||||
<li role="none"><a href="https://beta-seedpass-docs.netlify.app/" role="menuitem">Docs</a>
|
||||
<li role="none"><a href="https://docs.seedpass.me/" role="menuitem">Docs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
36
scripts/run_ci_tests.sh
Executable file
36
scripts/run_ci_tests.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
pytest_args=(-vv)
|
||||
if [[ -n "${STRESS_ARGS:-}" ]]; then
|
||||
pytest_args+=(${STRESS_ARGS})
|
||||
fi
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests)
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
timeout_bin=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$timeout_bin" ]]; then
|
||||
$timeout_bin 15m pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
else
|
||||
echo "timeout command not found; running tests without timeout" >&2
|
||||
pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
if [[ $status -eq 124 ]]; then
|
||||
echo "::error::Tests exceeded 15-minute limit"
|
||||
tail -n 20 pytest.log
|
||||
exit 1
|
||||
fi
|
||||
exit $status
|
12
scripts/vendor_dependencies.sh
Executable file
12
scripts/vendor_dependencies.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VENDOR_DIR="src/vendor"
|
||||
|
||||
# Clean vendor directory
|
||||
rm -rf "$VENDOR_DIR"
|
||||
mkdir -p "$VENDOR_DIR"
|
||||
|
||||
pip download --no-binary :all: -r src/runtime_requirements.txt -d "$VENDOR_DIR"
|
||||
|
||||
echo "Vendored dependencies installed in $VENDOR_DIR"
|
40
src/main.py
40
src/main.py
@@ -1,10 +1,15 @@
|
||||
# main.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Add bundled vendor directory to sys.path so bundled dependencies can be imported
|
||||
vendor_dir = Path(__file__).parent / "vendor"
|
||||
if vendor_dir.exists():
|
||||
sys.path.insert(0, str(vendor_dir))
|
||||
|
||||
import os
|
||||
import logging
|
||||
import signal
|
||||
import getpass
|
||||
import time
|
||||
import argparse
|
||||
import asyncio
|
||||
@@ -151,7 +156,8 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for idx, fp in enumerate(fingerprints, start=1):
|
||||
print(colored(f"{idx}. {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"{idx}. {label}", "cyan"))
|
||||
|
||||
choice = input("Select a seed profile by number to switch: ").strip()
|
||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
||||
@@ -195,7 +201,8 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for idx, fp in enumerate(fingerprints, start=1):
|
||||
print(colored(f"{idx}. {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"{idx}. {label}", "cyan"))
|
||||
|
||||
choice = input("Select a seed profile by number to remove: ").strip()
|
||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
||||
@@ -239,7 +246,8 @@ def handle_list_fingerprints(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for fp in fingerprints:
|
||||
print(colored(f"- {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"- {label}", "cyan"))
|
||||
pause()
|
||||
except Exception as e:
|
||||
logging.error(f"Error listing seed profiles: {e}", exc_info=True)
|
||||
@@ -641,6 +649,25 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
|
||||
|
||||
def handle_set_profile_name(pm: PasswordManager) -> None:
|
||||
"""Set or clear the custom name for the current seed profile."""
|
||||
fp = getattr(pm.fingerprint_manager, "current_fingerprint", None)
|
||||
if not fp:
|
||||
print(colored("No seed profile selected.", "red"))
|
||||
return
|
||||
current = pm.fingerprint_manager.get_name(fp)
|
||||
if current:
|
||||
print(colored(f"Current name: {current}", "cyan"))
|
||||
else:
|
||||
print(colored("No custom name set.", "cyan"))
|
||||
value = input("Enter new name (leave blank to remove): ").strip()
|
||||
if pm.fingerprint_manager.set_name(fp, value or None):
|
||||
if value:
|
||||
print(colored("Name updated.", "green"))
|
||||
else:
|
||||
print(colored("Name removed.", "green"))
|
||||
|
||||
|
||||
def handle_toggle_secret_mode(pm: PasswordManager) -> None:
|
||||
"""Toggle secret mode and adjust clipboard delay."""
|
||||
cfg = pm.config_manager
|
||||
@@ -756,6 +783,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
print(color_text("2. Add a New Seed Profile", "menu"))
|
||||
print(color_text("3. Remove an Existing Seed Profile", "menu"))
|
||||
print(color_text("4. List All Seed Profiles", "menu"))
|
||||
print(color_text("5. Set Seed Profile Name", "menu"))
|
||||
choice = input("Select an option or press Enter to go back: ").strip()
|
||||
password_manager.update_activity()
|
||||
if choice == "1":
|
||||
@@ -767,6 +795,8 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
handle_remove_fingerprint(password_manager)
|
||||
elif choice == "4":
|
||||
handle_list_fingerprints(password_manager)
|
||||
elif choice == "5":
|
||||
handle_set_profile_name(password_manager)
|
||||
elif not choice:
|
||||
break
|
||||
else:
|
||||
|
@@ -6,7 +6,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import getpass
|
||||
from utils.seed_prompt import masked_input
|
||||
|
||||
import bcrypt
|
||||
|
||||
@@ -93,7 +93,7 @@ class ConfigManager:
|
||||
self.save_config(data)
|
||||
if require_pin and data.get("pin_hash"):
|
||||
for _ in range(3):
|
||||
pin = getpass.getpass("Enter settings PIN: ").strip()
|
||||
pin = masked_input("Enter settings PIN: ").strip()
|
||||
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
|
||||
break
|
||||
print("Invalid PIN")
|
||||
|
@@ -223,15 +223,28 @@ class EncryptionManager:
|
||||
return fh.read()
|
||||
|
||||
def decrypt_and_save_index_from_nostr(
|
||||
self, encrypted_data: bytes, relative_path: Optional[Path] = None
|
||||
) -> None:
|
||||
"""Decrypts data from Nostr and saves it, automatically using the new format."""
|
||||
self,
|
||||
encrypted_data: bytes,
|
||||
relative_path: Optional[Path] = None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> bool:
|
||||
"""Decrypts data from Nostr and saves it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
encrypted_data:
|
||||
The payload downloaded from Nostr.
|
||||
relative_path:
|
||||
Destination filename under the profile directory.
|
||||
strict:
|
||||
When ``True`` (default) re-raise any decryption error. When ``False``
|
||||
return ``False`` if decryption fails.
|
||||
"""
|
||||
if relative_path is None:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
try:
|
||||
decrypted_data = self.decrypt_data(
|
||||
encrypted_data
|
||||
) # This now handles both formats
|
||||
decrypted_data = self.decrypt_data(encrypted_data)
|
||||
if USE_ORJSON:
|
||||
data = json_lib.loads(decrypted_data)
|
||||
else:
|
||||
@@ -240,18 +253,22 @@ class EncryptionManager:
|
||||
self.update_checksum(relative_path)
|
||||
logger.info("Index file from Nostr was processed and saved successfully.")
|
||||
print(colored("Index file updated from Nostr successfully.", "green"))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to decrypt and save data from Nostr: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to decrypt and save data from Nostr: {e}",
|
||||
"red",
|
||||
return True
|
||||
except Exception as e: # pragma: no cover - error handling
|
||||
if strict:
|
||||
logger.error(
|
||||
f"Failed to decrypt and save data from Nostr: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
)
|
||||
raise
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to decrypt and save data from Nostr: {e}",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
raise
|
||||
logger.warning(f"Failed to decrypt index from Nostr: {e}")
|
||||
return False
|
||||
|
||||
def update_checksum(self, relative_path: Optional[Path] = None) -> None:
|
||||
"""Updates the checksum file for the specified file."""
|
||||
|
@@ -437,8 +437,8 @@ class EntryManager:
|
||||
"""Return the npub and nsec for the specified entry."""
|
||||
|
||||
entry = self.retrieve_entry(index)
|
||||
etype = entry.get("type") if entry else None
|
||||
kind = entry.get("kind") if entry else None
|
||||
etype = entry.get("type", "").lower() if entry else ""
|
||||
kind = entry.get("kind", "").lower() if entry else ""
|
||||
if not entry or (
|
||||
etype != EntryType.NOSTR.value and kind != EntryType.NOSTR.value
|
||||
):
|
||||
@@ -920,6 +920,7 @@ class EntryManager:
|
||||
filter_kind: str | None = None,
|
||||
*,
|
||||
include_archived: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
|
||||
"""List entries in the index with optional sorting and filtering.
|
||||
|
||||
@@ -932,7 +933,8 @@ class EntryManager:
|
||||
|
||||
if not entries_data:
|
||||
logger.info("No entries found.")
|
||||
print(colored("No entries found.", "yellow"))
|
||||
if verbose:
|
||||
print(colored("No entries found.", "yellow"))
|
||||
return []
|
||||
|
||||
def sort_key(item: Tuple[str, Dict[str, Any]]):
|
||||
@@ -987,51 +989,59 @@ class EntryManager:
|
||||
)
|
||||
|
||||
logger.debug(f"Total entries found: {len(entries)}")
|
||||
for idx, entry in filtered_items:
|
||||
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"))
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(colored(f" Derivation Index: {entry.get('index')}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
|
||||
"cyan",
|
||||
if verbose:
|
||||
for idx, entry in filtered_items:
|
||||
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"))
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(
|
||||
colored(f" Derivation Index: {entry.get('index')}", "cyan")
|
||||
)
|
||||
)
|
||||
elif etype == EntryType.PASSWORD.value:
|
||||
print(
|
||||
colored(
|
||||
f" Label: {entry.get('label', entry.get('website', ''))}",
|
||||
"cyan",
|
||||
print(
|
||||
colored(
|
||||
f" Period: {entry.get('period', 30)}s Digits: {entry.get('digits', 6)}",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
)
|
||||
print(
|
||||
colored(f" Username: {entry.get('username') or 'N/A'}", "cyan")
|
||||
)
|
||||
print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}",
|
||||
"cyan",
|
||||
elif etype == EntryType.PASSWORD.value:
|
||||
print(
|
||||
colored(
|
||||
f" Label: {entry.get('label', entry.get('website', ''))}",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" Derivation Index: {entry.get('index', idx)}",
|
||||
"cyan",
|
||||
print(
|
||||
colored(
|
||||
f" Username: {entry.get('username') or 'N/A'}", "cyan"
|
||||
)
|
||||
)
|
||||
)
|
||||
print("-" * 40)
|
||||
print(colored(f" URL: {entry.get('url') or 'N/A'}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" Archived: {'Yes' if entry.get('archived', entry.get('blacklisted', False)) else 'No'}",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(colored(f" Label: {entry.get('label', '')}", "cyan"))
|
||||
print(
|
||||
colored(
|
||||
f" Derivation Index: {entry.get('index', idx)}",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print("-" * 40)
|
||||
|
||||
return entries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list entries: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to list entries: {e}", "red"))
|
||||
if verbose:
|
||||
print(colored(f"Error: Failed to list entries: {e}", "red"))
|
||||
return []
|
||||
|
||||
def search_entries(
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -60,9 +60,13 @@ class Vault:
|
||||
"""Return the encrypted index bytes if present."""
|
||||
return self.encryption_manager.get_encrypted_index()
|
||||
|
||||
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
|
||||
def decrypt_and_save_index_from_nostr(
|
||||
self, encrypted_data: bytes, *, strict: bool = True
|
||||
) -> bool:
|
||||
"""Decrypt Nostr payload and overwrite the local index."""
|
||||
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
|
||||
return self.encryption_manager.decrypt_and_save_index_from_nostr(
|
||||
encrypted_data, strict=strict
|
||||
)
|
||||
|
||||
# ----- Config helpers -----
|
||||
def load_config(self) -> dict:
|
||||
|
29
src/runtime_requirements.txt
Normal file
29
src/runtime_requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Runtime dependencies for vendoring/packaging only
|
||||
# Generated from requirements.txt with all test-only packages removed
|
||||
colorama>=0.4.6
|
||||
termcolor>=1.1.0
|
||||
cryptography>=40.0.2
|
||||
bip-utils>=2.5.0
|
||||
bech32==1.2.0
|
||||
coincurve>=18.0.0
|
||||
mnemonic
|
||||
aiohttp>=3.12.14
|
||||
bcrypt
|
||||
portalocker>=2.8
|
||||
nostr-sdk>=0.42.1
|
||||
websocket-client==1.7.0
|
||||
|
||||
websockets>=15.0.0
|
||||
tomli
|
||||
pgpy==0.6.0
|
||||
pyotp>=2.8.0
|
||||
pyperclip
|
||||
qrcode>=8.2
|
||||
typer>=0.12.3
|
||||
fastapi>=0.116.0
|
||||
uvicorn>=0.35.0
|
||||
httpx>=0.28.1
|
||||
requests>=2.32
|
||||
python-multipart
|
||||
orjson
|
||||
argon2-cffi
|
@@ -152,3 +152,41 @@ def test_view_archived_entries_removed_after_restore(monkeypatch, capsys):
|
||||
note = pm.notifications.get_nowait()
|
||||
assert note.level == "WARNING"
|
||||
assert note.message == "No archived entries found."
|
||||
|
||||
|
||||
def test_archived_entries_menu_hides_active(monkeypatch, capsys):
|
||||
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)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_client = SimpleNamespace()
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.is_dirty = False
|
||||
pm.notifications = queue.Queue()
|
||||
|
||||
archived_idx = entry_mgr.add_entry("archived.com", 8)
|
||||
active_idx = entry_mgr.add_entry("active.com", 8)
|
||||
|
||||
# Archive only the first entry
|
||||
monkeypatch.setattr("builtins.input", lambda *_: str(archived_idx))
|
||||
pm.handle_archive_entry()
|
||||
assert entry_mgr.retrieve_entry(archived_idx)["archived"] is True
|
||||
assert entry_mgr.retrieve_entry(active_idx)["archived"] is False
|
||||
|
||||
# View archived entries and immediately exit
|
||||
inputs = iter([""])
|
||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||
pm.handle_view_archived_entries()
|
||||
out = capsys.readouterr().out
|
||||
assert "archived.com" in out
|
||||
assert "active.com" not in out
|
||||
|
@@ -63,7 +63,7 @@ def test_index_export_import_round_trip():
|
||||
},
|
||||
}
|
||||
)
|
||||
vault.decrypt_and_save_index_from_nostr(encrypted)
|
||||
assert vault.decrypt_and_save_index_from_nostr(encrypted)
|
||||
|
||||
loaded = vault.load_index()
|
||||
assert loaded["entries"] == original["entries"]
|
||||
|
@@ -2,6 +2,8 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
|
||||
|
||||
import sys
|
||||
@@ -11,6 +13,7 @@ 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.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.entry_types import EntryType
|
||||
from password_manager.config_manager import ConfigManager
|
||||
|
||||
|
||||
@@ -30,6 +33,8 @@ def test_handle_list_entries(monkeypatch, capsys):
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.nostr_client = SimpleNamespace()
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = False
|
||||
pm.secret_mode_enabled = False
|
||||
|
||||
entry_mgr.add_totp("Example", TEST_SEED)
|
||||
entry_mgr.add_entry("example.com", 12)
|
||||
@@ -80,12 +85,322 @@ def test_list_entries_show_details(monkeypatch, capsys):
|
||||
lambda *a, **k: "b",
|
||||
)
|
||||
|
||||
inputs = iter(["1", "0", "n"])
|
||||
inputs = iter(["1", "0"])
|
||||
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
|
||||
|
||||
pm.handle_list_entries()
|
||||
out = capsys.readouterr().out
|
||||
assert "Retrieved 2FA Code" in out
|
||||
assert "123456" in out
|
||||
assert "Label: Example" in out
|
||||
assert "Period: 30s" in out
|
||||
assert "API" in out
|
||||
assert "acct" in out
|
||||
|
||||
|
||||
def test_show_entry_details_by_index(monkeypatch):
|
||||
"""Ensure entry details screen triggers expected calls."""
|
||||
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)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.nostr_client = SimpleNamespace()
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = False
|
||||
|
||||
index = entry_mgr.add_entry("example.com", 12)
|
||||
|
||||
header_calls = []
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.clear_header_with_notification",
|
||||
lambda *a, **k: header_calls.append(True),
|
||||
)
|
||||
|
||||
call_order = []
|
||||
monkeypatch.setattr(
|
||||
pm,
|
||||
"display_entry_details",
|
||||
lambda *a, **k: call_order.append("display"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pm,
|
||||
"_entry_actions_menu",
|
||||
lambda *a, **k: call_order.append("actions"),
|
||||
)
|
||||
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *a, **k: False
|
||||
)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
|
||||
|
||||
pm.show_entry_details_by_index(index)
|
||||
|
||||
assert len(header_calls) == 1
|
||||
assert call_order == ["display", "actions"]
|
||||
|
||||
|
||||
def _setup_manager(tmp_path):
|
||||
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)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_client = SimpleNamespace()
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = False
|
||||
return pm, entry_mgr
|
||||
|
||||
|
||||
def _detail_common(monkeypatch, pm):
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.clear_header_with_notification",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: "")
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *a, **k: False
|
||||
)
|
||||
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
|
||||
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
called = []
|
||||
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True))
|
||||
return called
|
||||
|
||||
|
||||
def test_show_seed_entry_details(monkeypatch, capsys):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert "Type: Seed Phrase" in out
|
||||
assert "Label: seed" in out
|
||||
assert "Words: 12" in out
|
||||
assert f"Derivation Index: {idx}" in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
def test_show_ssh_entry_details(monkeypatch, capsys):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
||||
data = entry_mgr._load_index(force_reload=True)
|
||||
data["entries"][str(idx)]["public_key_label"] = "server"
|
||||
data["entries"][str(idx)]["fingerprint"] = "abc123"
|
||||
entry_mgr._save_index(data)
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert "Type: SSH Key" in out
|
||||
assert "Label: ssh" in out
|
||||
assert f"Derivation Index: {idx}" in out
|
||||
assert "server" in out
|
||||
assert "abc123" in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
def test_show_pgp_entry_details(monkeypatch, capsys):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
|
||||
_k, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert "Type: PGP Key" in out
|
||||
assert "Label: pgp" in out
|
||||
assert "Key Type: ed25519" in out
|
||||
assert "User ID: test" in out
|
||||
assert f"Derivation Index: {idx}" in out
|
||||
assert fp in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
def test_show_nostr_entry_details(monkeypatch, capsys):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_nostr_key("nostr")
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert "Type: Nostr Key" in out
|
||||
assert "Label: nostr" in out
|
||||
assert f"Derivation Index: {idx}" in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
def test_show_managed_account_entry_details(monkeypatch, capsys):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
|
||||
fp = entry_mgr.retrieve_entry(idx).get("fingerprint")
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert "Type: Managed Account" in out
|
||||
assert "Label: acct" in out
|
||||
assert f"Derivation Index: {idx}" in out
|
||||
assert "Words: 12" in out
|
||||
assert fp in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry_type",
|
||||
[
|
||||
"password",
|
||||
"seed",
|
||||
"ssh",
|
||||
"pgp",
|
||||
"nostr",
|
||||
"totp",
|
||||
"key_value",
|
||||
"managed_account",
|
||||
],
|
||||
)
|
||||
def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
|
||||
"""Ensure sensitive details are displayed for each entry type."""
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *a, **k: True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.copy_to_clipboard", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
|
||||
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.clear_header_with_notification",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None)
|
||||
|
||||
input_val = "r" if entry_type == "managed_account" else ""
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: input_val)
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
pm, "_entry_actions_menu", lambda *a, **k: called.append(True)
|
||||
)
|
||||
|
||||
if entry_type == "password":
|
||||
idx = entry_mgr.add_entry("example", 8)
|
||||
expected = "pw123"
|
||||
elif entry_type == "seed":
|
||||
idx = entry_mgr.add_seed("seed", TEST_SEED, words_num=12)
|
||||
expected = entry_mgr.get_seed_phrase(idx, TEST_SEED)
|
||||
elif entry_type == "ssh":
|
||||
idx = entry_mgr.add_ssh_key("ssh", TEST_SEED)
|
||||
priv, pub = entry_mgr.get_ssh_key_pair(idx, TEST_SEED)
|
||||
expected = priv
|
||||
extra = pub
|
||||
elif entry_type == "pgp":
|
||||
idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, user_id="test")
|
||||
priv, fp = entry_mgr.get_pgp_key(idx, TEST_SEED)
|
||||
expected = priv
|
||||
extra = fp
|
||||
elif entry_type == "nostr":
|
||||
idx = entry_mgr.add_nostr_key("nostr")
|
||||
_npub, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
|
||||
expected = nsec
|
||||
elif entry_type == "totp":
|
||||
entry_mgr.add_totp("Example", TEST_SEED)
|
||||
idx = 0
|
||||
monkeypatch.setattr(
|
||||
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
|
||||
)
|
||||
expected = "123456"
|
||||
elif entry_type == "key_value":
|
||||
idx = entry_mgr.add_key_value("API", "abc")
|
||||
expected = "abc"
|
||||
else: # managed_account
|
||||
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
|
||||
expected = entry_mgr.get_managed_account_seed(idx, TEST_SEED)
|
||||
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert expected in out
|
||||
if entry_type in {"ssh", "pgp"}:
|
||||
assert extra in out
|
||||
assert called == [True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry_type", [EntryType.PASSWORD, EntryType.TOTP, EntryType.KEY_VALUE]
|
||||
)
|
||||
def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
|
||||
"""Entries storing an EntryType enum should display correctly."""
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
|
||||
if entry_type == EntryType.PASSWORD:
|
||||
idx = entry_mgr.add_entry("example.com", 8)
|
||||
expect = "example.com"
|
||||
elif entry_type == EntryType.TOTP:
|
||||
entry_mgr.add_totp("Example", TEST_SEED)
|
||||
idx = 0
|
||||
monkeypatch.setattr(
|
||||
pm.entry_manager, "get_totp_code", lambda *a, **k: "123456"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
|
||||
)
|
||||
expect = "Label: Example"
|
||||
else: # KEY_VALUE
|
||||
idx = entry_mgr.add_key_value("API", "abc")
|
||||
expect = "API"
|
||||
|
||||
data = entry_mgr._load_index(force_reload=True)
|
||||
data["entries"][str(idx)]["type"] = entry_type
|
||||
entry_mgr._save_index(data)
|
||||
|
||||
called = _detail_common(monkeypatch, pm)
|
||||
pm.show_entry_details_by_index(idx)
|
||||
out = capsys.readouterr().out
|
||||
assert expect in out
|
||||
assert called == [True]
|
||||
|
@@ -41,11 +41,11 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys):
|
||||
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
|
||||
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
|
||||
|
||||
inputs = iter(["Example", "0", "n", ""])
|
||||
inputs = iter(["Example", "0"])
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
|
||||
|
||||
pm.handle_search_entries()
|
||||
out = capsys.readouterr().out
|
||||
assert "0. Example" in out
|
||||
assert "Retrieved 2FA Code" in out
|
||||
assert "123456" in out
|
||||
assert "Label: Example" in out
|
||||
assert "Period: 30s" in out
|
||||
|
67
src/tests/test_manager_seed_setup.py
Normal file
67
src/tests/test_manager_seed_setup.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import builtins
|
||||
from mnemonic import Mnemonic
|
||||
from password_manager.manager import PasswordManager
|
||||
from utils import seed_prompt
|
||||
|
||||
|
||||
def test_validate_bip85_seed_invalid_word():
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
bad_phrase = "abandon " * 11 + "zzzz"
|
||||
assert not pm.validate_bip85_seed(bad_phrase)
|
||||
|
||||
|
||||
def test_validate_bip85_seed_checksum_failure():
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
# Use a known valid phrase to avoid randomness causing a valid checksum
|
||||
phrase = (
|
||||
"legal winner thank year wave sausage worth useful legal winner thank yellow"
|
||||
)
|
||||
words = phrase.split()
|
||||
words[-1] = "abandon"
|
||||
bad_phrase = " ".join(words)
|
||||
assert not pm.validate_bip85_seed(bad_phrase)
|
||||
|
||||
|
||||
def test_setup_existing_seed_words(monkeypatch):
|
||||
m = Mnemonic("english")
|
||||
phrase = m.generate(strength=128)
|
||||
words = phrase.split()
|
||||
word_iter = iter(words)
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.masked_input",
|
||||
lambda *_: next(word_iter),
|
||||
)
|
||||
# Ensure prompt_seed_words uses the patched function
|
||||
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
|
||||
monkeypatch.setattr(builtins, "input", lambda *_: "y")
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
|
||||
|
||||
result = pm.setup_existing_seed(method="words")
|
||||
assert result == phrase
|
||||
|
||||
|
||||
def test_setup_existing_seed_paste(monkeypatch):
|
||||
m = Mnemonic("english")
|
||||
phrase = m.generate(strength=128)
|
||||
|
||||
called = {}
|
||||
|
||||
def fake_masked_input(prompt: str) -> str:
|
||||
called["prompt"] = prompt
|
||||
return phrase
|
||||
|
||||
monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input)
|
||||
monkeypatch.setattr(
|
||||
builtins,
|
||||
"input",
|
||||
lambda *_: (_ for _ in ()).throw(RuntimeError("input called")),
|
||||
)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
|
||||
|
||||
result = pm.setup_existing_seed(method="paste")
|
||||
assert result == phrase
|
||||
assert called["prompt"].startswith("Enter your 12-word BIP-85 seed")
|
@@ -93,3 +93,46 @@ def test_show_private_key_qr(monkeypatch, capsys):
|
||||
out = capsys.readouterr().out
|
||||
assert called == [nsec]
|
||||
assert color_text(f"nsec: {nsec}", "deterministic") in out
|
||||
|
||||
|
||||
def test_qr_menu_case_insensitive(monkeypatch):
|
||||
"""QR menu should appear even if entry type is uppercase."""
|
||||
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)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_client = FakeNostrClient()
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.is_dirty = False
|
||||
pm.secret_mode_enabled = False
|
||||
|
||||
idx = entry_mgr.add_nostr_key("main")
|
||||
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
|
||||
|
||||
# Modify index to use uppercase type/kind
|
||||
data = enc_mgr.load_json_data(entry_mgr.index_file)
|
||||
data["entries"][str(idx)]["type"] = "NOSTR"
|
||||
data["entries"][str(idx)]["kind"] = "NOSTR"
|
||||
enc_mgr.save_json_data(data, entry_mgr.index_file)
|
||||
entry_mgr._index_cache = None
|
||||
|
||||
inputs = iter([str(idx), "q", "p", ""])
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.TotpManager.print_qr_code",
|
||||
lambda data: called.append(data),
|
||||
)
|
||||
|
||||
pm.handle_retrieve_entry()
|
||||
assert called == [f"nostr:{npub}"]
|
||||
|
@@ -9,16 +9,14 @@ from utils import password_prompt
|
||||
|
||||
def test_prompt_new_password(monkeypatch):
|
||||
responses = cycle(["goodpass", "goodpass"])
|
||||
monkeypatch.setattr(
|
||||
password_prompt.getpass, "getpass", lambda prompt: next(responses)
|
||||
)
|
||||
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(responses))
|
||||
result = password_prompt.prompt_new_password()
|
||||
assert result == "goodpass"
|
||||
|
||||
|
||||
def test_prompt_new_password_retry(monkeypatch, caplog):
|
||||
seq = iter(["pass1", "pass2", "passgood", "passgood"])
|
||||
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: next(seq))
|
||||
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(seq))
|
||||
caplog.set_level(logging.WARNING)
|
||||
result = password_prompt.prompt_new_password()
|
||||
assert "User entered a password shorter" in caplog.text
|
||||
@@ -26,7 +24,7 @@ def test_prompt_new_password_retry(monkeypatch, caplog):
|
||||
|
||||
|
||||
def test_prompt_existing_password(monkeypatch):
|
||||
monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: "mypassword")
|
||||
monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: "mypassword")
|
||||
assert password_prompt.prompt_existing_password() == "mypassword"
|
||||
|
||||
|
||||
|
@@ -41,7 +41,7 @@ def test_add_and_delete_entry(monkeypatch):
|
||||
manager_module.PasswordManager, "generate_bip85_seed", lambda self: seed
|
||||
)
|
||||
monkeypatch.setattr(manager_module, "confirm_action", lambda *_a, **_k: True)
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2")
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "3")
|
||||
|
||||
pm.add_new_fingerprint()
|
||||
|
||||
|
@@ -6,6 +6,9 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
from password_manager.manager import PasswordManager, EncryptionMode
|
||||
from helpers import create_vault, dummy_nostr_client
|
||||
import gzip
|
||||
from nostr.backup_models import Manifest, ChunkMeta
|
||||
|
||||
|
||||
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
@@ -51,3 +54,33 @@ def test_add_and_switch_fingerprint(monkeypatch):
|
||||
assert pm.current_fingerprint == fingerprint
|
||||
assert fm.current_fingerprint == fingerprint
|
||||
assert pm.fingerprint_dir == expected_dir
|
||||
|
||||
|
||||
def test_sync_index_missing_bad_data(monkeypatch, dummy_nostr_client):
|
||||
client, _relay = dummy_nostr_client
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
dir_path = Path(tmpdir)
|
||||
vault, _enc = create_vault(dir_path)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.fingerprint_dir = dir_path
|
||||
pm.vault = vault
|
||||
pm.nostr_client = client
|
||||
pm.sync_vault = lambda *a, **k: None
|
||||
|
||||
manifest = Manifest(
|
||||
ver=1,
|
||||
algo="aes-gcm",
|
||||
chunks=[ChunkMeta(id="c0", size=1, hash="00")],
|
||||
delta_since=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"fetch_latest_snapshot",
|
||||
lambda: (manifest, [gzip.compress(b"garbage")]),
|
||||
)
|
||||
monkeypatch.setattr(client, "fetch_deltas_since", lambda *_a, **_k: [])
|
||||
|
||||
pm.sync_index_from_nostr_if_missing()
|
||||
data = pm.vault.load_index()
|
||||
assert data["entries"] == {}
|
||||
|
58
src/tests/test_retrieve_pause_sensitive_entries.py
Normal file
58
src/tests/test_retrieve_pause_sensitive_entries.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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.manager import PasswordManager, EncryptionMode
|
||||
from password_manager.config_manager import ConfigManager
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"adder,needs_confirm",
|
||||
[
|
||||
(lambda mgr: mgr.add_seed("seed", TEST_SEED), True),
|
||||
(lambda mgr: mgr.add_pgp_key("pgp", TEST_SEED, user_id="test"), True),
|
||||
(lambda mgr: mgr.add_ssh_key("ssh", TEST_SEED), True),
|
||||
(lambda mgr: mgr.add_nostr_key("nostr"), False),
|
||||
],
|
||||
)
|
||||
def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm):
|
||||
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)
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.encryption_mode = EncryptionMode.SEED_ONLY
|
||||
pm.encryption_manager = enc_mgr
|
||||
pm.vault = vault
|
||||
pm.entry_manager = entry_mgr
|
||||
pm.backup_manager = backup_mgr
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.fingerprint_dir = tmp_path
|
||||
pm.secret_mode_enabled = False
|
||||
|
||||
index = adder(entry_mgr)
|
||||
|
||||
pause_calls = []
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.pause", lambda *a, **k: pause_calls.append(True)
|
||||
)
|
||||
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: str(index))
|
||||
if needs_confirm:
|
||||
monkeypatch.setattr(
|
||||
"password_manager.manager.confirm_action", lambda *a, **k: True
|
||||
)
|
||||
|
||||
pm.handle_retrieve_entry()
|
||||
assert len(pause_calls) == 1
|
76
src/tests/test_seed_prompt.py
Normal file
76
src/tests/test_seed_prompt.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import types
|
||||
from utils import seed_prompt
|
||||
|
||||
|
||||
def test_masked_input_posix_backspace(monkeypatch, capsys):
|
||||
seq = iter(["a", "b", "\x7f", "c", "\n"])
|
||||
monkeypatch.setattr(seed_prompt.sys.stdin, "read", lambda n=1: next(seq))
|
||||
monkeypatch.setattr(seed_prompt.sys.stdin, "fileno", lambda: 0)
|
||||
|
||||
if seed_prompt.termios is None:
|
||||
fake_termios = types.SimpleNamespace(
|
||||
tcgetattr=lambda fd: None,
|
||||
tcsetattr=lambda fd, *_: None,
|
||||
TCSADRAIN=1,
|
||||
)
|
||||
monkeypatch.setattr(seed_prompt, "termios", fake_termios)
|
||||
else:
|
||||
monkeypatch.setattr(seed_prompt.termios, "tcgetattr", lambda fd: None)
|
||||
monkeypatch.setattr(seed_prompt.termios, "tcsetattr", lambda fd, *_: None)
|
||||
|
||||
if seed_prompt.tty is None:
|
||||
fake_tty = types.SimpleNamespace(setraw=lambda fd: None)
|
||||
monkeypatch.setattr(seed_prompt, "tty", fake_tty)
|
||||
else:
|
||||
monkeypatch.setattr(seed_prompt.tty, "setraw", lambda fd: None)
|
||||
|
||||
monkeypatch.setattr(seed_prompt.sys, "platform", "linux", raising=False)
|
||||
|
||||
result = seed_prompt.masked_input("Enter: ")
|
||||
assert result == "ac"
|
||||
out = capsys.readouterr().out
|
||||
assert out.startswith("Enter: ")
|
||||
assert out.count("*") == 3
|
||||
|
||||
|
||||
def test_masked_input_windows_space(monkeypatch, capsys):
|
||||
seq = iter(["x", "y", " ", "z", "\r"])
|
||||
fake_msvcrt = types.SimpleNamespace(getwch=lambda: next(seq))
|
||||
monkeypatch.setattr(seed_prompt, "msvcrt", fake_msvcrt)
|
||||
monkeypatch.setattr(seed_prompt.sys, "platform", "win32", raising=False)
|
||||
|
||||
result = seed_prompt.masked_input("Password: ")
|
||||
assert result == "xy z"
|
||||
out = capsys.readouterr().out
|
||||
assert out.startswith("Password: ")
|
||||
assert out.count("*") == 4
|
||||
|
||||
|
||||
def test_prompt_seed_words_valid(monkeypatch):
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
m = Mnemonic("english")
|
||||
phrase = m.generate(strength=128)
|
||||
words = phrase.split()
|
||||
|
||||
word_iter = iter(words)
|
||||
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
|
||||
monkeypatch.setattr("builtins.input", lambda *_: "y")
|
||||
|
||||
result = seed_prompt.prompt_seed_words(len(words))
|
||||
assert result == phrase
|
||||
|
||||
|
||||
def test_prompt_seed_words_invalid_word(monkeypatch):
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
m = Mnemonic("english")
|
||||
phrase = m.generate(strength=128)
|
||||
words = phrase.split()
|
||||
# Insert an invalid word for the first entry then the correct one
|
||||
inputs = iter(["invalid"] + words)
|
||||
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
|
||||
monkeypatch.setattr("builtins.input", lambda *_: "y")
|
||||
|
||||
result = seed_prompt.prompt_seed_words(len(words))
|
||||
assert result == phrase
|
@@ -25,6 +25,7 @@ try:
|
||||
update_checksum_file,
|
||||
)
|
||||
from .password_prompt import prompt_for_password
|
||||
from .seed_prompt import masked_input, prompt_seed_words
|
||||
from .input_utils import timed_input
|
||||
from .memory_protection import InMemorySecret
|
||||
from .clipboard import copy_to_clipboard
|
||||
@@ -58,6 +59,8 @@ __all__ = [
|
||||
"exclusive_lock",
|
||||
"shared_lock",
|
||||
"prompt_for_password",
|
||||
"masked_input",
|
||||
"prompt_seed_words",
|
||||
"timed_input",
|
||||
"InMemorySecret",
|
||||
"copy_to_clipboard",
|
||||
|
@@ -34,7 +34,11 @@ class FingerprintManager:
|
||||
self.app_dir = app_dir
|
||||
self.fingerprints_file = self.app_dir / "fingerprints.json"
|
||||
self._ensure_app_directory()
|
||||
self.fingerprints, self.current_fingerprint = self._load_fingerprints()
|
||||
(
|
||||
self.fingerprints,
|
||||
self.current_fingerprint,
|
||||
self.names,
|
||||
) = self._load_fingerprints()
|
||||
|
||||
def get_current_fingerprint_dir(self) -> Optional[Path]:
|
||||
"""
|
||||
@@ -62,25 +66,26 @@ class FingerprintManager:
|
||||
)
|
||||
raise
|
||||
|
||||
def _load_fingerprints(self) -> tuple[list[str], Optional[str]]:
|
||||
"""Return stored fingerprints and the last used fingerprint."""
|
||||
def _load_fingerprints(self) -> tuple[list[str], Optional[str], dict[str, str]]:
|
||||
"""Return stored fingerprints, the last used fingerprint, and name mapping."""
|
||||
try:
|
||||
if self.fingerprints_file.exists():
|
||||
with open(self.fingerprints_file, "r") as f:
|
||||
data = json.load(f)
|
||||
fingerprints = data.get("fingerprints", [])
|
||||
current = data.get("last_used")
|
||||
names = data.get("names", {})
|
||||
logger.debug(
|
||||
f"Loaded fingerprints: {fingerprints} (last used: {current})"
|
||||
)
|
||||
return fingerprints, current
|
||||
return fingerprints, current, names
|
||||
logger.debug(
|
||||
"fingerprints.json not found. Initializing empty fingerprint list."
|
||||
)
|
||||
return [], None
|
||||
return [], None, {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load fingerprints: {e}", exc_info=True)
|
||||
return [], None
|
||||
return [], None, {}
|
||||
|
||||
def _save_fingerprints(self):
|
||||
"""
|
||||
@@ -92,6 +97,7 @@ class FingerprintManager:
|
||||
{
|
||||
"fingerprints": self.fingerprints,
|
||||
"last_used": self.current_fingerprint,
|
||||
"names": self.names,
|
||||
},
|
||||
f,
|
||||
indent=4,
|
||||
@@ -116,6 +122,7 @@ class FingerprintManager:
|
||||
fingerprint = generate_fingerprint(seed_phrase)
|
||||
if fingerprint and fingerprint not in self.fingerprints:
|
||||
self.fingerprints.append(fingerprint)
|
||||
self.names.setdefault(fingerprint, "")
|
||||
self.current_fingerprint = fingerprint
|
||||
self._save_fingerprints()
|
||||
logger.info(f"Fingerprint {fingerprint} added successfully.")
|
||||
@@ -144,6 +151,7 @@ class FingerprintManager:
|
||||
if fingerprint in self.fingerprints:
|
||||
try:
|
||||
self.fingerprints.remove(fingerprint)
|
||||
self.names.pop(fingerprint, None)
|
||||
if self.current_fingerprint == fingerprint:
|
||||
self.current_fingerprint = (
|
||||
self.fingerprints[0] if self.fingerprints else None
|
||||
@@ -198,6 +206,26 @@ class FingerprintManager:
|
||||
logger.error(f"Fingerprint {fingerprint} not found.")
|
||||
return False
|
||||
|
||||
def set_name(self, fingerprint: str, name: str | None) -> bool:
|
||||
"""Set a custom name for a fingerprint."""
|
||||
if fingerprint not in self.fingerprints:
|
||||
return False
|
||||
if name:
|
||||
self.names[fingerprint] = name
|
||||
else:
|
||||
self.names.pop(fingerprint, None)
|
||||
self._save_fingerprints()
|
||||
return True
|
||||
|
||||
def get_name(self, fingerprint: str) -> Optional[str]:
|
||||
"""Return the custom name for ``fingerprint`` if set."""
|
||||
return self.names.get(fingerprint) or None
|
||||
|
||||
def display_name(self, fingerprint: str) -> str:
|
||||
"""Return name and fingerprint for display."""
|
||||
name = self.get_name(fingerprint)
|
||||
return f"{name} ({fingerprint})" if name else fingerprint
|
||||
|
||||
def get_fingerprint_directory(self, fingerprint: str) -> Optional[Path]:
|
||||
"""
|
||||
Retrieves the directory path for a given fingerprint.
|
||||
|
@@ -11,11 +11,10 @@ this module enhances code reuse, security, and maintainability across the applic
|
||||
Ensure that all dependencies are installed and properly configured in your environment.
|
||||
"""
|
||||
|
||||
import getpass
|
||||
from utils.seed_prompt import masked_input
|
||||
import logging
|
||||
import sys
|
||||
import unicodedata
|
||||
import traceback
|
||||
|
||||
from termcolor import colored
|
||||
from colorama import init as colorama_init
|
||||
@@ -53,8 +52,8 @@ def prompt_new_password() -> str:
|
||||
|
||||
while attempts < max_retries:
|
||||
try:
|
||||
password = getpass.getpass(prompt="Enter a new password: ").strip()
|
||||
confirm_password = getpass.getpass(prompt="Confirm your password: ").strip()
|
||||
password = masked_input("Enter a new password: ").strip()
|
||||
confirm_password = masked_input("Confirm your password: ").strip()
|
||||
|
||||
if not password:
|
||||
print(
|
||||
@@ -128,7 +127,7 @@ def prompt_existing_password(
|
||||
attempts = 0
|
||||
while attempts < max_retries:
|
||||
try:
|
||||
password = getpass.getpass(prompt=prompt_message).strip()
|
||||
password = masked_input(prompt_message).strip()
|
||||
|
||||
if not password:
|
||||
print(
|
||||
|
152
src/utils/seed_prompt.py
Normal file
152
src/utils/seed_prompt.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import msvcrt # type: ignore
|
||||
except ImportError: # pragma: no cover - Windows only
|
||||
msvcrt = None # type: ignore
|
||||
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
except ImportError: # pragma: no cover - POSIX only
|
||||
termios = None # type: ignore
|
||||
tty = None # type: ignore
|
||||
|
||||
from utils.terminal_utils import clear_screen
|
||||
|
||||
|
||||
def _masked_input_windows(prompt: str) -> str:
|
||||
"""Windows implementation using ``msvcrt``."""
|
||||
if msvcrt is None: # pragma: no cover - should not happen
|
||||
return input(prompt)
|
||||
|
||||
sys.stdout.write(prompt)
|
||||
sys.stdout.flush()
|
||||
buffer: list[str] = []
|
||||
while True:
|
||||
ch = msvcrt.getwch()
|
||||
if ch in ("\r", "\n"):
|
||||
sys.stdout.write("\n")
|
||||
return "".join(buffer)
|
||||
if ch in ("\b", "\x7f"):
|
||||
if buffer:
|
||||
buffer.pop()
|
||||
sys.stdout.write("\b \b")
|
||||
else:
|
||||
buffer.append(ch)
|
||||
sys.stdout.write("*")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _masked_input_posix(prompt: str) -> str:
|
||||
"""POSIX implementation using ``termios`` and ``tty``."""
|
||||
if termios is None or tty is None: # pragma: no cover - should not happen
|
||||
return input(prompt)
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
sys.stdout.write(prompt)
|
||||
sys.stdout.flush()
|
||||
buffer: list[str] = []
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
while True:
|
||||
ch = sys.stdin.read(1)
|
||||
if ch in ("\r", "\n"):
|
||||
sys.stdout.write("\n")
|
||||
return "".join(buffer)
|
||||
if ch in ("\x7f", "\b"):
|
||||
if buffer:
|
||||
buffer.pop()
|
||||
sys.stdout.write("\b \b")
|
||||
else:
|
||||
buffer.append(ch)
|
||||
sys.stdout.write("*")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
|
||||
def masked_input(prompt: str) -> str:
|
||||
"""Return input from the user while masking typed characters."""
|
||||
if sys.platform == "win32":
|
||||
return _masked_input_windows(prompt)
|
||||
return _masked_input_posix(prompt)
|
||||
|
||||
|
||||
def prompt_seed_words(count: int = 12) -> str:
|
||||
"""Prompt the user for a BIP-39 seed phrase.
|
||||
|
||||
The user is asked for each word one at a time. A numbered list is
|
||||
displayed showing ``*`` for entered words and ``_`` for words yet to be
|
||||
provided. After all words are entered the user is asked to confirm each
|
||||
word individually. If the user answers ``no`` to a confirmation prompt the
|
||||
word can be re-entered.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
count:
|
||||
Number of words to prompt for. Defaults to ``12``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The complete seed phrase.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the resulting phrase fails ``Mnemonic.check`` validation.
|
||||
"""
|
||||
|
||||
from mnemonic import Mnemonic
|
||||
|
||||
m = Mnemonic("english")
|
||||
words: list[str] = [""] * count
|
||||
|
||||
idx = 0
|
||||
while idx < count:
|
||||
clear_screen()
|
||||
progress = [f"{i+1}: {'*' if w else '_'}" for i, w in enumerate(words)]
|
||||
print("\n".join(progress))
|
||||
entered = masked_input(f"Enter word number {idx+1}: ").strip().lower()
|
||||
if entered not in m.wordlist:
|
||||
print("Invalid word, try again.")
|
||||
continue
|
||||
words[idx] = entered
|
||||
idx += 1
|
||||
|
||||
for i in range(count):
|
||||
while True:
|
||||
clear_screen()
|
||||
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
|
||||
print("\n".join(progress))
|
||||
response = (
|
||||
input(f"Is this the correct word for number {i+1}? {words[i]} (Y/N): ")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if response in ("y", "yes"):
|
||||
break
|
||||
if response in ("n", "no"):
|
||||
while True:
|
||||
clear_screen()
|
||||
progress = [f"{j+1}: {'*' if j < i else '_'}" for j in range(count)]
|
||||
print("\n".join(progress))
|
||||
new_word = (
|
||||
masked_input(f"Re-enter word number {i+1}: ").strip().lower()
|
||||
)
|
||||
if new_word in m.wordlist:
|
||||
words[i] = new_word
|
||||
break
|
||||
print("Invalid word, try again.")
|
||||
# Ask for confirmation again with the new word
|
||||
else:
|
||||
print("Please respond with 'Y' or 'N'.")
|
||||
continue
|
||||
|
||||
phrase = " ".join(words)
|
||||
if not m.check(phrase):
|
||||
raise ValueError("Invalid BIP-39 seed phrase")
|
||||
return phrase
|
@@ -8,6 +8,20 @@ from termcolor import colored
|
||||
from utils.color_scheme import color_text
|
||||
|
||||
|
||||
def format_profile(fingerprint: str | None, pm=None) -> str | None:
|
||||
"""Return display string for a fingerprint with optional custom name."""
|
||||
if not fingerprint:
|
||||
return None
|
||||
if pm and getattr(pm, "fingerprint_manager", None):
|
||||
try:
|
||||
name = pm.fingerprint_manager.get_name(fingerprint)
|
||||
if name:
|
||||
return f"{name} ({fingerprint})"
|
||||
except Exception:
|
||||
pass
|
||||
return fingerprint
|
||||
|
||||
|
||||
def clear_screen() -> None:
|
||||
"""Clear the terminal screen using an ANSI escape code."""
|
||||
print("\033c", end="")
|
||||
@@ -18,16 +32,17 @@ def clear_and_print_fingerprint(
|
||||
breadcrumb: str | None = None,
|
||||
parent_fingerprint: str | None = None,
|
||||
child_fingerprint: str | None = None,
|
||||
pm=None,
|
||||
) -> None:
|
||||
"""Clear the screen and optionally display the current fingerprint and path."""
|
||||
clear_screen()
|
||||
header_fp = None
|
||||
if parent_fingerprint and child_fingerprint:
|
||||
header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}"
|
||||
header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}"
|
||||
elif fingerprint:
|
||||
header_fp = fingerprint
|
||||
header_fp = format_profile(fingerprint, pm)
|
||||
elif parent_fingerprint or child_fingerprint:
|
||||
header_fp = parent_fingerprint or child_fingerprint
|
||||
header_fp = format_profile(parent_fingerprint or child_fingerprint, pm)
|
||||
if header_fp:
|
||||
header = f"Seed Profile: {header_fp}"
|
||||
if breadcrumb:
|
||||
@@ -36,15 +51,15 @@ def clear_and_print_fingerprint(
|
||||
|
||||
|
||||
def clear_and_print_profile_chain(
|
||||
fingerprints: list[str] | None, breadcrumb: str | None = None
|
||||
fingerprints: list[str] | None, breadcrumb: str | None = None, pm=None
|
||||
) -> None:
|
||||
"""Clear the screen and display a chain of fingerprints."""
|
||||
clear_screen()
|
||||
if not fingerprints:
|
||||
return
|
||||
chain = fingerprints[0]
|
||||
chain = format_profile(fingerprints[0], pm)
|
||||
for fp in fingerprints[1:]:
|
||||
chain += f" > Managed Account > {fp}"
|
||||
chain += f" > Managed Account > {format_profile(fp, pm)}"
|
||||
header = f"Seed Profile: {chain}"
|
||||
if breadcrumb:
|
||||
header += f" > {breadcrumb}"
|
||||
@@ -63,11 +78,11 @@ def clear_header_with_notification(
|
||||
clear_screen()
|
||||
header_fp = None
|
||||
if parent_fingerprint and child_fingerprint:
|
||||
header_fp = f"{parent_fingerprint} > Managed Account > {child_fingerprint}"
|
||||
header_fp = f"{format_profile(parent_fingerprint, pm)} > Managed Account > {format_profile(child_fingerprint, pm)}"
|
||||
elif fingerprint:
|
||||
header_fp = fingerprint
|
||||
header_fp = format_profile(fingerprint, pm)
|
||||
elif parent_fingerprint or child_fingerprint:
|
||||
header_fp = parent_fingerprint or child_fingerprint
|
||||
header_fp = format_profile(parent_fingerprint or child_fingerprint, pm)
|
||||
if header_fp:
|
||||
header = f"Seed Profile: {header_fp}"
|
||||
if breadcrumb:
|
||||
|
Reference in New Issue
Block a user