Add key field to key/value entries

This commit is contained in:
thePR0M3TH3AN
2025-07-28 15:04:56 -04:00
parent d3f2cb8256
commit 4a20817094
17 changed files with 71 additions and 32 deletions

View File

@@ -458,7 +458,7 @@ The table below summarizes the extra fields stored for each entry type. Every en
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
### Managing Multiple Seeds

View File

@@ -55,7 +55,7 @@ Manage individual entries within a vault.
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --key api --value abc123` |
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
@@ -144,7 +144,7 @@ Run or stop the local HTTP API.
- **`seedpass entry add-pgp <label>`** Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
- **`seedpass entry add-nostr <label>`** Create a Nostr key entry for decentralised chat.
- **`seedpass entry add-seed <label>`** Store a derived seed phrase. Use `--words` to set the word count.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--value`.
- **`seedpass entry add-key-value <label>`** Store arbitrary data with `--key` and `--value`.
- **`seedpass entry add-managed-account <label>`** Store a BIP85 derived account seed.
- **`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.

View File

@@ -95,6 +95,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
- **custom_fields** (`array`, optional): Additional user-defined fields.
- **origin** (`string`, optional): Source identifier for imported data.
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
- **key** (`string`, optional): Name of the key for `key_value` entries.
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.

View File

@@ -363,7 +363,7 @@ entry includes a `label`, while only password entries track a `url`.
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |

View File

@@ -173,6 +173,7 @@ def create_entry(
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
entry.get("label"),
entry.get("key"),
entry.get("value"),
notes=entry.get("notes", ""),
)

View File

@@ -315,12 +315,13 @@ def entry_add_seed(
def entry_add_key_value(
ctx: typer.Context,
label: str,
key: str = typer.Option(..., "--key", help="Key name"),
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_key_value(label, value, notes=notes)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))

View File

@@ -379,9 +379,13 @@ class EntryService:
self._manager.start_background_vault_sync()
return idx
def add_key_value(self, label: str, value: str, *, notes: str = "") -> int:
def add_key_value(
self, label: str, key: str, value: str, *, notes: str = ""
) -> int:
with self._lock:
idx = self._manager.entry_manager.add_key_value(label, value, notes=notes)
idx = self._manager.entry_manager.add_key_value(
label, key, value, notes=notes
)
self._manager.start_background_vault_sync()
return idx

View File

@@ -412,6 +412,7 @@ class EntryManager:
def add_key_value(
self,
label: str,
key: str,
value: str,
*,
notes: str = "",
@@ -429,6 +430,7 @@ class EntryManager:
"type": EntryType.KEY_VALUE.value,
"kind": EntryType.KEY_VALUE.value,
"label": label,
"key": key,
"modified_ts": int(time.time()),
"value": value,
"notes": notes,
@@ -720,6 +722,7 @@ class EntryManager:
label: Optional[str] = None,
period: Optional[int] = None,
digits: Optional[int] = None,
key: Optional[str] = None,
value: Optional[str] = None,
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
@@ -736,6 +739,7 @@ class EntryManager:
:param label: (Optional) The new label for the entry.
:param period: (Optional) The new TOTP period in seconds.
:param digits: (Optional) The new number of digits for TOTP codes.
:param key: (Optional) New key for key/value entries.
:param value: (Optional) New value for key/value entries.
"""
try:
@@ -764,6 +768,7 @@ class EntryManager:
"label": label,
"period": period,
"digits": digits,
"key": key,
"value": value,
"custom_fields": custom_fields,
"tags": tags,
@@ -790,6 +795,7 @@ class EntryManager:
},
EntryType.KEY_VALUE.value: {
"label",
"key",
"value",
"archived",
"notes",
@@ -870,6 +876,9 @@ class EntryManager:
EntryType.KEY_VALUE.value,
EntryType.MANAGED_ACCOUNT.value,
):
if key is not None and entry_type == EntryType.KEY_VALUE.value:
entry["key"] = key
logger.debug(f"Updated key for index {index}.")
if value is not None:
entry["value"] = value
logger.debug(f"Updated value for index {index}.")

View File

@@ -1888,6 +1888,10 @@ class PasswordManager:
if not label:
print(colored("Error: Label cannot be empty.", "red"))
return
key_field = input("Key: ").strip()
if not key_field:
print(colored("Error: Key cannot be empty.", "red"))
return
value = input("Value: ").strip()
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
@@ -1915,6 +1919,7 @@ class PasswordManager:
index = self.entry_manager.add_key_value(
label,
key_field,
value,
notes=notes,
custom_fields=custom_fields,
@@ -2886,6 +2891,9 @@ class PasswordManager:
input(f'Enter new label (leave blank to keep "{label}"): ').strip()
or label
)
new_key = input(
f'Enter new key (leave blank to keep "{entry.get("key", "")}"): '
).strip() or entry.get("key", "")
new_value = (
input("Enter new value (leave blank to keep current): ").strip()
or value
@@ -2947,6 +2955,7 @@ class PasswordManager:
archived=new_blacklisted,
notes=new_notes,
label=new_label,
key=new_key,
value=new_value,
custom_fields=custom_fields,
tags=tags,
@@ -3237,7 +3246,8 @@ class PasswordManager:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.KEY_VALUE.value:
print(color_text(" Type: Key/Value", "index"))
print(color_text(f" Label (key): {entry.get('label', '')}", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
print(color_text(f" Key: {entry.get('key', '')}", "index"))
print(color_text(f" Value: {entry.get('value', '')}", "index"))
notes = entry.get("notes", "")
if notes:

View File

@@ -217,6 +217,7 @@ class EntryDialog(toga.Window):
self.length_input = toga.NumberInput(
min=8, max=128, style=Pack(width=80), value=16
)
self.key_input = toga.TextInput(style=Pack(flex=1))
self.value_input = toga.TextInput(style=Pack(flex=1))
save_button = toga.Button(
@@ -234,6 +235,8 @@ class EntryDialog(toga.Window):
box.add(self.url_input)
box.add(toga.Label("Length"))
box.add(self.length_input)
box.add(toga.Label("Key"))
box.add(self.key_input)
box.add(toga.Label("Value"))
box.add(self.value_input)
box.add(save_button)
@@ -249,6 +252,7 @@ class EntryDialog(toga.Window):
self.username_input.value = entry.get("username", "") or ""
self.url_input.value = entry.get("url", "") or ""
self.length_input.value = entry.get("length", 16)
self.key_input.value = entry.get("key", "")
self.value_input.value = entry.get("value", "")
def save(self, widget: toga.Widget) -> None:
@@ -257,6 +261,7 @@ class EntryDialog(toga.Window):
url = self.url_input.value or None
length = int(self.length_input.value or 16)
kind = self.kind_input.value
key = self.key_input.value or None
value = self.value_input.value or None
if self.entry_id is None:
@@ -275,7 +280,9 @@ class EntryDialog(toga.Window):
elif kind == EntryType.NOSTR.value:
entry_id = self.main.entries.add_nostr_key(label)
elif kind == EntryType.KEY_VALUE.value:
entry_id = self.main.entries.add_key_value(label, value or "")
entry_id = self.main.entries.add_key_value(
label, key or "", value or ""
)
elif kind == EntryType.MANAGED_ACCOUNT.value:
entry_id = self.main.entries.add_managed_account(label)
else:
@@ -284,7 +291,7 @@ class EntryDialog(toga.Window):
if kind == EntryType.PASSWORD.value:
kwargs.update({"username": username, "url": url})
elif kind == EntryType.KEY_VALUE.value:
kwargs.update({"value": value})
kwargs.update({"key": key, "value": value})
self.main.entries.modify_entry(entry_id, **kwargs)
entry = self.main.entries.retrieve_entry(entry_id) or {}

View File

@@ -26,7 +26,7 @@ class DummyPM:
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
add_nostr_key=lambda label, index=None, notes="": 4,
add_seed=lambda label, seed, index=None, words_num=24, notes="": 5,
add_key_value=lambda label, value, notes="": 6,
add_key_value=lambda label, key, value, notes="": 6,
add_managed_account=lambda label, seed, index=None, notes="": 7,
modify_entry=lambda *a, **kw: None,
archive_entry=lambda i: None,

View File

@@ -90,8 +90,8 @@ runner = CliRunner()
(
"add-key-value",
"add_key_value",
["Label", "--value", "val", "--notes", "note"],
("Label", "val"),
["Label", "--key", "k1", "--value", "val", "--notes", "note"],
("Label", "k1", "val"),
{"notes": "note"},
"7",
),

View File

@@ -73,7 +73,7 @@ def test_round_trip_entry_types(method, expected_type):
entry_mgr.add_totp("example", TEST_SEED)
index = 0
elif method == "add_key_value":
index = entry_mgr.add_key_value("label", "val")
index = entry_mgr.add_key_value("label", "k1", "val")
else:
if method == "add_ssh_key":
index = entry_mgr.add_ssh_key("ssh", TEST_SEED)
@@ -118,7 +118,7 @@ def test_legacy_entry_defaults_to_password():
("add_pgp_key", ("pgp", TEST_SEED)),
("add_nostr_key", ("nostr",)),
("add_seed", ("seed", TEST_SEED)),
("add_key_value", ("label", "val")),
("add_key_value", ("label", "k1", "val")),
("add_managed_account", ("acct", TEST_SEED)),
],
)

View File

@@ -53,16 +53,18 @@ class FakeEntries:
self.added.append(("nostr", label))
return 1
def add_key_value(self, label, value):
self.added.append(("key_value", label, value))
def add_key_value(self, label, key, value):
self.added.append(("key_value", label, key, value))
return 1
def add_managed_account(self, label):
self.added.append(("managed_account", label))
return 1
def modify_entry(self, entry_id, username=None, url=None, label=None, value=None):
self.modified.append((entry_id, username, url, label, value))
def modify_entry(
self, entry_id, username=None, url=None, label=None, key=None, value=None
):
self.modified.append((entry_id, username, url, label, key, value))
def setup_module(module):
@@ -106,7 +108,7 @@ def test_unlock_creates_main_window():
(EntryType.SEED.value, ("seed", "L")),
(EntryType.PGP.value, ("pgp", "L")),
(EntryType.NOSTR.value, ("nostr", "L")),
(EntryType.KEY_VALUE.value, ("key_value", "L", "val")),
(EntryType.KEY_VALUE.value, ("key_value", "L", "k1", "val")),
(EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")),
],
)
@@ -123,6 +125,7 @@ def test_entrydialog_add_calls_service(kind, expect):
dlg.username_input.value = "u"
dlg.url_input.value = "x"
dlg.length_input.value = 12
dlg.key_input.value = "k1"
dlg.value_input.value = "val"
dlg.save(None)
@@ -136,9 +139,9 @@ def test_entrydialog_add_calls_service(kind, expect):
@pytest.mark.parametrize(
"kind,expected",
[
(EntryType.PASSWORD.value, (1, "newu", "newx", "New", None)),
(EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")),
(EntryType.TOTP.value, (1, None, None, "New", None)),
(EntryType.PASSWORD.value, (1, "newu", "newx", "New", None, None)),
(EntryType.KEY_VALUE.value, (1, None, None, "New", "k2", "val2")),
(EntryType.TOTP.value, (1, None, None, "New", None, None)),
],
)
def test_entrydialog_edit_calls_service(kind, expected):
@@ -157,6 +160,7 @@ def test_entrydialog_edit_calls_service(kind, expected):
dlg.kind_input.value = kind
dlg.username_input.value = "newu"
dlg.url_input.value = "newx"
dlg.key_input.value = "k2"
dlg.value_input.value = "val2"
dlg.save(None)

View File

@@ -23,12 +23,13 @@ def test_add_and_modify_key_value():
tmp_path = Path(tmpdir)
em = setup_entry_mgr(tmp_path)
idx = em.add_key_value("API", "abc123", notes="token")
idx = em.add_key_value("API entry", "api_key", "abc123", notes="token")
entry = em.retrieve_entry(idx)
assert entry == {
"type": "key_value",
"kind": "key_value",
"label": "API",
"label": "API entry",
"key": "api_key",
"value": "abc123",
"notes": "token",
"archived": False,
@@ -36,8 +37,9 @@ def test_add_and_modify_key_value():
"tags": [],
}
em.modify_entry(idx, value="def456")
em.modify_entry(idx, key="api_key2", value="def456")
updated = em.retrieve_entry(idx)
assert updated["key"] == "api_key2"
assert updated["value"] == "def456"
results = em.search_entries("def456")

View File

@@ -38,7 +38,7 @@ def test_handle_list_entries(monkeypatch, capsys):
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_entry("example.com", 12)
entry_mgr.add_key_value("API", "abc123")
entry_mgr.add_key_value("API entry", "api", "abc123")
entry_mgr.add_managed_account("acct", TEST_SEED)
inputs = iter(["1", ""]) # list all, then exit
@@ -72,7 +72,7 @@ def test_list_entries_show_details(monkeypatch, capsys):
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED)
entry_mgr.add_key_value("API", "val")
entry_mgr.add_key_value("API entry", "api", "val")
entry_mgr.add_managed_account("acct", TEST_SEED)
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
@@ -353,7 +353,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
)
expected = "123456"
elif entry_type == "key_value":
idx = entry_mgr.add_key_value("API", "abc")
idx = entry_mgr.add_key_value("API entry", "api", "abc")
expected = "abc"
else: # managed_account
idx = entry_mgr.add_managed_account("acct", TEST_SEED)
@@ -390,8 +390,8 @@ def test_show_entry_details_with_enum_type(monkeypatch, capsys, entry_type):
)
expect = "Label: Example"
else: # KEY_VALUE
idx = entry_mgr.add_key_value("API", "abc")
expect = "API"
idx = entry_mgr.add_key_value("API entry", "api", "abc")
expect = "API entry"
data = entry_mgr._load_index(force_reload=True)
data["entries"][str(idx)]["type"] = entry_type

View File

@@ -93,7 +93,7 @@ def test_search_key_value_value():
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
idx = entry_mgr.add_key_value("API", "token123")
idx = entry_mgr.add_key_value("API entry", "api", "token123")
result = entry_mgr.search_entries("token123")
assert result == []