Add TOTP export via CLI and API

This commit is contained in:
thePR0M3TH3AN
2025-07-09 17:09:27 -04:00
parent a54fc3658e
commit 4f810ccbc3
7 changed files with 82 additions and 0 deletions

View File

@@ -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 <id>`** Update an entry's label, username, URL or notes.
- **`seedpass entry archive <id>`** Mark an entry as archived so it is hidden from normal lists.
- **`seedpass entry unarchive <id>`** Restore an archived entry.
- **`seedpass entry export-totp --file <path>`** Export all stored TOTP secrets to a JSON file.
Example retrieving a TOTP code:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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")

View File

@@ -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 = {}

View File

@@ -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 = {}