diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 90c3301..5e73847 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -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.") diff --git a/src/nostr/client.py b/src/nostr/client.py index 440f42b..ecd8ab0 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -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() diff --git a/src/nostr/key_manager.py b/src/nostr/key_manager.py index c68be10..34aa2b9 100644 --- a/src/nostr/key_manager.py +++ b/src/nostr/key_manager.py @@ -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: diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 590c8b1..d0f07d1 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -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( diff --git a/src/seedpass/core/portable_backup.py b/src/seedpass/core/portable_backup.py index 1f0215b..bc00c9a 100644 --- a/src/seedpass/core/portable_backup.py +++ b/src/seedpass/core/portable_backup.py @@ -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: diff --git a/src/seedpass/core/profile_service.py b/src/seedpass/core/profile_service.py index 3e2681e..821165e 100644 --- a/src/seedpass/core/profile_service.py +++ b/src/seedpass/core/profile_service.py @@ -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" diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py index f2ca11e..74238f5 100644 --- a/src/seedpass/core/state_manager.py +++ b/src/seedpass/core/state_manager.py @@ -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: diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py index f266489..ec346ae 100644 --- a/src/tests/test_background_sync_always.py +++ b/src/tests/test_background_sync_always.py @@ -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( diff --git a/src/tests/test_new_seed_profile_creation.py b/src/tests/test_new_seed_profile_creation.py index b899e7e..3812c2c 100644 --- a/src/tests/test_new_seed_profile_creation.py +++ b/src/tests/test_new_seed_profile_creation.py @@ -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 diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index efe001d..cf149aa 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -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 diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index f8a5916..d0756a2 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -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 diff --git a/src/tests/test_profile_cleanup.py b/src/tests/test_profile_cleanup.py index d85653f..659e705 100644 --- a/src/tests/test_profile_cleanup.py +++ b/src/tests/test_profile_cleanup.py @@ -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) diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index 35e05db..02db4b3 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -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( diff --git a/src/tests/test_seed_generation.py b/src/tests/test_seed_generation.py index eb6b6ca..5922707 100644 --- a/src/tests/test_seed_generation.py +++ b/src/tests/test_seed_generation.py @@ -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 diff --git a/src/tests/test_service_classes.py b/src/tests/test_service_classes.py index 4b75c6d..530e60f 100644 --- a/src/tests/test_service_classes.py +++ b/src/tests/test_service_classes.py @@ -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") diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py index 71abe25..0cd1914 100644 --- a/src/tests/test_state_manager.py +++ b/src/tests/test_state_manager.py @@ -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)