Merge pull request #724 from PR0M3TH3AN/codex/replace-random.random-with-cryptographic-shuffle

Use HMAC-based deterministic shuffle
This commit is contained in:
thePR0M3TH3AN
2025-08-03 09:27:11 -04:00
committed by GitHub
3 changed files with 103 additions and 18 deletions

View File

@@ -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 FisherYates 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 FisherYates 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)
@@ -457,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
@@ -489,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))

View File

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

View File

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