Use HMAC-based deterministic shuffle

This commit is contained in:
thePR0M3TH3AN
2025-08-03 09:15:43 -04:00
parent 3cdf391742
commit 4f09ad5c26
2 changed files with 71 additions and 14 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)

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