mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 23:38:49 +00:00
27
README.md
27
README.md
@@ -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 50 KB 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 50 KB 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.
|
||||
- **Auto‑Lock 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
|
||||
|
1001
docs/advanced_cli.md
1001
docs/advanced_cli.md
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
```
|
||||
|
@@ -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 KB and rotated when deltas accumulate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
@@ -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),
|
||||
|
@@ -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", "")
|
||||
|
@@ -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:
|
||||
|
49
src/tests/test_add_tags_from_retrieve.py
Normal file
49
src/tests/test_add_tags_from_retrieve.py
Normal 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"}
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
49
src/tests/test_edit_tags_from_retrieve.py
Normal file
49
src/tests/test_edit_tags_from_retrieve.py
Normal 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"]
|
@@ -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)
|
||||
|
@@ -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": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@@ -33,6 +33,7 @@ def test_add_and_modify_key_value():
|
||||
"notes": "token",
|
||||
"archived": False,
|
||||
"custom_fields": [],
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
em.modify_entry(idx, value="def456")
|
||||
|
@@ -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
|
||||
|
57
src/tests/test_manager_edit_totp.py
Normal file
57
src/tests/test_manager_edit_totp.py
Normal 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
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
||||
|
20
src/tests/test_modify_totp_entry.py
Normal file
20
src/tests/test_modify_totp_entry.py
Normal 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
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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)]
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
49
src/tests/test_tag_persistence.py
Normal file
49
src/tests/test_tag_persistence.py
Normal 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)]
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user