mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Add TOTP export via CLI and API
This commit is contained in:
@@ -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:
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
|
@@ -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 = {}
|
||||
|
@@ -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 = {}
|
||||
|
||||
|
Reference in New Issue
Block a user