mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
feat: track nostr account index per seed
This commit is contained in:
@@ -43,6 +43,7 @@ from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from seedpass.core.state_manager import StateManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
@@ -195,11 +196,13 @@ def main() -> None:
|
||||
|
||||
encrypted = entry_mgr.vault.get_encrypted_index()
|
||||
if encrypted:
|
||||
idx = StateManager(dir_path).state.get("nostr_account_idx", 0)
|
||||
client = NostrClient(
|
||||
entry_mgr.vault.encryption_manager,
|
||||
fingerprint or dir_path.name,
|
||||
parent_seed=seed,
|
||||
config_manager=cfg_mgr,
|
||||
account_index=idx,
|
||||
)
|
||||
asyncio.run(client.publish_snapshot(encrypted))
|
||||
print("[+] Data synchronized to Nostr.")
|
||||
|
@@ -58,6 +58,7 @@ class NostrClient(ConnectionHandler, SnapshotHandler):
|
||||
offline_mode: bool = False,
|
||||
config_manager: Optional["ConfigManager"] = None,
|
||||
key_index: bytes | None = None,
|
||||
account_index: int | None = None,
|
||||
) -> None:
|
||||
self.encryption_manager = encryption_manager
|
||||
self.fingerprint = fingerprint
|
||||
@@ -69,7 +70,7 @@ class NostrClient(ConnectionHandler, SnapshotHandler):
|
||||
parent_seed = self.encryption_manager.decrypt_parent_seed()
|
||||
|
||||
# Use our project's KeyManager to derive the private key
|
||||
self.key_manager = KeyManager(parent_seed, fingerprint)
|
||||
self.key_manager = KeyManager(parent_seed, fingerprint, account_index)
|
||||
|
||||
# Create a nostr-sdk Keys object from our derived private key
|
||||
private_key_hex = self.key_manager.keys.private_key_hex()
|
||||
|
@@ -16,17 +16,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeyManager:
|
||||
"""
|
||||
Manages key generation, encoding, and derivation for NostrClient.
|
||||
"""
|
||||
"""Manages key generation, encoding, and derivation for ``NostrClient``."""
|
||||
|
||||
def __init__(self, parent_seed: str, fingerprint: str):
|
||||
"""
|
||||
Initializes the KeyManager with the provided parent_seed and fingerprint.
|
||||
def __init__(
|
||||
self, parent_seed: str, fingerprint: str, account_index: int | None = None
|
||||
):
|
||||
"""Initialize the key manager.
|
||||
|
||||
Parameters:
|
||||
parent_seed (str): The parent seed used for key derivation.
|
||||
fingerprint (str): The fingerprint to differentiate key derivations.
|
||||
Parameters
|
||||
----------
|
||||
parent_seed:
|
||||
The BIP-39 seed used as the root for derivations.
|
||||
fingerprint:
|
||||
Seed profile fingerprint used for legacy derivations and logging.
|
||||
account_index:
|
||||
Optional explicit index for BIP-85 Nostr key derivation. When ``None``
|
||||
the index defaults to ``0``.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(parent_seed, str):
|
||||
@@ -40,12 +45,15 @@ class KeyManager:
|
||||
|
||||
self.parent_seed = parent_seed
|
||||
self.fingerprint = fingerprint
|
||||
logger.debug(f"KeyManager initialized with parent_seed and fingerprint.")
|
||||
self.account_index = account_index
|
||||
logger.debug(
|
||||
"KeyManager initialized with parent_seed, fingerprint and account index."
|
||||
)
|
||||
|
||||
# Initialize BIP85
|
||||
self.bip85 = self.initialize_bip85()
|
||||
|
||||
# Generate Nostr keys using the fingerprint
|
||||
# Generate Nostr keys using the provided account index
|
||||
self.keys = self.generate_nostr_keys()
|
||||
logger.debug("Nostr Keys initialized successfully.")
|
||||
|
||||
@@ -70,34 +78,36 @@ class KeyManager:
|
||||
raise
|
||||
|
||||
def generate_nostr_keys(self) -> Keys:
|
||||
"""
|
||||
Derives a unique Nostr key pair for the given fingerprint using BIP-85.
|
||||
|
||||
Returns:
|
||||
Keys: An instance of Keys containing the Nostr key pair.
|
||||
"""
|
||||
"""Derive a Nostr key pair using the configured ``account_index``."""
|
||||
try:
|
||||
# Convert fingerprint to an integer index (using a hash function)
|
||||
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
|
||||
2**31
|
||||
)
|
||||
index = self.account_index if self.account_index is not None else 0
|
||||
|
||||
# Derive entropy for Nostr key (32 bytes)
|
||||
entropy_bytes = self.bip85.derive_entropy(
|
||||
index=index,
|
||||
entropy_bytes=32,
|
||||
app_no=NOSTR_KEY_APP_ID,
|
||||
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
|
||||
)
|
||||
|
||||
# Generate Nostr key pair from entropy
|
||||
private_key_hex = entropy_bytes.hex()
|
||||
keys = Keys(priv_k=private_key_hex)
|
||||
logger.debug(f"Nostr keys generated for fingerprint {self.fingerprint}.")
|
||||
logger.debug("Nostr keys generated for account index %s", index)
|
||||
return keys
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate Nostr keys: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def generate_v1_nostr_keys(self) -> Keys:
|
||||
"""Derive keys using the legacy fingerprint-hash method."""
|
||||
try:
|
||||
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
|
||||
2**31
|
||||
)
|
||||
entropy_bytes = self.bip85.derive_entropy(
|
||||
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
|
||||
)
|
||||
return Keys(priv_k=entropy_bytes.hex())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate v1 Nostr keys: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def generate_legacy_nostr_keys(self) -> Keys:
|
||||
"""Derive Nostr keys using the legacy application ID."""
|
||||
try:
|
||||
|
@@ -296,6 +296,7 @@ class PasswordManager:
|
||||
self._suppress_entry_actions_menu: bool = False
|
||||
self.last_bip85_idx: int = 0
|
||||
self.last_sync_ts: int = 0
|
||||
self.nostr_account_idx: int = 0
|
||||
self.auth_guard = AuthGuard(self)
|
||||
|
||||
# Service composition
|
||||
@@ -1152,6 +1153,14 @@ class PasswordManager:
|
||||
print(colored("Please write this down and keep it in a safe place!", "red"))
|
||||
|
||||
if confirm_action("Do you want to use this generated seed? (Y/N): "):
|
||||
# Determine next account index if state manager is available
|
||||
next_idx = 0
|
||||
if getattr(self, "state_manager", None) is not None:
|
||||
try:
|
||||
next_idx = self.state_manager.state.get("nostr_account_idx", 0) + 1
|
||||
except Exception:
|
||||
next_idx = 0
|
||||
|
||||
# Add a new fingerprint using the generated seed
|
||||
try:
|
||||
fingerprint = self.fingerprint_manager.add_fingerprint(new_seed)
|
||||
@@ -1184,6 +1193,15 @@ class PasswordManager:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Persist the assigned account index for the new profile
|
||||
try:
|
||||
StateManager(fingerprint_dir).update_state(nostr_account_idx=next_idx)
|
||||
if getattr(self, "state_manager", None) is not None:
|
||||
self.state_manager.update_state(nostr_account_idx=next_idx)
|
||||
self.nostr_account_idx = next_idx
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set the current fingerprint in both PasswordManager and FingerprintManager
|
||||
self.current_fingerprint = fingerprint
|
||||
self.fingerprint_manager.current_fingerprint = fingerprint
|
||||
@@ -1408,12 +1426,14 @@ class PasswordManager:
|
||||
self.last_sync_ts = state.get("last_sync_ts", 0)
|
||||
self.manifest_id = state.get("manifest_id")
|
||||
self.delta_since = state.get("delta_since", 0)
|
||||
self.nostr_account_idx = state.get("nostr_account_idx", 0)
|
||||
else:
|
||||
relay_list = list(DEFAULT_RELAYS)
|
||||
self.last_bip85_idx = 0
|
||||
self.last_sync_ts = 0
|
||||
self.manifest_id = None
|
||||
self.delta_since = 0
|
||||
self.nostr_account_idx = 0
|
||||
self.offline_mode = bool(config.get("offline_mode", True))
|
||||
self.inactivity_timeout = config.get(
|
||||
"inactivity_timeout", INACTIVITY_TIMEOUT
|
||||
@@ -1431,6 +1451,7 @@ class PasswordManager:
|
||||
config_manager=self.config_manager,
|
||||
parent_seed=getattr(self, "parent_seed", None),
|
||||
key_index=self.KEY_INDEX,
|
||||
account_index=self.nostr_account_idx,
|
||||
)
|
||||
|
||||
if getattr(self, "manifest_id", None) and hasattr(
|
||||
@@ -4537,6 +4558,7 @@ class PasswordManager:
|
||||
config_manager=self.config_manager,
|
||||
parent_seed=getattr(self, "parent_seed", None),
|
||||
key_index=self.KEY_INDEX,
|
||||
account_index=self.nostr_account_idx,
|
||||
)
|
||||
|
||||
if getattr(self, "manifest_id", None) and hasattr(
|
||||
|
@@ -21,6 +21,7 @@ from utils.key_derivation import (
|
||||
)
|
||||
from .encryption import EncryptionManager
|
||||
from utils.checksum import json_checksum, canonical_json_dumps
|
||||
from .state_manager import StateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,10 +107,12 @@ def export_backup(
|
||||
enc_file.write_bytes(encrypted)
|
||||
os.chmod(enc_file, 0o600)
|
||||
try:
|
||||
idx = StateManager(vault.fingerprint_dir).state.get("nostr_account_idx", 0)
|
||||
client = NostrClient(
|
||||
vault.encryption_manager,
|
||||
vault.fingerprint_dir.name,
|
||||
config_manager=backup_manager.config_manager,
|
||||
account_index=idx,
|
||||
)
|
||||
asyncio.run(client.publish_snapshot(encrypted))
|
||||
except Exception:
|
||||
|
@@ -77,6 +77,7 @@ class ProfileService:
|
||||
config_manager=getattr(pm, "config_manager", None),
|
||||
parent_seed=getattr(pm, "parent_seed", None),
|
||||
key_index=pm.KEY_INDEX,
|
||||
account_index=pm.nostr_account_idx,
|
||||
)
|
||||
if getattr(pm, "manifest_id", None) and hasattr(
|
||||
pm.nostr_client, "_state_lock"
|
||||
|
@@ -26,6 +26,7 @@ class StateManager:
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
"nostr_account_idx": 0,
|
||||
}
|
||||
with shared_lock(self.state_path) as fh:
|
||||
fh.seek(0)
|
||||
@@ -37,6 +38,7 @@ class StateManager:
|
||||
"manifest_id": None,
|
||||
"delta_since": 0,
|
||||
"relays": list(DEFAULT_RELAYS),
|
||||
"nostr_account_idx": 0,
|
||||
}
|
||||
try:
|
||||
obj = json.loads(data.decode())
|
||||
@@ -47,6 +49,7 @@ class StateManager:
|
||||
obj.setdefault("manifest_id", None)
|
||||
obj.setdefault("delta_since", 0)
|
||||
obj.setdefault("relays", list(DEFAULT_RELAYS))
|
||||
obj.setdefault("nostr_account_idx", 0)
|
||||
return obj
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
|
@@ -20,6 +20,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
|
||||
pm.current_fingerprint = None
|
||||
pm.encryption_manager = object()
|
||||
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
|
||||
pm.nostr_account_idx = 0
|
||||
|
||||
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
|
||||
monkeypatch.setattr(
|
||||
|
@@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from seedpass.core.state_manager import StateManager
|
||||
|
||||
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
|
||||
@@ -13,6 +14,7 @@ def setup_pm(tmp_path, monkeypatch):
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.fingerprint_manager = FingerprintManager(tmp_path)
|
||||
pm.config_manager = type("Cfg", (), {"get_kdf_iterations": lambda self: 1})()
|
||||
pm.state_manager = StateManager(tmp_path)
|
||||
monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw")
|
||||
monkeypatch.setattr("seedpass.core.manager.derive_index_key", lambda seed: b"idx")
|
||||
monkeypatch.setattr(
|
||||
@@ -49,3 +51,5 @@ def test_generate_new_seed_creates_profile(monkeypatch):
|
||||
|
||||
assert fingerprint == generate_fingerprint(VALID_SEED)
|
||||
assert pm.fingerprint_manager.list_fingerprints() == [fingerprint]
|
||||
sm = StateManager(tmp_path / fingerprint)
|
||||
assert sm.state["nostr_account_idx"] == 1
|
||||
|
@@ -35,6 +35,7 @@ def test_change_password_triggers_nostr_backup(monkeypatch):
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.store_hashed_password = lambda pw: None
|
||||
pm.verify_password = lambda pw: True
|
||||
pm.nostr_account_idx = 0
|
||||
|
||||
with patch("seedpass.core.manager.NostrClient") as MockClient:
|
||||
mock_instance = MockClient.return_value
|
||||
|
@@ -62,6 +62,7 @@ def test_password_change_and_unlock(monkeypatch):
|
||||
pm.nostr_client = SimpleNamespace(
|
||||
publish_snapshot=lambda *a, **k: (None, "abcd")
|
||||
)
|
||||
pm.nostr_account_idx = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.prompt_existing_password", lambda *_: old_pw
|
||||
|
@@ -20,6 +20,7 @@ def setup_pm(tmp_path):
|
||||
pm.encryption_mode = manager_module.EncryptionMode.SEED_ONLY
|
||||
pm.fingerprint_manager = manager_module.FingerprintManager(constants.APP_DIR)
|
||||
pm.current_fingerprint = None
|
||||
pm.state_manager = manager_module.StateManager(constants.APP_DIR)
|
||||
return pm, constants, manager_module
|
||||
|
||||
|
||||
@@ -41,8 +42,8 @@ def test_generate_seed_cleanup_on_failure(monkeypatch):
|
||||
|
||||
# fingerprint list should be empty and only fingerprints.json should remain
|
||||
assert pm.fingerprint_manager.list_fingerprints() == []
|
||||
contents = list(const.APP_DIR.iterdir())
|
||||
assert len(contents) == 1 and contents[0].name == "fingerprints.json"
|
||||
contents = sorted(p.name for p in const.APP_DIR.iterdir())
|
||||
assert contents == ["fingerprints.json", "seedpass_state.json"]
|
||||
fp_file = pm.fingerprint_manager.fingerprints_file
|
||||
with open(fp_file) as f:
|
||||
data = json.load(f)
|
||||
|
@@ -29,6 +29,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
|
||||
pm.fingerprint_manager = fm
|
||||
pm.encryption_manager = object()
|
||||
pm.current_fingerprint = None
|
||||
pm.nostr_account_idx = 0
|
||||
|
||||
monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1")
|
||||
monkeypatch.setattr(
|
||||
|
@@ -21,6 +21,7 @@ def setup_password_manager():
|
||||
pm.fingerprint_manager = manager_module.FingerprintManager(constants.APP_DIR)
|
||||
pm.current_fingerprint = None
|
||||
pm.save_and_encrypt_seed = lambda seed, fingerprint_dir: None
|
||||
pm.state_manager = manager_module.StateManager(constants.APP_DIR)
|
||||
return pm, constants
|
||||
|
||||
|
||||
|
@@ -120,6 +120,7 @@ def test_profile_service_switch(monkeypatch):
|
||||
pm.delta_since = None
|
||||
pm.encryption_manager = SimpleNamespace()
|
||||
pm.parent_seed = TEST_SEED
|
||||
pm.nostr_account_idx = 0
|
||||
|
||||
service = ProfileService(pm)
|
||||
monkeypatch.setattr("builtins.input", lambda *_: "2")
|
||||
|
@@ -14,6 +14,7 @@ def test_state_manager_round_trip():
|
||||
assert state["last_sync_ts"] == 0
|
||||
assert state["manifest_id"] is None
|
||||
assert state["delta_since"] == 0
|
||||
assert state["nostr_account_idx"] == 0
|
||||
|
||||
sm.add_relay("wss://example.com")
|
||||
sm.update_state(
|
||||
@@ -30,6 +31,7 @@ def test_state_manager_round_trip():
|
||||
assert state2["last_sync_ts"] == 123
|
||||
assert state2["manifest_id"] == "mid"
|
||||
assert state2["delta_since"] == 111
|
||||
assert state2["nostr_account_idx"] == 0
|
||||
|
||||
sm2.remove_relay(1) # remove first default relay
|
||||
assert len(sm2.list_relays()) == len(DEFAULT_RELAYS)
|
||||
|
Reference in New Issue
Block a user