mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
14
README.md
14
README.md
@@ -355,6 +355,20 @@ variable to control the delay between publishes when experimenting with large va
|
||||
pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000
|
||||
```
|
||||
|
||||
### Generating a Test Profile
|
||||
|
||||
Use the helper script below to populate a profile with sample entries for testing:
|
||||
|
||||
```bash
|
||||
python scripts/generate_test_profile.py --profile demo_profile --count 100
|
||||
```
|
||||
|
||||
The script now determines the fingerprint from the generated seed and stores the
|
||||
vault under `~/.seedpass/<fingerprint>`. It also prints the fingerprint after
|
||||
creation and publishes the encrypted index to Nostr. Use that same seed phrase
|
||||
to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot
|
||||
so your vault stays in sync across machines.
|
||||
|
||||
### Automatically Updating the Script Checksum
|
||||
|
||||
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
|
||||
|
@@ -1,7 +1,12 @@
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: neo-dark
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
primaryTextColor: '#ffffff'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
flowchart TD
|
||||
@@ -11,7 +16,7 @@ flowchart TD
|
||||
end
|
||||
subgraph subGraph1["Backup Pipeline"]
|
||||
direction TB
|
||||
BK1["Incremental Backups<br>(BackupManager)"]
|
||||
BK1["Timestamped Backups<br>(BackupManager)"]
|
||||
BK2["Portable Backup<br>(portable_backup.py)<br>.json.enc"]
|
||||
BK3["Nostr Snapshot<br>(nostr.client)<br>gzip chunks"]
|
||||
end
|
||||
|
@@ -49,9 +49,28 @@
|
||||
<!-- Intro Section -->
|
||||
<section class="intro" id="intro" aria-labelledby="intro-heading">
|
||||
<div class="container">
|
||||
<h1 id="intro-heading">SeedPass: Secure Password Manager</h1>
|
||||
<p><strong>SeedPass</strong> is a secure password generator and manager built on Bitcoin's BIP-85 standard. It uses deterministic key derivation to generate passwords that are never stored but can be easily regenerated when needed.</p>
|
||||
<p>By integrating with the <strong>Nostr network</strong>, SeedPass compresses your encrypted vault and publishes it in 50 KB chunks. Each chunk is sent as a parameterised replaceable event, with deltas tracking changes between snapshots and automatic rotation when deltas grow large.</p>
|
||||
<h1 id="intro-heading">One Seed to Rule Them All</h1>
|
||||
<p>SeedPass deterministically derives every key and password from a single 12‑word phrase.</p>
|
||||
<pre class="mermaid mini-chart">
|
||||
---
|
||||
config:
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
flowchart TB
|
||||
seed["alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima"]
|
||||
seed --> pw["🔑 Passwords"]
|
||||
seed --> totp["📱 2FA Codes"]
|
||||
seed --> ssh["🖧 SSH Keys"]
|
||||
seed --> pgp["🔒 PGP Key"]
|
||||
seed --> mn["🌱 Seed Phrase"]
|
||||
seed --> nostr["⚡ Nostr Keys"]
|
||||
classDef default fill:#ffffff,stroke:#e94a39,stroke-width:2px,color:#283c4f;
|
||||
</pre>
|
||||
<a href="https://github.com/PR0M3TH3AN/SeedPass" class="btn-primary cta-button"><i class="fas fa-download" aria-hidden="true"></i> Get Started</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -63,7 +82,12 @@
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: neo-dark
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
primaryTextColor: '#ffffff'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
flowchart TD
|
||||
@@ -73,7 +97,7 @@ flowchart TD
|
||||
end
|
||||
subgraph subGraph1["Backup Pipeline"]
|
||||
direction TB
|
||||
BK1["Incremental Backups<br>(BackupManager)"]
|
||||
BK1["Timestamped Backups<br>(BackupManager)"]
|
||||
BK2["Portable Backup<br>(portable_backup.py)<br>.json.enc"]
|
||||
BK3["Nostr Snapshot<br>(nostr.client)<br>gzip chunks"]
|
||||
end
|
||||
|
@@ -337,6 +337,13 @@ footer .social-media a:focus {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* Mini flow chart in hero */
|
||||
.mini-chart {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
background-color: var(--background-section);
|
||||
|
199
scripts/generate_test_profile.py
Normal file
199
scripts/generate_test_profile.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a SeedPass test profile with realistic entries.
|
||||
|
||||
This script populates a profile directory with a variety of entry types.
|
||||
If the profile does not exist, a new BIP-39 seed phrase is generated and
|
||||
stored encrypted. A clear text copy is written to ``seed_phrase.txt`` so
|
||||
it can be reused across devices.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum
|
||||
|
||||
# Ensure src directory is in sys.path for imports
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = PROJECT_ROOT / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
import constants as consts
|
||||
|
||||
# Use a dedicated subdirectory for test profiles so regular data is not polluted
|
||||
consts.APP_DIR = consts.APP_DIR / "tests"
|
||||
consts.PARENT_SEED_FILE = consts.APP_DIR / "parent_seed.enc"
|
||||
consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
|
||||
|
||||
from constants import APP_DIR, initialize_app
|
||||
from utils.key_derivation import derive_key_from_password, derive_index_key
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.entry_management import EntryManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
import bcrypt
|
||||
import asyncio
|
||||
import gzip
|
||||
|
||||
DEFAULT_PASSWORD = "testpassword"
|
||||
|
||||
|
||||
def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]:
|
||||
"""Create or load a profile and return the seed phrase, manager, directory and fingerprint."""
|
||||
initialize_app()
|
||||
seed_txt = APP_DIR / f"{profile_name}_seed.txt"
|
||||
if seed_txt.exists():
|
||||
seed_phrase = seed_txt.read_text().strip()
|
||||
else:
|
||||
seed_phrase = (
|
||||
Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
|
||||
.FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)
|
||||
.ToStr()
|
||||
)
|
||||
seed_txt.write_text(seed_phrase)
|
||||
seed_txt.chmod(0o600)
|
||||
|
||||
fp_mgr = FingerprintManager(APP_DIR)
|
||||
fingerprint = fp_mgr.add_fingerprint(seed_phrase) or generate_fingerprint(
|
||||
seed_phrase
|
||||
)
|
||||
if fingerprint is None:
|
||||
fingerprint = profile_name
|
||||
profile_dir = APP_DIR / fingerprint
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
seed_key = derive_key_from_password(DEFAULT_PASSWORD)
|
||||
seed_mgr = EncryptionManager(seed_key, profile_dir)
|
||||
seed_file = profile_dir / "parent_seed.enc"
|
||||
clear_path = profile_dir / "seed_phrase.txt"
|
||||
|
||||
if seed_file.exists():
|
||||
try:
|
||||
current = seed_mgr.decrypt_parent_seed()
|
||||
except Exception:
|
||||
current = None
|
||||
if current != seed_phrase:
|
||||
seed_mgr.encrypt_parent_seed(seed_phrase)
|
||||
else:
|
||||
seed_mgr.encrypt_parent_seed(seed_phrase)
|
||||
|
||||
if not clear_path.exists():
|
||||
clear_path.write_text(seed_phrase)
|
||||
clear_path.chmod(0o600)
|
||||
|
||||
index_key = derive_index_key(seed_phrase)
|
||||
enc_mgr = EncryptionManager(index_key, profile_dir)
|
||||
vault = Vault(enc_mgr, profile_dir)
|
||||
cfg_mgr = ConfigManager(vault, profile_dir)
|
||||
# Store the default password hash so the profile can be opened
|
||||
hashed = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
|
||||
cfg_mgr.set_password_hash(hashed)
|
||||
backup_mgr = BackupManager(profile_dir, cfg_mgr)
|
||||
entry_mgr = EntryManager(vault, backup_mgr)
|
||||
return seed_phrase, entry_mgr, profile_dir, fingerprint
|
||||
|
||||
|
||||
def random_secret(length: int = 16) -> str:
|
||||
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
return "".join(random.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def populate(entry_mgr: EntryManager, seed: str, count: int) -> None:
|
||||
"""Add ``count`` entries of varying types to the vault."""
|
||||
start_index = entry_mgr.get_next_index()
|
||||
for i in range(count):
|
||||
idx = start_index + i
|
||||
kind = idx % 7
|
||||
if kind == 0:
|
||||
entry_mgr.add_entry(
|
||||
label=f"site-{idx}.example.com",
|
||||
length=12,
|
||||
username=f"user{idx}",
|
||||
url=f"https://example{idx}.com",
|
||||
notes=f"Website account {idx}",
|
||||
custom_fields=[{"key": "id", "value": str(idx)}],
|
||||
)
|
||||
elif kind == 1:
|
||||
entry_mgr.add_totp(f"totp-generated-{idx}", seed)
|
||||
elif kind == 2:
|
||||
entry_mgr.add_totp(f"totp-imported-{idx}", seed, secret=random_secret())
|
||||
elif kind == 3:
|
||||
entry_mgr.add_ssh_key(f"ssh-{idx}", seed, notes=f"SSH key for server {idx}")
|
||||
elif kind == 4:
|
||||
entry_mgr.add_seed(
|
||||
f"derived-seed-{idx}", seed, words_num=24, notes=f"Seed {idx}"
|
||||
)
|
||||
elif kind == 5:
|
||||
entry_mgr.add_nostr_key(f"nostr-{idx}", notes=f"Nostr key {idx}")
|
||||
else:
|
||||
entry_mgr.add_pgp_key(
|
||||
f"pgp-{idx}",
|
||||
seed,
|
||||
user_id=f"user{idx}@example.com",
|
||||
notes=f"PGP key {idx}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create or extend a SeedPass test profile"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
default="test_profile",
|
||||
help="profile name inside ~/.seedpass/tests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--count",
|
||||
type=int,
|
||||
default=100,
|
||||
help="number of entries to add",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile)
|
||||
print(f"Using profile directory: {dir_path}")
|
||||
print(f"Parent seed: {seed}")
|
||||
if fingerprint:
|
||||
print(f"Fingerprint: {fingerprint}")
|
||||
populate(entry_mgr, seed, args.count)
|
||||
print(f"Added {args.count} entries.")
|
||||
|
||||
encrypted = entry_mgr.vault.get_encrypted_index()
|
||||
if encrypted:
|
||||
client = NostrClient(
|
||||
entry_mgr.vault.encryption_manager,
|
||||
fingerprint or dir_path.name,
|
||||
parent_seed=seed,
|
||||
)
|
||||
asyncio.run(client.publish_snapshot(encrypted))
|
||||
print("[+] Data synchronized to Nostr.")
|
||||
try:
|
||||
result = asyncio.run(client.fetch_latest_snapshot())
|
||||
if result:
|
||||
_, chunks = result
|
||||
retrieved = gzip.decompress(b"".join(chunks))
|
||||
if retrieved == encrypted:
|
||||
print("[+] Verified snapshot retrieval.")
|
||||
else:
|
||||
print(
|
||||
f"[!] Retrieval failed: {client.last_error or 'data mismatch'}"
|
||||
)
|
||||
else:
|
||||
print(f"[!] Retrieval failed: {client.last_error or 'data mismatch'}")
|
||||
except Exception as e:
|
||||
print(f"[!] Retrieval failed: {e}")
|
||||
else:
|
||||
print("[-] No encrypted index found to sync.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
10
src/main.py
10
src/main.py
@@ -304,6 +304,8 @@ def handle_post_to_nostr(
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to post to Nostr: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to post to Nostr: {e}", "red"))
|
||||
finally:
|
||||
pause()
|
||||
|
||||
|
||||
def handle_retrieve_from_nostr(password_manager: PasswordManager):
|
||||
@@ -336,6 +338,8 @@ def handle_retrieve_from_nostr(password_manager: PasswordManager):
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
|
||||
finally:
|
||||
pause()
|
||||
|
||||
|
||||
def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
|
||||
@@ -395,6 +399,8 @@ def handle_add_relay(password_manager: PasswordManager) -> None:
|
||||
except Exception as e:
|
||||
logging.error(f"Error adding relay: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
finally:
|
||||
pause()
|
||||
|
||||
|
||||
def handle_remove_relay(password_manager: PasswordManager) -> None:
|
||||
@@ -430,6 +436,8 @@ def handle_remove_relay(password_manager: PasswordManager) -> None:
|
||||
except Exception as e:
|
||||
logging.error(f"Error removing relay: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
finally:
|
||||
pause()
|
||||
|
||||
|
||||
def handle_reset_relays(password_manager: PasswordManager) -> None:
|
||||
@@ -447,6 +455,8 @@ def handle_reset_relays(password_manager: PasswordManager) -> None:
|
||||
except Exception as e:
|
||||
logging.error(f"Error resetting relays: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
finally:
|
||||
pause()
|
||||
|
||||
|
||||
def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
|
||||
|
@@ -153,10 +153,8 @@ class NostrClient:
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
data = json.loads(msg)
|
||||
if data[0] == "EVENT":
|
||||
if data[0] in {"EVENT", "EOSE"}:
|
||||
return True
|
||||
if data[0] == "EOSE":
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
@@ -376,14 +376,10 @@ class EncryptionManager:
|
||||
try:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
if not (self.fingerprint_dir / relative_path).exists():
|
||||
logger.error(
|
||||
# Missing index is normal on first run
|
||||
logger.info(
|
||||
f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'."
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Index file '{relative_path}' does not exist.", "red"
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
|
@@ -302,7 +302,7 @@ class PasswordManager:
|
||||
# Initialize BIP85 and other managers
|
||||
self.initialize_bip85()
|
||||
self.initialize_managers()
|
||||
self.sync_index_from_nostr_if_missing()
|
||||
self.sync_index_from_nostr()
|
||||
print(
|
||||
colored(
|
||||
f"Seed profile {fingerprint} selected and managers initialized.",
|
||||
@@ -432,7 +432,7 @@ class PasswordManager:
|
||||
# Initialize BIP85 and other managers
|
||||
self.initialize_bip85()
|
||||
self.initialize_managers()
|
||||
self.sync_index_from_nostr_if_missing()
|
||||
self.sync_index_from_nostr()
|
||||
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
|
||||
|
||||
# Re-initialize NostrClient with the new fingerprint
|
||||
@@ -616,7 +616,7 @@ class PasswordManager:
|
||||
|
||||
self.initialize_bip85()
|
||||
self.initialize_managers()
|
||||
self.sync_index_from_nostr_if_missing()
|
||||
self.sync_index_from_nostr()
|
||||
return fingerprint # Return the generated or added fingerprint
|
||||
else:
|
||||
logging.error("Invalid BIP-85 seed phrase. Exiting.")
|
||||
@@ -757,7 +757,7 @@ class PasswordManager:
|
||||
|
||||
self.initialize_bip85()
|
||||
self.initialize_managers()
|
||||
self.sync_index_from_nostr_if_missing()
|
||||
self.sync_index_from_nostr()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
|
||||
|
32
src/tests/test_generate_test_profile.py
Normal file
32
src/tests/test_generate_test_profile.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import importlib.util
|
||||
|
||||
|
||||
def test_initialize_profile_creates_directories(monkeypatch):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
# Mock home directory so APP_DIR is within tmp_path
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
# Reload constants to use the mocked home directory
|
||||
constants = importlib.import_module("constants")
|
||||
importlib.reload(constants)
|
||||
# Load the script module directly from its path
|
||||
script_path = (
|
||||
Path(__file__).resolve().parents[2] / "scripts" / "generate_test_profile.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"generate_test_profile", script_path
|
||||
)
|
||||
gtp = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(gtp)
|
||||
|
||||
seed, mgr, dir_path, fingerprint = gtp.initialize_profile("test")
|
||||
|
||||
assert constants.APP_DIR.exists()
|
||||
assert (constants.APP_DIR / "test_seed.txt").exists()
|
||||
assert dir_path.exists()
|
||||
assert dir_path.name == fingerprint
|
@@ -2,12 +2,15 @@ import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
import asyncio
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from nostr.client import NostrClient
|
||||
import nostr.client as nostr_client
|
||||
|
||||
|
||||
def test_nostr_client_uses_custom_relays():
|
||||
@@ -50,6 +53,25 @@ class FakeAddRelayClient:
|
||||
self.connected = True
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self, messages):
|
||||
self.messages = messages
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
pass
|
||||
|
||||
async def send(self, _):
|
||||
pass
|
||||
|
||||
async def recv(self):
|
||||
if self.messages:
|
||||
return self.messages.pop(0)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def _setup_client(tmpdir, fake_cls):
|
||||
key = Fernet.generate_key()
|
||||
enc_mgr = EncryptionManager(key, Path(tmpdir))
|
||||
@@ -91,3 +113,18 @@ def test_check_relay_health_runs_async(tmp_path, monkeypatch):
|
||||
|
||||
assert result == 1
|
||||
assert recorded["args"] == (3, 2)
|
||||
|
||||
|
||||
def test_ping_relay_accepts_eose(tmp_path, monkeypatch):
|
||||
client = _setup_client(tmp_path, FakeAddRelayClient)
|
||||
|
||||
fake_ws = FakeWebSocket([json.dumps(["EOSE"])])
|
||||
|
||||
def fake_connect(*_args, **_kwargs):
|
||||
return fake_ws
|
||||
|
||||
monkeypatch.setattr(nostr_client.websockets, "connect", fake_connect)
|
||||
|
||||
result = asyncio.run(client._ping_relay("wss://relay", timeout=0.1))
|
||||
|
||||
assert result is True
|
||||
|
Reference in New Issue
Block a user