mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Merge pull request #724 from PR0M3TH3AN/codex/replace-random.random-with-cryptographic-shuffle
Use HMAC-based deterministic shuffle
This commit is contained in:
@@ -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)
|
||||
@@ -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))
|
||||
|
32
src/tests/test_password_shuffle_consistency.py
Normal file
32
src/tests/test_password_shuffle_consistency.py
Normal 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
|
@@ -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
|
||||
|
Reference in New Issue
Block a user