diff --git a/README.md b/README.md index deb69b8..92cb53c 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,7 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an - **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss. - **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays. - **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique. +- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment. - **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt. - **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications. - **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information. diff --git a/docs/api_reference.md b/docs/api_reference.md index 6de61d5..14eba27 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -29,6 +29,7 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `POST /api/v1/fingerprint/select` – Switch the active fingerprint. - `GET /api/v1/totp/export` – Export all TOTP entries as JSON. - `GET /api/v1/totp` – Return current TOTP codes and remaining time. +- `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`. - `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed. - `POST /api/v1/checksum/verify` – Verify the checksum of the running script. - `POST /api/v1/checksum/update` – Update the stored script checksum. @@ -36,6 +37,8 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `POST /api/v1/vault/import` – Import a vault backup from a file or path. - `POST /api/v1/shutdown` – Stop the server gracefully. +**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment. + ## Example Requests Send requests with the token in the header: diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 7b4a010..56a151a 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -3309,9 +3309,15 @@ class PasswordManager: print(colored(f"Error: Failed to export 2FA codes: {e}", "red")) return None - def handle_backup_reveal_parent_seed(self) -> None: - """ - Handles the backup and reveal of the parent seed. + def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None: + """Reveal the parent seed and optionally save an encrypted backup. + + Parameters + ---------- + file: + Optional path where an encrypted backup should be written. When + provided, the confirmation and filename prompts are skipped and the + seed is saved directly to this location. """ try: fp, parent_fp, child_fp = self.header_fingerprint_args @@ -3360,24 +3366,26 @@ class PasswordManager: ) ) - # Option to save to file with default filename - if confirm_action( - "Do you want to save this to an encrypted backup file? (Y/N): " - ): - filename = input( - f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): " - ).strip() - filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME - backup_path = ( - self.fingerprint_dir / filename - ) # Save in fingerprint directory + backup_path: Path | None = None + if file is not None: + backup_path = file + save = True + else: + save = confirm_action( + "Do you want to save this to an encrypted backup file? (Y/N): " + ) + if save: + filename = input( + f"Enter filename to save (default: {DEFAULT_SEED_BACKUP_FILENAME}): " + ).strip() + filename = filename if filename else DEFAULT_SEED_BACKUP_FILENAME + backup_path = self.fingerprint_dir / filename - # Validate filename - if not self.is_valid_filename(filename): + if save and backup_path is not None: + if not self.is_valid_filename(backup_path.name): print(colored("Invalid filename. Operation aborted.", "red")) return - # Encrypt and save the parent seed to the backup path self.encryption_manager.encrypt_and_save_file( self.parent_seed.encode("utf-8"), backup_path ) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index a5356ba..5963343 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -333,6 +333,22 @@ def get_totp_codes(authorization: str | None = Header(None)) -> dict: return {"codes": codes} +@app.get("/api/v1/parent-seed") +def get_parent_seed( + authorization: str | None = Header(None), file: str | None = None +) -> dict: + """Return the parent seed or save it as an encrypted backup.""" + _check_token(authorization) + assert _pm is not None + if file: + path = Path(file) + _pm.encryption_manager.encrypt_and_save_file( + _pm.parent_seed.encode("utf-8"), path + ) + return {"status": "saved", "path": str(path)} + return {"seed": _pm.parent_seed} + + @app.get("/api/v1/nostr/pubkey") def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any: _check_token(authorization) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 541b4ec..c0a9110 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -360,6 +360,18 @@ def vault_change_password(ctx: typer.Context) -> None: pm.change_password() +@vault_app.command("reveal-parent-seed") +def vault_reveal_parent_seed( + ctx: typer.Context, + file: Optional[str] = typer.Option( + None, "--file", help="Save encrypted seed to this path" + ), +) -> None: + """Display the parent seed and optionally write an encrypted backup file.""" + pm = _get_pm(ctx) + pm.handle_backup_reveal_parent_seed(Path(file) if file else None) + + @nostr_app.command("sync") def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 564df02..1e62ec8 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -138,6 +138,26 @@ def test_totp_codes_endpoint(client): } +def test_parent_seed_endpoint(client, tmp_path): + cl, token = client + api._pm.parent_seed = "seed" + called = {} + api._pm.encryption_manager = SimpleNamespace( + encrypt_and_save_file=lambda data, path: called.setdefault("path", path) + ) + headers = {"Authorization": f"Bearer {token}"} + + res = cl.get("/api/v1/parent-seed", headers=headers) + assert res.status_code == 200 + assert res.json() == {"seed": "seed"} + + out = tmp_path / "bk.enc" + res = cl.get("/api/v1/parent-seed", params={"file": str(out)}, headers=headers) + assert res.status_code == 200 + assert res.json() == {"status": "saved", "path": str(out)} + assert called["path"] == out + + def test_fingerprint_endpoints(client): cl, token = client calls = {} diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 1843a8c..7b15867 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -111,6 +111,24 @@ def test_vault_change_password(monkeypatch): assert called.get("called") is True +def test_vault_reveal_parent_seed(monkeypatch, tmp_path): + called = {} + + def reveal(path=None): + called["path"] = path + + pm = SimpleNamespace( + handle_backup_reveal_parent_seed=reveal, select_fingerprint=lambda fp: None + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + out_path = tmp_path / "seed.enc" + result = runner.invoke( + app, ["vault", "reveal-parent-seed", "--file", str(out_path)] + ) + assert result.exit_code == 0 + assert called["path"] == out_path + + def test_nostr_get_pubkey(monkeypatch): pm = SimpleNamespace( nostr_client=SimpleNamespace(