Merge pull request #414 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-08 21:27:21 -04:00
committed by GitHub
26 changed files with 748 additions and 926 deletions

View File

@@ -10,7 +10,7 @@
**⚠️ Disclaimer**
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information.
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
---
### Supported OS
@@ -53,6 +53,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
- **AutoLock on Inactivity:** Vault locks after a configurable timeout for additional security.
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
## Prerequisites
@@ -172,6 +173,7 @@ seedpass import --file "~/seedpass_backup.json"
# Quickly find or retrieve entries
seedpass search "github"
seedpass search --tags "work,personal"
seedpass get "github"
seedpass totp "email"
# The code is printed and copied to your clipboard
@@ -265,9 +267,10 @@ When choosing **Add Entry**, you can now select from:
1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit.
2. SeedPass will show the current label, period, digit count, and archived status.
3. Enter new values or press **Enter** to keep the existing settings.
4. The updated entry is saved back to your encrypted vault.
5. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu.
6. When editing an archived entry you'll be prompted to restore it after saving your changes.
4. When retrieving a 2FA entry you can press **E** to edit the label, period or digit count, or **A** to archive/unarchive it.
5. The updated entry is saved back to your encrypted vault.
6. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu.
7. When editing an archived entry you'll be prompted to restore it after saving your changes.
### Using Secret Mode
@@ -295,14 +298,14 @@ entry includes a `label`, while only password entries track a `url`.
| Entry Type | Extra Fields |
|---------------|---------------------------------------------------------------------------------------------------------------------------------------|
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields) |
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes` |
| SSH Key | `index`, `archived`, optional `notes` |
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes` |
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes` |
| Nostr Key Pair| `index`, `archived`, optional `notes` |
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes` |
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
| 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` |
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
### Managing Multiple Seeds

File diff suppressed because it is too large Load Diff

View File

@@ -77,6 +77,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
"index": 0
}
```
@@ -97,6 +98,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
- **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.
- **tags** (`array`, optional): Category labels to aid in organization and search.
Example:
```json
@@ -126,6 +128,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
"custom_fields": [
{"name": "department", "value": "finance"}
],
"tags": ["work"],
"timestamp": "2024-04-27T12:34:56Z",
"metadata": {
"created_at": "2024-04-27T12:34:56Z",
@@ -250,6 +253,7 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
"key": "api_key",
"value": "<encrypted_value>"
},
"tags": ["api"],
"timestamp": "2024-04-27T12:40:56Z"
}
```

View File

@@ -155,6 +155,7 @@ flowchart TD
<li><i class="fas fa-lock" aria-hidden="true"></i> Auto-lock after inactivity</li>
<li><i class="fas fa-users-cog" aria-hidden="true"></i> Derive nested managed account seeds</li>
<li><i class="fas fa-user-secret" aria-hidden="true"></i> Secret Mode copies passwords to your clipboard</li>
<li><i class="fas fa-tags" aria-hidden="true"></i> Group entries using tags for easy cross-type search</li>
</ul>
</div>
</section>
@@ -204,6 +205,7 @@ Enter your choice (1-7) or press Enter to exit:
<div class="container">
<h2 class="section-title" id="disclaimer-heading">Disclaimer</h2>
<p><strong>⚠️ Disclaimer:</strong> This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Additionally, the security of the program's memory management and logs has not been evaluated and may leak sensitive information.</p>
<p>Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.</p>
<p>Snapshot chunks are limited to 50&nbsp;KB and rotated when deltas accumulate.</p>
</div>
</section>

View File

