Merge pull request #827 from PR0M3TH3AN/codex/add-backup-restoration-feature

feat: add startup backup restore option
This commit is contained in:
thePR0M3TH3AN
2025-08-19 09:04:29 -04:00
committed by GitHub
4 changed files with 135 additions and 1 deletions

View File

@@ -435,6 +435,16 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th
``` ```
*(or `python src/main.py` when running directly from the repository)* *(or `python src/main.py` when running directly from the repository)*
To restore a previously backed up index at launch, provide the backup path
and fingerprint:
```bash
seedpass --restore-backup /path/to/backup.json.enc --fingerprint <fp>
```
Without the flag, the startup prompt offers a **Restore from backup** option
before the vault is initialized.
2. **Follow the Prompts:** 2. **Follow the Prompts:**
- **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one. - **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one.
@@ -620,6 +630,10 @@ initial setup. You must provide both your 12word master seed and the master
password that encrypted the vault; without the correct password the retrieved password that encrypted the vault; without the correct password the retrieved
data cannot be decrypted. data cannot be decrypted.
Alternatively, a local backup file can be loaded at startup. Launch the
application with `--restore-backup <file> --fingerprint <fp>` or choose the
**Restore from backup** option presented before the vault initializes.
1. Start SeedPass and choose option **4** when prompted to set up a seed. 1. Start SeedPass and choose option **4** when prompted to set up a seed.
2. Paste your BIP85 seed phrase when asked. 2. Paste your BIP85 seed phrase when asked.
3. Enter the master password associated with that seed. 3. Enter the master password associated with that seed.

View File

@@ -19,7 +19,7 @@ from termcolor import colored
from utils.color_scheme import color_text from utils.color_scheme import color_text
import importlib import importlib
from seedpass.core.manager import PasswordManager from seedpass.core.manager import PasswordManager, restore_backup_index
from nostr.client import NostrClient from nostr.client import NostrClient
from seedpass.core.entry_types import EntryType from seedpass.core.entry_types import EntryType
from seedpass.core.config_manager import ConfigManager from seedpass.core.config_manager import ConfigManager
@@ -1285,6 +1285,10 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
load_global_config() load_global_config()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint") parser.add_argument("--fingerprint")
parser.add_argument(
"--restore-backup",
help="Restore index from backup file before starting",
)
parser.add_argument( parser.add_argument(
"--no-clipboard", "--no-clipboard",
action="store_true", action="store_true",
@@ -1315,6 +1319,41 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.restore_backup:
fp_target = args.fingerprint or fingerprint
if fp_target is None:
print(
colored(
"Error: --fingerprint is required when using --restore-backup.",
"red",
)
)
return 1
try:
restore_backup_index(Path(args.restore_backup), fp_target)
logger.info("Restored backup from %s", args.restore_backup)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
elif args.command is None:
print("Startup Options:")
print("1. Continue")
print("2. Restore from backup")
choice = input("Select an option: ").strip()
if choice == "2":
path = input("Enter backup file path: ").strip()
fp_target = args.fingerprint or fingerprint
if fp_target is None:
fp_target = input("Enter fingerprint for restore: ").strip()
try:
restore_backup_index(Path(path), fp_target)
logger.info("Restored backup from %s", path)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
if args.max_prompt_attempts is not None: if args.max_prompt_attempts is not None:
os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts) os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts)

View File

@@ -158,6 +158,31 @@ def calculate_profile_id(seed: str) -> str:
return fp or "" return fp or ""
def restore_backup_index(path: Path, fingerprint: str) -> None:
"""Restore the encrypted index for ``fingerprint`` from ``path``.
This helper is intended for use before full :class:`PasswordManager`
initialization. It simply copies the provided backup file into the
profile's directory, replacing the existing index if present. A copy of
the previous index is kept with a ``.bak`` suffix to allow manual
recovery if needed.
"""
fingerprint_dir = APP_DIR / fingerprint
fingerprint_dir.mkdir(parents=True, exist_ok=True)
dest = fingerprint_dir / "seedpass_entries_db.json.enc"
src = Path(path)
# Ensure the source file can be read
src.read_bytes()
if dest.exists():
shutil.copy2(dest, dest.with_suffix(".bak"))
shutil.copy2(src, dest)
os.chmod(dest, 0o600)
@dataclass @dataclass
class Notification: class Notification:
"""Simple message container for UI notifications.""" """Simple message container for UI notifications."""

View File

@@ -0,0 +1,56 @@
import main
from pathlib import Path
def test_cli_flag_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
rc = main.main(["--fingerprint", "fp", "--restore-backup", str(backup)])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"
def test_menu_option_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
inputs = iter(["2", str(backup)])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs))
rc = main.main(["--fingerprint", "fp"])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"