mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 07:48:57 +00:00
Merge pull request #438 from PR0M3TH3AN/codex/add-totp-export-command-and-endpoint
Add CLI and API support for exporting TOTP entries
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` |
|
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
|
||||||
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
||||||
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 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
|
### 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 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 archive <id>`** – Mark an entry as archived so it is hidden from normal lists.
|
||||||
- **`seedpass entry unarchive <id>`** – Restore an archived entry.
|
- **`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:
|
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.
|
- `POST /api/v1/fingerprint` – Add a new seed fingerprint.
|
||||||
- `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint.
|
- `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint.
|
||||||
- `POST /api/v1/fingerprint/select` – Switch the active 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.
|
- `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/verify` – Verify the checksum of the running script.
|
||||||
- `POST /api/v1/checksum/update` – Update the stored script checksum.
|
- `POST /api/v1/checksum/update` – Update the stored script checksum.
|
||||||
|
@@ -597,6 +597,35 @@ class EntryManager:
|
|||||||
period = int(entry.get("period", 30))
|
period = int(entry.get("period", 30))
|
||||||
return TotpManager.time_remaining(period)
|
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]:
|
def get_encrypted_index(self) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
Retrieves the encrypted password index file's contents.
|
Retrieves the encrypted password index file's contents.
|
||||||
|
@@ -306,6 +306,14 @@ def select_fingerprint(
|
|||||||
return {"status": "ok"}
|
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")
|
@app.get("/api/v1/nostr/pubkey")
|
||||||
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
|
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
|
||||||
_check_token(authorization)
|
_check_token(authorization)
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -314,6 +315,17 @@ def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
|
|||||||
typer.echo(str(entry_id))
|
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")
|
@vault_app.command("export")
|
||||||
def vault_export(
|
def vault_export(
|
||||||
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
|
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
|
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):
|
def test_fingerprint_endpoints(client):
|
||||||
cl, token = client
|
cl, token = client
|
||||||
calls = {}
|
calls = {}
|
||||||
|
@@ -353,6 +353,26 @@ def test_entry_unarchive(monkeypatch):
|
|||||||
assert called["id"] == 4
|
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):
|
def test_verify_checksum_command(monkeypatch):
|
||||||
called = {}
|
called = {}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user