diff --git a/docs/advanced_cli.md b/docs/advanced_cli.md index 6e5db5f..f48c708 100644 --- a/docs/advanced_cli.md +++ b/docs/advanced_cli.md @@ -60,6 +60,7 @@ Manage individual entries within a vault. | Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` | | Archive an entry | `entry archive` | `seedpass entry archive 1` | | Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` | +| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` | ### Vault Commands @@ -139,6 +140,7 @@ Run or stop the local HTTP API. - **`seedpass entry modify `** – Update an entry's label, username, URL or notes. - **`seedpass entry archive `** – Mark an entry as archived so it is hidden from normal lists. - **`seedpass entry unarchive `** – Restore an archived entry. +- **`seedpass entry export-totp --file `** – Export all stored TOTP secrets to a JSON file. Example retrieving a TOTP code: diff --git a/docs/api_reference.md b/docs/api_reference.md index 7cc14e2..658f25a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -27,6 +27,7 @@ Keep this token secret. Every request must include it in the `Authorization` hea - `POST /api/v1/fingerprint` – Add a new seed fingerprint. - `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint. - `POST /api/v1/fingerprint/select` – Switch the active fingerprint. +- `GET /api/v1/totp/export` – Export all TOTP entries as JSON. - `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. diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 260909e..b8c9bcb 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -597,6 +597,35 @@ class EntryManager: period = int(entry.get("period", 30)) return TotpManager.time_remaining(period) + def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]: + """Return all TOTP secrets and metadata for external use.""" + data = self.vault.load_index() + entries = data.get("entries", {}) + exported: list[dict[str, Any]] = [] + for entry in entries.values(): + etype = entry.get("type", entry.get("kind")) + if etype != EntryType.TOTP.value: + continue + label = entry.get("label", "") + period = int(entry.get("period", 30)) + digits = int(entry.get("digits", 6)) + if "secret" in entry: + secret = entry["secret"] + else: + idx = int(entry.get("index", 0)) + secret = TotpManager.derive_secret(parent_seed, idx) + uri = TotpManager.make_otpauth_uri(label, secret, period, digits) + exported.append( + { + "label": label, + "secret": secret, + "period": period, + "digits": digits, + "uri": uri, + } + ) + return {"entries": exported} + def get_encrypted_index(self) -> Optional[bytes]: """ Retrieves the encrypted password index file's contents. diff --git a/src/seedpass/api.py b/src/seedpass/api.py index f05ba8e..d156438 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -306,6 +306,14 @@ def select_fingerprint( return {"status": "ok"} +@app.get("/api/v1/totp/export") +def export_totp(authorization: str | None = Header(None)) -> dict: + """Return all stored TOTP entries in JSON format.""" + _check_token(authorization) + assert _pm is not None + return _pm.entry_manager.export_totp_entries(_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 3d72b8f..cfa4a2c 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -1,5 +1,6 @@ from pathlib import Path from typing import Optional +import json import typer @@ -314,6 +315,17 @@ def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: typer.echo(str(entry_id)) +@entry_app.command("export-totp") +def entry_export_totp( + ctx: typer.Context, file: str = typer.Option(..., help="Output file") +) -> None: + """Export all TOTP secrets to a JSON file.""" + pm = _get_pm(ctx) + data = pm.entry_manager.export_totp_entries(pm.parent_seed) + Path(file).write_text(json.dumps(data, indent=2)) + typer.echo(str(file)) + + @vault_app.command("export") def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 31ed814..9e7fff0 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -112,6 +112,16 @@ def test_update_config_secret_mode(client): assert called["val"] is True +def test_totp_export_endpoint(client): + cl, token = client + api._pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]} + api._pm.parent_seed = "seed" + headers = {"Authorization": f"Bearer {token}"} + res = cl.get("/api/v1/totp/export", headers=headers) + assert res.status_code == 200 + assert res.json() == {"entries": ["x"]} + + 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 0b8d73b..9fb0f84 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -353,6 +353,26 @@ def test_entry_unarchive(monkeypatch): assert called["id"] == 4 +def test_entry_export_totp(monkeypatch, tmp_path): + called = {} + + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + export_totp_entries=lambda seed: called.setdefault("called", True) + or {"entries": []} + ), + parent_seed="seed", + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + + out = tmp_path / "t.json" + result = runner.invoke(app, ["entry", "export-totp", "--file", str(out)]) + assert result.exit_code == 0 + assert out.exists() + assert called.get("called") is True + + def test_verify_checksum_command(monkeypatch): called = {}