Merge pull request #840 from PR0M3TH3AN/codex/increment-nostr-account-derivation

feat: track nostr account index per seed
This commit is contained in:
thePR0M3TH3AN
2025-08-20 22:22:19 -04:00
committed by GitHub
16 changed files with 86 additions and 30 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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