mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 14:58:56 +00:00
Merge pull request #827 from PR0M3TH3AN/codex/add-backup-restoration-feature
feat: add startup backup restore option
This commit is contained in:
14
README.md
14
README.md
@@ -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 12‑word 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 BIP‑85 seed phrase when asked.
|
2. Paste your BIP‑85 seed phrase when asked.
|
||||||
3. Enter the master password associated with that seed.
|
3. Enter the master password associated with that seed.
|
||||||
|
41
src/main.py
41
src/main.py
@@ -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)
|
||||||
|
|
||||||
|
@@ -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."""
|
||||||
|
56
src/tests/test_backup_restore_startup.py
Normal file
56
src/tests/test_backup_restore_startup.py
Normal 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"
|
Reference in New Issue
Block a user