mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
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.
|
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.
|
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 os
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import string
|
import string
|
||||||
import random
|
import hmac
|
||||||
import traceback
|
import traceback
|
||||||
import base64
|
import base64
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -145,14 +150,31 @@ class PasswordGenerator:
|
|||||||
logger.debug(f"Password after mapping to all allowed characters: {password}")
|
logger.debug(f"Password after mapping to all allowed characters: {password}")
|
||||||
return 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:
|
def _shuffle_deterministically(self, password: str, dk: bytes) -> str:
|
||||||
"""Deterministically shuffle characters using derived bytes."""
|
"""Deterministically shuffle characters using an HMAC-based PRNG."""
|
||||||
shuffle_seed = int.from_bytes(dk, "big")
|
|
||||||
rng = random.Random(shuffle_seed)
|
|
||||||
password_chars = list(password)
|
password_chars = list(password)
|
||||||
rng.shuffle(password_chars)
|
shuffled_chars = self._fisher_yates_hmac(password_chars, dk)
|
||||||
shuffled = "".join(password_chars)
|
shuffled = "".join(shuffled_chars)
|
||||||
logger.debug("Shuffled password deterministically.")
|
logger.debug("Shuffled password deterministically using HMAC-Fisher-Yates.")
|
||||||
return shuffled
|
return shuffled
|
||||||
|
|
||||||
def generate_password(
|
def generate_password(
|
||||||
@@ -396,13 +418,16 @@ class PasswordGenerator:
|
|||||||
f"Assigned special character '{char}' to position {j}."
|
f"Assigned special character '{char}' to position {j}."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Shuffle again to distribute the characters more evenly
|
# Shuffle again to distribute the characters more evenly. The key is
|
||||||
shuffle_seed = (
|
# tweaked with the current ``dk_index`` so that each call produces a
|
||||||
int.from_bytes(dk, "big") + dk_index
|
# unique but deterministic ordering.
|
||||||
) # Modify seed to vary shuffle
|
shuffle_key = hmac.new(
|
||||||
rng = random.Random(shuffle_seed)
|
dk, dk_index.to_bytes(4, "big"), hashlib.sha256
|
||||||
rng.shuffle(password_chars)
|
).digest()
|
||||||
logger.debug(f"Shuffled password characters for balanced distribution.")
|
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 counts after modifications
|
||||||
final_upper = sum(1 for c in password_chars if c in uppercase)
|
final_upper = sum(1 for c in password_chars if c in uppercase)
|
||||||
|
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
|
Reference in New Issue
Block a user