Merge pull request #339 from PR0M3TH3AN/beta

Beta
This commit is contained in:
thePR0M3TH3AN
2025-07-06 19:33:13 -04:00
committed by GitHub
11 changed files with 342 additions and 20 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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&#8201;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 12word 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

View File

@@ -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);

View 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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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"))

View 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

View File

@@ -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