From 4f09ad5c268948c6fdaba74478887796398caeb1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 3 Aug 2025 09:15:43 -0400 Subject: [PATCH 1/2] Use HMAC-based deterministic shuffle --- src/seedpass/core/password_generation.py | 53 ++++++++++++++----- .../test_password_shuffle_consistency.py | 32 +++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 src/tests/test_password_shuffle_consistency.py diff --git a/src/seedpass/core/password_generation.py b/src/seedpass/core/password_generation.py index 4044c5c..1c166b6 100644 --- a/src/seedpass/core/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -11,13 +11,18 @@ Ensure that all dependencies are installed and properly configured in your envir Never ever ever use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed. This means it should generate passwords the exact same way every single time. Salts would break this functionality and is not appropriate for this software's use case. +To keep behaviour stable across Python versions, the shuffling logic uses an +HMAC-SHA256-based Fisher–Yates shuffle instead of ``random.Random``. The HMAC +is keyed with the derived password bytes, providing deterministic yet +cryptographically strong pseudo-randomness without relying on Python's +non-stable random implementation. """ import os import logging import hashlib import string -import random +import hmac import traceback import base64 from typing import Optional @@ -145,14 +150,31 @@ class PasswordGenerator: logger.debug(f"Password after mapping to all allowed characters: {password}") return password + def _fisher_yates_hmac(self, items: list[str], key: bytes) -> list[str]: + """Shuffle ``items`` in a deterministic yet cryptographically sound manner. + + A Fisher–Yates shuffle is driven by an HMAC-SHA256 based + pseudo-random number generator seeded with ``key``. Unlike + :class:`random.Random`, this approach is stable across Python + versions while still deriving all of its entropy from ``key``. + """ + + counter = 0 + for i in range(len(items) - 1, 0, -1): + msg = counter.to_bytes(4, "big") + digest = hmac.new(key, msg, hashlib.sha256).digest() + j = int.from_bytes(digest, "big") % (i + 1) + items[i], items[j] = items[j], items[i] + counter += 1 + return items + def _shuffle_deterministically(self, password: str, dk: bytes) -> str: - """Deterministically shuffle characters using derived bytes.""" - shuffle_seed = int.from_bytes(dk, "big") - rng = random.Random(shuffle_seed) + """Deterministically shuffle characters using an HMAC-based PRNG.""" + password_chars = list(password) - rng.shuffle(password_chars) - shuffled = "".join(password_chars) - logger.debug("Shuffled password deterministically.") + shuffled_chars = self._fisher_yates_hmac(password_chars, dk) + shuffled = "".join(shuffled_chars) + logger.debug("Shuffled password deterministically using HMAC-Fisher-Yates.") return shuffled def generate_password( @@ -396,13 +418,16 @@ class PasswordGenerator: f"Assigned special character '{char}' to position {j}." ) - # Shuffle again to distribute the characters more evenly - shuffle_seed = ( - int.from_bytes(dk, "big") + dk_index - ) # Modify seed to vary shuffle - rng = random.Random(shuffle_seed) - rng.shuffle(password_chars) - logger.debug(f"Shuffled password characters for balanced distribution.") + # Shuffle again to distribute the characters more evenly. The key is + # tweaked with the current ``dk_index`` so that each call produces a + # unique but deterministic ordering. + shuffle_key = hmac.new( + dk, dk_index.to_bytes(4, "big"), hashlib.sha256 + ).digest() + password_chars = self._fisher_yates_hmac(password_chars, shuffle_key) + logger.debug( + "Shuffled password characters for balanced distribution using HMAC-Fisher-Yates." + ) # Final counts after modifications final_upper = sum(1 for c in password_chars if c in uppercase) diff --git a/src/tests/test_password_shuffle_consistency.py b/src/tests/test_password_shuffle_consistency.py new file mode 100644 index 0000000..78f4235 --- /dev/null +++ b/src/tests/test_password_shuffle_consistency.py @@ -0,0 +1,32 @@ +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy + + +class DummyEnc: + def derive_seed_from_mnemonic(self, mnemonic): + return b"\x00" * 32 + + +class DummyBIP85: + def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: + return bytes((index + i) % 256 for i in range(bytes_len)) + + +def make_generator(): + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() + return pg + + +def test_password_generation_consistent_output(): + pg = make_generator() + expected = "0j6R3e-%4xN@N{Jb" + assert pg.generate_password(length=16, index=1) == expected + # Generating again should produce the same password + assert pg.generate_password(length=16, index=1) == expected From aad41929bff0dba74d466f82d962b2abedcff4e7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 3 Aug 2025 09:24:50 -0400 Subject: [PATCH 2/2] Use HMAC DRNG for RSA PGP keys --- src/seedpass/core/password_generation.py | 18 ++++++++++++++---- src/tests/test_pgp_entry.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/seedpass/core/password_generation.py b/src/seedpass/core/password_generation.py index 1c166b6..43b1800 100644 --- a/src/seedpass/core/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -482,7 +482,13 @@ def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str: def derive_pgp_key( bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = "" ) -> tuple[str, str]: - """Derive a deterministic PGP private key and return it with its fingerprint.""" + """Derive a deterministic PGP private key and return it with its fingerprint. + + For RSA keys the randomness required during key generation is provided by + an HMAC-SHA256 based deterministic generator seeded from the BIP-85 + entropy. This avoids use of Python's ``random`` module while ensuring the + output remains stable across Python versions. + """ from pgpy import PGPKey, PGPUID from pgpy.packet.packets import PrivKeyV4 @@ -514,14 +520,18 @@ def derive_pgp_key( if key_type.lower() == "rsa": class DRNG: + """HMAC-SHA256 based deterministic random generator.""" + def __init__(self, seed: bytes) -> None: - self.seed = seed + self.key = seed + self.counter = 0 def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic out = b"" while len(out) < n: - self.seed = hashlib.sha256(self.seed).digest() - out += self.seed + msg = self.counter.to_bytes(4, "big") + out += hmac.new(self.key, msg, hashlib.sha256).digest() + self.counter += 1 return out[:n] rsa_key = RSA.generate(2048, randfunc=DRNG(entropy)) diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py index c1fd37f..9cb7c28 100644 --- a/src/tests/test_pgp_entry.py +++ b/src/tests/test_pgp_entry.py @@ -39,3 +39,21 @@ def test_pgp_key_determinism(): entry = data["entries"][str(idx)] assert entry["key_type"] == "ed25519" assert entry["user_id"] == "Test" + + +def test_pgp_rsa_key_determinism(): + """RSA PGP keys should be derived deterministically.""" + + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + idx = entry_mgr.add_pgp_key("pgp", TEST_SEED, key_type="rsa", user_id="Test") + key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED) + key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED) + + assert fp1 == fp2 + assert key1 == key2