@@ -79,6 +79,7 @@ class EntryManager:
if "word_count" not in entry and "words" in entry:
entry["word_count"] = entry["words"]
entry.pop("words", None)
entry.setdefault("tags", [])
logger.debug("Index loaded successfully.")
return data
except Exception as e:
@@ -127,6 +128,7 @@ class EntryManager:
archived: bool = False,
notes: str = "",
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
) -> int:
"""
Adds a new entry to the encrypted JSON index file.
@@ -154,6 +156,7 @@ class EntryManager:
"kind": EntryType.PASSWORD.value,
"notes": notes,
"custom_fields": custom_fields or [],
"tags": tags or [],
}
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
@@ -197,6 +200,7 @@ class EntryManager:
period: int = 30,
digits: int = 6,
notes: str = "",
tags: list[str] | None = None,
) -> str:
"""Add a new TOTP entry and return the provisioning URI."""
entry_id = self.get_next_index()
@@ -216,6 +220,7 @@ class EntryManager:
"digits": digits,
"archived": archived,
"notes": notes,
"tags": tags or [],
}
else:
entry = {
@@ -227,6 +232,7 @@ class EntryManager:
"digits": digits,
"archived": archived,
"notes": notes,
"tags": tags or [],
}
data["entries"][str(entry_id)] = entry
@@ -248,6 +254,7 @@ class EntryManager:
index: int | None = None,
notes: str = "",
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new SSH key pair entry.
@@ -268,6 +275,7 @@ class EntryManager:
"label": label,
"notes": notes,
"archived": archived,
"tags": tags or [],
}
self._save_index(data)
self.update_checksum()
@@ -297,6 +305,7 @@ class EntryManager:
user_id: str = "",
notes: str = "",
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new PGP key entry."""
@@ -314,6 +323,7 @@ class EntryManager:
"user_id": user_id,
"notes": notes,
"archived": archived,
"tags": tags or [],
}
self._save_index(data)
self.update_checksum()
@@ -347,6 +357,7 @@ class EntryManager:
index: int | None = None,
notes: str = "",
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new Nostr key pair entry."""
@@ -362,6 +373,7 @@ class EntryManager:
"label": label,
"notes": notes,
"archived": archived,
"tags": tags or [],
}
self._save_index(data)
self.update_checksum()
@@ -376,6 +388,7 @@ class EntryManager:
notes: str = "",
custom_fields=None,
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new generic key/value entry."""
@@ -391,6 +404,7 @@ class EntryManager:
"notes": notes,
"archived": archived,
"custom_fields": custom_fields or [],
"tags": tags or [],
}
self._save_index(data)
@@ -431,6 +445,7 @@ class EntryManager:
words_num: int = 24,
notes: str = "",
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new derived seed phrase entry."""
@@ -447,6 +462,7 @@ class EntryManager:
"word_count": words_num,
"notes": notes,
"archived": archived,
"tags": tags or [],
}
self._save_index(data)
self.update_checksum()
@@ -483,6 +499,7 @@ class EntryManager:
index: int | None = None,
notes: str = "",
archived: bool = False,
tags: list[str] | None = None,
) -> int:
"""Add a new managed account seed entry.
@@ -518,6 +535,7 @@ class EntryManager:
"notes": notes,
"fingerprint": fingerprint,
"archived": archived,
"tags": tags or [],
}
self._save_index(data)
@@ -642,6 +660,7 @@ class EntryManager:
digits: Optional[int] = None,
value: Optional[str] = None,
custom_fields: List[Dict[str, Any]] | None = None,
tags: list[str] | None = None,
**legacy,
) -> None:
"""
@@ -727,6 +746,10 @@ class EntryManager:
f"Updated custom fields for index {index}: {custom_fields}"
)
if tags is not None:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
@@ -890,8 +913,10 @@ class EntryManager:
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
label = entry.get("label", entry.get("website", ""))
notes = entry.get("notes", "")
tags = entry.get("tags", [])
label_match = query_lower in label.lower()
notes_match = query_lower in notes.lower()
tags_match = any(query_lower in str(t).lower() for t in tags)
if etype == EntryType.PASSWORD.value:
username = entry.get("username", "")
@@ -908,6 +933,7 @@ class EntryManager:
or query_lower in url.lower()
or notes_match
or custom_match
or tags_match
):
results.append(
(
@@ -931,6 +957,7 @@ class EntryManager:
or query_lower in value_field.lower()
or notes_match
or custom_match
or tags_match
):
results.append(
(
@@ -942,7 +969,7 @@ class EntryManager:
)
)
else:
if label_match or notes_match:
if label_match or notes_match or tags_match:
results.append(
(
int(idx),

View File

@@ -981,6 +981,12 @@ class PasswordManager:
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
notes = input("Enter notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
custom_fields: list[dict[str, object]] = []
while True:
@@ -1021,6 +1027,7 @@ class PasswordManager:
archived=False,
notes=notes,
custom_fields=custom_fields,
tags=tags,
)
# Mark database as dirty for background sync
@@ -1084,6 +1091,14 @@ class PasswordManager:
)
continue
notes = input("Notes (optional): ").strip()
tags_input = input(
"Enter tags (comma-separated, optional): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
totp_index = self.entry_manager.get_next_totp_index()
entry_id = self.entry_manager.get_next_index()
uri = self.entry_manager.add_totp(
@@ -1093,6 +1108,7 @@ class PasswordManager:
period=int(period),
digits=int(digits),
notes=notes,
tags=tags,
)
secret = TotpManager.derive_secret(self.parent_seed, totp_index)
self.is_dirty = True
@@ -1128,6 +1144,14 @@ class PasswordManager:
period = int(input("Period (default 30): ").strip() or 30)
digits = int(input("Digits (default 6): ").strip() or 6)
notes = input("Notes (optional): ").strip()
tags_input = input(
"Enter tags (comma-separated, optional): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
entry_id = self.entry_manager.get_next_index()
uri = self.entry_manager.add_totp(
label,
@@ -1136,6 +1160,7 @@ class PasswordManager:
period=period,
digits=digits,
notes=notes,
tags=tags,
)
self.is_dirty = True
self.last_update = time.time()
@@ -1181,7 +1206,15 @@ class PasswordManager:
print(colored("Error: Label cannot be empty.", "red"))
return
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_ssh_key(label, self.parent_seed, notes=notes)
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
index = self.entry_manager.add_ssh_key(
label, self.parent_seed, notes=notes, tags=tags
)
priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair(
index, self.parent_seed
)
@@ -1230,12 +1263,18 @@ class PasswordManager:
return
words_input = input("Word count (12 or 24, default 24): ").strip()
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
if words_input and words_input not in {"12", "24"}:
print(colored("Invalid word count. Choose 12 or 24.", "red"))
return
words = int(words_input) if words_input else 24
index = self.entry_manager.add_seed(
label, self.parent_seed, words_num=words, notes=notes
label, self.parent_seed, words_num=words, notes=notes, tags=tags
)
phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed)
self.is_dirty = True
@@ -1296,12 +1335,19 @@ class PasswordManager:
)
user_id = input("User ID (optional): ").strip()
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
index = self.entry_manager.add_pgp_key(
label,
self.parent_seed,
key_type=key_type,
user_id=user_id,
notes=notes,
tags=tags,
)
priv_key, fingerprint = self.entry_manager.get_pgp_key(
index, self.parent_seed
@@ -1350,7 +1396,13 @@ class PasswordManager:
print(colored("Error: Label cannot be empty.", "red"))
return
notes = input("Notes (optional): ").strip()
index = self.entry_manager.add_nostr_key(label, notes=notes)
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
index = self.entry_manager.add_nostr_key(label, notes=notes, tags=tags)
npub, nsec = self.entry_manager.get_nostr_key_pair(index, self.parent_seed)
self.is_dirty = True
self.last_update = time.time()
@@ -1401,6 +1453,12 @@ class PasswordManager:
return
value = input("Value: ").strip()
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
custom_fields: list[dict[str, object]] = []
while True:
@@ -1419,7 +1477,11 @@ class PasswordManager:
)
index = self.entry_manager.add_key_value(
label, value, notes=notes, custom_fields=custom_fields
label,
value,
notes=notes,
custom_fields=custom_fields,
tags=tags,
)
self.is_dirty = True
self.last_update = time.time()
@@ -1465,8 +1527,14 @@ class PasswordManager:
print(colored("Error: Label cannot be empty.", "red"))
return
notes = input("Notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
index = self.entry_manager.add_managed_account(
label, self.parent_seed, notes=notes
label, self.parent_seed, notes=notes, tags=tags
)
seed = self.entry_manager.get_managed_account_seed(index, self.parent_seed)
self.is_dirty = True
@@ -1555,6 +1623,8 @@ class PasswordManager:
print(colored("C. Add Custom Field", "cyan"))
print(colored("H. Add Hidden Field", "cyan"))
print(colored("E. Edit", "cyan"))
print(colored("T. Edit Tags", "cyan"))
print(colored("Q. Show QR codes", "cyan"))
choice = (
input("Select an action or press Enter to return: ").strip().lower()
@@ -1591,8 +1661,29 @@ class PasswordManager:
self.entry_manager.modify_entry(index, custom_fields=custom_fields)
self.is_dirty = True
self.last_update = time.time()
elif choice == "t":
current_tags = entry.get("tags", [])
print(
colored(
f"Current tags: {', '.join(current_tags) if current_tags else 'None'}",
"cyan",
)
)
tags_input = input(
"Enter tags (comma-separated, leave blank to remove all tags): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
self.entry_manager.modify_entry(index, tags=tags)
self.is_dirty = True
self.last_update = time.time()
elif choice == "e":
self._entry_edit_menu(index, entry)
elif choice == "q":
self._entry_qr_menu(index, entry)
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
@@ -1606,6 +1697,9 @@ class PasswordManager:
if entry_type == EntryType.PASSWORD.value:
print(colored("U. Edit Username", "cyan"))
print(colored("R. Edit URL", "cyan"))
elif entry_type == EntryType.TOTP.value:
print(colored("P. Edit Period", "cyan"))
print(colored("D. Edit Digits", "cyan"))
choice = input("Select option or press Enter to go back: ").strip().lower()
if not choice:
break
@@ -1625,10 +1719,79 @@ class PasswordManager:
self.entry_manager.modify_entry(index, url=new_url)
self.is_dirty = True
self.last_update = time.time()
elif entry_type == EntryType.TOTP.value and choice == "p":
period_str = input("New period (seconds): ").strip()
if period_str.isdigit():
self.entry_manager.modify_entry(index, period=int(period_str))
self.is_dirty = True
self.last_update = time.time()
else:
print(colored("Invalid period value.", "red"))
elif entry_type == EntryType.TOTP.value and choice == "d":
digits_str = input("New digits: ").strip()
if digits_str.isdigit():
self.entry_manager.modify_entry(index, digits=int(digits_str))
self.is_dirty = True
self.last_update = time.time()
else:
print(colored("Invalid digits value.", "red"))
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
def _entry_qr_menu(self, index: int, entry: dict) -> None:
"""Display QR codes for the given ``entry``."""
entry_type = entry.get("type")
try:
if entry_type in {EntryType.SEED.value, EntryType.MANAGED_ACCOUNT.value}:
if entry_type == EntryType.SEED.value:
seed = self.entry_manager.get_seed_phrase(index, self.parent_seed)
else:
seed = self.entry_manager.get_managed_account_seed(
index, self.parent_seed
)
print(color_text(seed, "deterministic"))
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
return
if entry_type == EntryType.NOSTR.value:
while True:
print(colored("\n[+] QR Codes:", "green"))
print(colored("P. Public key", "cyan"))
print(colored("K. Private key", "cyan"))
choice = (
input("Select option or press Enter to return: ")
.strip()
.lower()
)
if not choice:
break
npub, nsec = self.entry_manager.get_nostr_key_pair(
index, self.parent_seed
)
if choice == "p":
print(colored(f"npub: {npub}", "cyan"))
TotpManager.print_qr_code(f"nostr:{npub}")
elif choice == "k":
print(color_text(f"nsec: {nsec}", "deterministic"))
TotpManager.print_qr_code(nsec)
else:
print(colored("Invalid choice.", "red"))
entry = self.entry_manager.retrieve_entry(index) or entry
return
print(colored("No QR codes available for this entry.", "yellow"))
except Exception as e: # pragma: no cover - best effort
logging.error(f"Error displaying QR menu: {e}", exc_info=True)
print(colored(f"Error: Failed to display QR codes: {e}", "red"))
def handle_retrieve_entry(self) -> None:
"""
Handles retrieving a password from the index by prompting the user for the index number
@@ -1683,6 +1846,9 @@ class PasswordManager:
print(color_text(f"Code: {code}", category))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
remaining = self.entry_manager.get_totp_time_remaining(index)
exit_loop = False
while remaining > 0:
@@ -1732,6 +1898,9 @@ class PasswordManager:
print(colored(f"Label: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(colored("Public Key:", "cyan"))
print(color_text(pub_pem, "default"))
if self.secret_mode_enabled:
@@ -1767,6 +1936,9 @@ class PasswordManager:
print(colored(f"Label: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
if self.secret_mode_enabled:
copy_to_clipboard(phrase, self.clipboard_clear_delay)
print(
@@ -1777,10 +1949,7 @@ class PasswordManager:
)
else:
print(color_text(phrase, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(phrase))
# Removed QR code display prompt and output
if confirm_action("Show derived entropy as hex? (Y/N): "):
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
@@ -1819,6 +1988,9 @@ class PasswordManager:
print(colored(f"User ID: {label}", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
if self.secret_mode_enabled:
copy_to_clipboard(priv_key, self.clipboard_clear_delay)
@@ -1856,14 +2028,12 @@ class PasswordManager:
)
else:
print(color_text(f"nsec: {nsec}", "deterministic"))
if confirm_action("Show QR code for npub? (Y/N): "):
TotpManager.print_qr_code(f"nostr:{npub}")
if confirm_action(
"WARNING: Displaying the nsec QR reveals your private key. Continue? (Y/N): "
):
TotpManager.print_qr_code(nsec)
# QR code display removed for npub and nsec
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
except Exception as e:
logging.error(f"Error deriving Nostr keys: {e}", exc_info=True)
print(colored(f"Error: Failed to derive Nostr keys: {e}", "red"))
@@ -1879,6 +2049,9 @@ class PasswordManager:
print(colored(f"Retrieving value for '{label}'.", "cyan"))
if notes:
print(colored(f"Notes: {notes}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
@@ -1937,6 +2110,9 @@ class PasswordManager:
print(colored(f"Notes: {notes}", "cyan"))
if fingerprint:
print(colored(f"Fingerprint: {fingerprint}", "cyan"))
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
print(
colored(
f"Archived Status: {'Archived' if archived else 'Active'}",
@@ -1964,10 +2140,7 @@ class PasswordManager:
)
else:
print(color_text(seed, "deterministic"))
if confirm_action("Show Compact Seed QR? (Y/N): "):
from password_manager.seedqr import encode_seedqr
TotpManager.print_qr_code(encode_seedqr(seed))
# QR code display removed for managed account seed
self._entry_actions_menu(index, entry)
pause()
return
@@ -2030,6 +2203,9 @@ class PasswordManager:
"cyan",
)
)
tags = entry.get("tags", [])
if tags:
print(colored(f"Tags: {', '.join(tags)}", "cyan"))
custom_fields = entry.get("custom_fields", [])
if custom_fields:
print(colored("Additional Fields:", "cyan"))
@@ -2190,6 +2366,15 @@ class PasswordManager:
{"label": label, "value": value, "is_hidden": hidden}
)
tags_input = input(
"Enter tags (comma-separated, leave blank to keep current): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else None
)
self.entry_manager.modify_entry(
index,
archived=new_blacklisted,
@@ -2198,6 +2383,7 @@ class PasswordManager:
period=new_period,
digits=new_digits,
custom_fields=custom_fields,
tags=tags,
)
elif entry_type in (
EntryType.KEY_VALUE.value,
@@ -2273,6 +2459,15 @@ class PasswordManager:
{"label": f_label, "value": f_value, "is_hidden": hidden}
)
tags_input = input(
"Enter tags (comma-separated, leave blank to keep current): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else None
)
self.entry_manager.modify_entry(
index,
archived=new_blacklisted,
@@ -2280,6 +2475,7 @@ class PasswordManager:
label=new_label,
value=new_value,
custom_fields=custom_fields,
tags=tags,
)
else:
website_name = entry.get("label", entry.get("website"))
@@ -2366,6 +2562,15 @@ class PasswordManager:
{"label": label, "value": value, "is_hidden": hidden}
)
tags_input = input(
"Enter tags (comma-separated, leave blank to keep current): "
).strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else None
)
self.entry_manager.modify_entry(
index,
new_username,
@@ -2374,6 +2579,7 @@ class PasswordManager:
notes=new_notes,
label=new_label,
custom_fields=custom_fields,
tags=tags,
)
# Mark database as dirty for background sync
@@ -2479,6 +2685,9 @@ class PasswordManager:
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.SEED.value:
print(color_text(" Type: Seed Phrase", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2489,6 +2698,9 @@ class PasswordManager:
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.SSH.value:
print(color_text(" Type: SSH Key", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2498,6 +2710,9 @@ class PasswordManager:
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.PGP.value:
print(color_text(" Type: PGP Key", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2513,6 +2728,9 @@ class PasswordManager:
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
elif etype == EntryType.NOSTR.value:
print(color_text(" Type: Nostr Key", "index"))
print(color_text(f" Label: {entry.get('label', '')}", "index"))
@@ -2522,6 +2740,9 @@ class PasswordManager:
notes = entry.get("notes", "")
if notes:
print(color_text(f" Notes: {notes}", "index"))
tags = entry.get("tags", [])
if tags:
print(color_text(f" Tags: {', '.join(tags)}", "index"))
else:
website = entry.get("label", entry.get("website", ""))
username = entry.get("username", "")

View File

@@ -58,7 +58,17 @@ def _v2_to_v3(data: dict) -> dict:
return data
LATEST_VERSION = 3
@migration(3)
def _v3_to_v4(data: dict) -> dict:
"""Add tags defaults to each entry."""
entries = data.get("entries", {})
for entry in entries.values():
entry.setdefault("tags", [])
data["schema_version"] = 4
return data
LATEST_VERSION = 4
def apply_migrations(data: dict) -> dict:

View File

@@ -0,0 +1,49 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return "pw"
def test_add_tags_from_retrieve(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = entry_mgr.add_entry("example.com", 8)
inputs = iter([str(index), "t", "work,personal", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
entry = entry_mgr.retrieve_entry(index)
assert set(entry.get("tags", [])) == {"work", "personal"}

View File

@@ -22,7 +22,7 @@ def test_backup_restore_workflow(monkeypatch):
index_file = fp_dir / "seedpass_entries_db.json.enc"
data1 = {
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "a",
@@ -32,6 +32,7 @@ def test_backup_restore_workflow(monkeypatch):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
@@ -46,7 +47,7 @@ def test_backup_restore_workflow(monkeypatch):
assert backup1.stat().st_mode & 0o777 == 0o600
data2 = {
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "b",
@@ -56,6 +57,7 @@ def test_backup_restore_workflow(monkeypatch):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
@@ -69,11 +71,11 @@ def test_backup_restore_workflow(monkeypatch):
if os.name != "nt":
assert backup2.stat().st_mode & 0o777 == 0o600
vault.save_index({"schema_version": 3, "entries": {"temp": {}}})
vault.save_index({"schema_version": 4, "entries": {"temp": {}}})
backup_mgr.restore_latest_backup()
assert vault.load_index()["entries"] == data2["entries"]
vault.save_index({"schema_version": 3, "entries": {}})
vault.save_index({"schema_version": 4, "entries": {}})
backup_mgr.restore_backup_by_timestamp(1111)
assert vault.load_index()["entries"] == data1["entries"]
@@ -91,7 +93,7 @@ def test_additional_backup_location(monkeypatch):
cfg_mgr.set_additional_backup_path(extra)
backup_mgr = BackupManager(fp_dir, cfg_mgr)
vault.save_index({"schema_version": 3, "entries": {"a": {}}})
vault.save_index({"schema_version": 4, "entries": {"a": {}}})
monkeypatch.setattr(time, "time", lambda: 3333)
backup_mgr.create_backup()

View File

@@ -31,7 +31,7 @@ def _setup_pm(tmp_path: Path):
def test_cli_export_creates_file(monkeypatch, tmp_path):
pm, vault = _setup_pm(tmp_path)
data = {
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "example",
@@ -39,6 +39,7 @@ def test_cli_export_creates_file(monkeypatch, tmp_path):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
@@ -58,7 +59,7 @@ def test_cli_export_creates_file(monkeypatch, tmp_path):
def test_cli_import_round_trip(monkeypatch, tmp_path):
pm, vault = _setup_pm(tmp_path)
original = {
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "example",
@@ -66,6 +67,7 @@ def test_cli_import_round_trip(monkeypatch, tmp_path):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
@@ -79,7 +81,7 @@ def test_cli_import_round_trip(monkeypatch, tmp_path):
parent_seed=TEST_SEED,
)
vault.save_index({"schema_version": 3, "entries": {}})
vault.save_index({"schema_version": 4, "entries": {}})
monkeypatch.setattr(main, "PasswordManager", lambda: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)

View File

@@ -0,0 +1,49 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return "pw"
def test_edit_tags_from_retrieve(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = entry_mgr.add_entry("example.com", 8, tags=["old"])
inputs = iter([str(index), "t", "newtag", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
entry = entry_mgr.retrieve_entry(index)
assert entry.get("tags", []) == ["newtag"]

View File

@@ -39,6 +39,7 @@ def test_add_and_retrieve_entry():
"kind": "password",
"notes": "",
"custom_fields": custom,
"tags": [],
}
data = enc_mgr.load_json_data(entry_mgr.index_file)

View File

@@ -31,7 +31,7 @@ def test_index_export_import_round_trip():
vault = setup_vault(tmp)
original = {
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "example",
@@ -39,6 +39,7 @@ def test_index_export_import_round_trip():
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
@@ -49,7 +50,7 @@ def test_index_export_import_round_trip():
vault.save_index(
{
"schema_version": 3,
"schema_version": 4,
"entries": {
"0": {
"label": "changed",
@@ -57,6 +58,7 @@ def test_index_export_import_round_trip():
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}

View File

@@ -33,6 +33,7 @@ def test_add_and_modify_key_value():
"notes": "token",
"archived": False,
"custom_fields": [],
"tags": [],
}
em.modify_entry(idx, value="def456")

View File

@@ -48,6 +48,7 @@ def test_handle_add_totp(monkeypatch, capsys):
"", # period
"", # digits
"", # notes
"", # tags
]
)
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))
@@ -66,5 +67,6 @@ def test_handle_add_totp(monkeypatch, capsys):
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
assert "ID 0" in out

View File

@@ -0,0 +1,57 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode
from password_manager.config_manager import ConfigManager
class FakeNostrClient:
def __init__(self, *args, **kwargs):
self.published = []
def publish_snapshot(self, data: bytes):
self.published.append(data)
return None, "abcd"
def test_edit_totp_period_from_retrieve(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
entry_mgr.add_totp("Example", TEST_SEED)
inputs = iter(["0", "e", "p", "45", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr(pm.entry_manager, "get_totp_code", lambda *a, **k: "123456")
monkeypatch.setattr(
pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1
)
monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None)
monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b")
pm.handle_retrieve_entry()
entry = entry_mgr.retrieve_entry(0)
assert entry["period"] == 45

View File

@@ -54,6 +54,7 @@ def test_manager_workflow(monkeypatch):
"", # username
"", # url
"", # notes
"", # tags
"n", # add custom field
"", # length (default)
"0", # retrieve index
@@ -65,6 +66,7 @@ def test_manager_workflow(monkeypatch):
"", # archive status
"", # new notes
"n", # edit custom fields
"", # tags keep
]
)
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: next(inputs))

View File

@@ -13,7 +13,7 @@ def setup(tmp_path: Path):
return enc_mgr, vault
def test_migrate_v0_to_v3(tmp_path: Path):
def test_migrate_v0_to_v4(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"passwords": {"0": {"website": "a", "length": 8}}}
enc_mgr.save_json_data(legacy)
@@ -26,11 +26,12 @@ def test_migrate_v0_to_v3(tmp_path: Path):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
assert data["entries"]["0"] == expected_entry
def test_migrate_v1_to_v3(tmp_path: Path):
def test_migrate_v1_to_v4(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {"schema_version": 1, "passwords": {"0": {"website": "b", "length": 10}}}
enc_mgr.save_json_data(legacy)
@@ -43,11 +44,12 @@ def test_migrate_v1_to_v3(tmp_path: Path):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
assert data["entries"]["0"] == expected_entry
def test_migrate_v2_to_v3(tmp_path: Path):
def test_migrate_v2_to_v4(tmp_path: Path):
enc_mgr, vault = setup(tmp_path)
legacy = {
"schema_version": 2,
@@ -65,6 +67,7 @@ def test_migrate_v2_to_v3(tmp_path: Path):
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
assert data["entries"]["0"] == expected_entry

View File

@@ -0,0 +1,20 @@
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
def test_modify_totp_entry_period_digits_and_archive(tmp_path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
em = EntryManager(vault, backup_mgr)
em.add_totp("Example", TEST_SEED, period=30, digits=6)
em.modify_entry(0, period=60, digits=8, archived=True)
entry = em.retrieve_entry(0)
assert entry["period"] == 60
assert entry["digits"] == 8
assert entry["archived"] is True

View File

@@ -31,6 +31,7 @@ def test_nostr_key_determinism():
"label": "main",
"notes": "",
"archived": False,
"tags": [],
}
npub1, nsec1 = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)

View File

@@ -10,6 +10,7 @@ from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.manager import PasswordManager, EncryptionMode, TotpManager
from password_manager.config_manager import ConfigManager
from utils.color_scheme import color_text
class FakeNostrClient:
@@ -44,13 +45,8 @@ def test_show_qr_for_nostr_keys(monkeypatch):
idx = entry_mgr.add_nostr_key("main")
npub, _ = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
inputs = iter([str(idx), "n", "", ""])
inputs = iter([str(idx), "q", "p", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
responses = iter([True, False])
monkeypatch.setattr(
"password_manager.manager.confirm_action",
lambda *_a, **_k: next(responses),
)
called = []
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code",
@@ -59,3 +55,41 @@ def test_show_qr_for_nostr_keys(monkeypatch):
pm.handle_retrieve_entry()
assert called == [f"nostr:{npub}"]
def test_show_private_key_qr(monkeypatch, capsys):
"""Ensure nsec QR code is shown and output is colored."""
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = FakeNostrClient()
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
idx = entry_mgr.add_nostr_key("main")
_, nsec = entry_mgr.get_nostr_key_pair(idx, TEST_SEED)
inputs = iter([str(idx), "q", "k", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
called = []
monkeypatch.setattr(
"password_manager.manager.TotpManager.print_qr_code",
lambda data: called.append(data),
)
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert called == [nsec]
assert color_text(f"nsec: {nsec}", "deterministic") in out

View File

@@ -105,3 +105,26 @@ def test_search_no_results():
entry_mgr.add_entry("Example.com", 12, "alice")
result = entry_mgr.search_entries("missing")
assert result == []
def test_search_by_tag_password():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
idx = entry_mgr.add_entry("TaggedSite", 8, tags=["work"])
result = entry_mgr.search_entries("work")
assert result == [(idx, "TaggedSite", "", "", False)]
def test_search_by_tag_totp():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
entry_mgr.add_totp("OTPAccount", TEST_SEED, tags=["mfa"])
idx = entry_mgr.search_entries("OTPAccount")[0][0]
result = entry_mgr.search_entries("mfa")
assert result == [(idx, "OTPAccount", None, None, False)]

View File

@@ -42,6 +42,7 @@ def test_seed_phrase_determinism():
"word_count": 12,
"notes": "",
"archived": False,
"tags": [],
}
assert entry24 == {
@@ -52,6 +53,7 @@ def test_seed_phrase_determinism():
"word_count": 24,
"notes": "",
"archived": False,
"tags": [],
}
assert phrase12_a not in entry12.values()

View File

@@ -29,6 +29,7 @@ def test_add_and_retrieve_ssh_key_pair():
"label": "ssh",
"notes": "",
"archived": False,
"tags": [],
}
priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED)

View File

@@ -0,0 +1,49 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
from password_manager.entry_management import EntryManager
from password_manager.backup import BackupManager
from password_manager.config_manager import ConfigManager
def setup_entry_manager(tmp_path: Path) -> EntryManager:
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
return EntryManager(vault, backup_mgr)
def test_tags_persist_on_new_entry():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
idx = entry_mgr.add_entry("Site", 8, tags=["work"])
# Reinitialize to simulate application restart
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("work")
assert result == [(idx, "Site", "", "", False)]
def test_tags_persist_after_modify():
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
entry_mgr = setup_entry_manager(tmp_path)
idx = entry_mgr.add_entry("Site", 8)
entry_mgr.modify_entry(idx, tags=["personal"])
# Ensure tag searchable before reload
assert entry_mgr.search_entries("personal") == [(idx, "Site", "", "", False)]
# Reinitialize to simulate application restart
entry_mgr = setup_entry_manager(tmp_path)
result = entry_mgr.search_entries("personal")
assert result == [(idx, "Site", "", "", False)]

View File

@@ -37,6 +37,7 @@ def test_add_totp_and_get_code():
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
code = entry_mgr.get_totp_code(0, TEST_SEED, timestamp=0)
@@ -76,6 +77,7 @@ def test_add_totp_imported(tmp_path):
"digits": 6,
"archived": False,
"notes": "",
"tags": [],
}
code = em.get_totp_code(0, timestamp=0)
assert code == pyotp.TOTP(secret).at(0)