mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 08:22:03 +00:00
Version password generation; freeze v1, add v2 without the class quota
Passwords are never stored -- they are re-derived on demand from (seed, index, length, policy) -- and entries carried no algorithm version. Any change to generate_password therefore changed every existing password in every vault, unrecoverably. This adds the version field that makes such changes possible at all, then makes three. Safety net first (M4). test_entropy_integrity.py freezes 18 v1 vectors spanning every policy and both sides of the 32-byte stream-wrap boundary, and asserts the RNG fails closed: os.urandom raising must abort seed generation, short reads must abort, AES-GCM nonces must be fresh. Each guard was mutation-verified rather than assumed -- removing _balance_distribution, perturbing the stream wrap, or altering the character mapping each turns all 18 vectors red, and adding a hash-of-timestamp fallback to generate_bip85_seed turns the fail-closed test red. generate_password now takes gen_version, defaulting to 1. Existing entries carry no field and keep deriving v1 byte-identically; the argument is only forwarded when non-legacy, so all 13 call sites across CLI, API and both TUIs are untouched. New password entries are stamped gen_version 2. An unreadable version raises rather than guessing which algorithm produced a password. v2 fixes three findings: - M1: drops the forced equal-quarters class quota. 1 -> 82 distinct class compositions at length 16 over 200 samples; per-character entropy 6.42 -> 6.53 bits against a 6.555 ceiling. - M2: rejection sampling instead of byte % len(alphabet). Chi-square 96.1 against df=93 (p<0.001 critical value ~147). - M3: an unbounded HMAC-expanded stream instead of cycling a 32-byte key, which was a repeating pad for passwords over 32 characters. Lag-32 repeat rate now sits within one standard error of chance. Dropping the quota removed an accidental backstop: v1's minimum enforcement overwrites uniformly random positions, so raising one class minimum could destroy another, and only the quota's near-total rewrite hid it. v2 chooses donor positions solely from classes that currently have a surplus, and rejects a policy whose minima cannot fit the requested length. Also removes a dead HKDF derivation whose result was discarded while the docstrings named it as the algorithm in use (L1); PBKDF2 was and remains the real one. Verified as a negative control -- the vectors stayed green. Six test doubles are updated to the real generate_password signature; they began receiving gen_version once new entries carried it. Existing entries stay on v1 by design. An opt-in per-entry upgrade, with a confirmation making the consequence unmissable, is still to come. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
This commit is contained in:
1 parent
44935219d1
commit
2aed95a687
11 files changed
+755
-51
No files matched your search
@@ -2,6 +2,53 @@
|
||||
|
||||
This file tracks the remaining work from the latest bug search and security evaluation.
|
||||
|
||||
## Entropy audit (2026-07-31)
|
||||
|
||||
[`docs/entropy_audit_2026-07-31.md`](docs/entropy_audit_2026-07-31.md) — RNG and
|
||||
randomness-integration audit. **No predictable-fallback defect:** every non-deterministic secret
|
||||
uses `os.urandom` / `secrets`, and the `random` module is not imported anywhere in non-test source.
|
||||
|
||||
**Read the compatibility constraint in that document before touching
|
||||
`core/password_generation.py`.** Passwords are never stored — they are re-derived on demand from
|
||||
`(seed, index, length, policy)`, and entries carry no algorithm version. Any change to
|
||||
`generate_password` silently changes every existing password in every vault, unrecoverably. All
|
||||
fixes land behind a new `gen_version` field; v1 output must stay byte-identical.
|
||||
|
||||
- [x] **M4 (done)** — v1 password vectors frozen, plus fail-closed RNG tests, in
|
||||
`src/tests/test_entropy_integrity.py` (26 tests, inside the `--determinism-only` CI gate).
|
||||
18 vectors cover every policy and both sides of the 32-byte stream-wrap boundary.
|
||||
Mutation-verified: removing `_balance_distribution` (M1), altering the `DeterministicStream`
|
||||
wrap (M3) and changing the character mapping (M2) each turn all 18 red; adding a
|
||||
hash-of-timestamp fallback to `generate_bip85_seed` turns the fail-closed test red.
|
||||
**M1/M2/M3 are now safe to attempt.**
|
||||
- [x] **L1 (done)** — deleted the dead HKDF block in `_derive_password_entropy`, dropped the three
|
||||
now-unused imports (`HKDF`, `hashes`, `default_backend`), and corrected the docstrings that
|
||||
named HKDF instead of PBKDF2. Verified as a negative control: all 18 v1 vectors stayed green,
|
||||
confirming the harness does not produce false alarms on behaviour-preserving edits.
|
||||
- [x] **M1 / M2 / M3 (done)** — password generation is now versioned. `gen_version` defaults to 1
|
||||
everywhere, so existing entries and every untouched caller keep deriving v1 byte-identically;
|
||||
new password entries are stamped `gen_version: 2`. v2 drops the forced equal-quarters class
|
||||
quota (M1), draws by rejection sampling (M2) from an unbounded HMAC-expanded stream (M3), and
|
||||
enforces only the policy minima — choosing donor positions from classes that have a surplus,
|
||||
so raising one minimum can no longer break another.
|
||||
|
||||
Measured at length 16 over 200 samples: v1 produced **1** distinct class composition, v2
|
||||
produces **82**. Per-character entropy 6.42 → 6.53 bits against a 6.555 ceiling. Chi-square
|
||||
96.1 (df=93) confirms the modulo bias is gone; lag-32 repeat rate sits within one standard
|
||||
error of chance, confirming the repeating pad is gone.
|
||||
- [ ] **Opt-in upgrade UX for existing entries** — a per-entry action that re-derives at v2, with a
|
||||
confirmation making the consequence unmissable ("this changes the password; update it at the
|
||||
site first"). Never bulk-migrate. Until this ships, existing entries stay on v1 by design.
|
||||
- [ ] **L2** — master seed is `os.urandom(32)` reduced to a hardcoded 12-word (128-bit) mnemonic
|
||||
while derived seeds default to 24 words. Not a defect; offer 24 words at profile creation.
|
||||
- [ ] **L4** — `torch/`: predictable temp filename (`services/memory/index.js:60`), a `Math.random`
|
||||
value misnamed `nonce` (`relay-health.mjs:95`), and three stale `_backups/` code copies that
|
||||
pollute security greps.
|
||||
- [ ] **L5** — no CycloneDX SBOM; folds into the supply-chain item below.
|
||||
|
||||
L3 (broad `except Exception` around crypto — these re-raise, so no silent fallback) overlaps the
|
||||
Robustness item further down.
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] Complete checklist item #8 (Supply chain and release integrity):
|
||||
|
||||
@@ -283,6 +283,10 @@ class EntryManager:
|
||||
:return: The assigned index of the new entry.
|
||||
"""
|
||||
try:
|
||||
# Local import to match this module's convention for
|
||||
# password_generation (keeps its heavy deps off the import path).
|
||||
from .password_generation import CURRENT_PASSWORD_GEN_VERSION
|
||||
|
||||
index = self.get_next_index()
|
||||
data = self._load_index()
|
||||
now_unix = self._now_unix()
|
||||
@@ -292,6 +296,10 @@ class EntryManager:
|
||||
entry = {
|
||||
"label": label,
|
||||
"length": length,
|
||||
# Which generation algorithm derives this entry's password.
|
||||
# Entries written before versioning have no field and fall back
|
||||
# to v1, which is frozen. See docs/entropy_audit_2026-07-31.md.
|
||||
"gen_version": CURRENT_PASSWORD_GEN_VERSION,
|
||||
"username": username if username else "",
|
||||
"url": url if url else "",
|
||||
"archived": archived,
|
||||
|
||||
@@ -34,7 +34,10 @@ from constants import MAX_RETRIES, RETRY_DELAY
|
||||
|
||||
from .encryption import EncryptionManager
|
||||
from .entry_management import EntryManager
|
||||
from .password_generation import PasswordGenerator
|
||||
from .password_generation import (
|
||||
PasswordGenerator,
|
||||
LEGACY_PASSWORD_GEN_VERSION,
|
||||
)
|
||||
from .backup import BackupManager
|
||||
from .vault import Vault
|
||||
from .portable_backup import export_backup, import_backup, PortableMode
|
||||
@@ -3140,14 +3143,34 @@ class PasswordManager:
|
||||
def _generate_password_for_entry(
|
||||
self, entry: dict, index: int, length: int | None = None
|
||||
) -> str:
|
||||
"""Generate a password for ``entry`` honoring any policy overrides."""
|
||||
"""Generate a password for ``entry`` honoring policy and generation version."""
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
if length is None:
|
||||
length = int(entry.get("length", DEFAULT_PASSWORD_LENGTH))
|
||||
overrides = entry.get("policy", {})
|
||||
|
||||
# Entries written before versioning carry no field and must keep
|
||||
# deriving via v1 -- their stored passwords depend on it.
|
||||
raw_version = entry.get("gen_version", LEGACY_PASSWORD_GEN_VERSION)
|
||||
try:
|
||||
gen_version = int(raw_version)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
f"Entry {index} has an unreadable gen_version {raw_version!r}; "
|
||||
f"refusing to guess which algorithm produced its password."
|
||||
) from None
|
||||
|
||||
# Only forward the argument when it is non-legacy. Keeping the legacy
|
||||
# call shape means every existing caller and test double that defines
|
||||
# generate_password(length, index) keeps working untouched.
|
||||
kwargs = (
|
||||
{} if gen_version == LEGACY_PASSWORD_GEN_VERSION
|
||||
else {"gen_version": gen_version}
|
||||
)
|
||||
|
||||
pg = self.password_generator
|
||||
if not hasattr(pg, "policy") or not isinstance(overrides, dict):
|
||||
return pg.generate_password(length, index)
|
||||
return pg.generate_password(length, index, **kwargs)
|
||||
|
||||
base_policy = pg.policy
|
||||
merged = dataclasses.replace(
|
||||
@@ -3156,7 +3179,7 @@ class PasswordManager:
|
||||
)
|
||||
pg.policy = merged
|
||||
try:
|
||||
return pg.generate_password(length, index)
|
||||
return pg.generate_password(length, index, **kwargs)
|
||||
finally:
|
||||
pg.policy = base_policy
|
||||
|
||||
|
||||
@@ -29,10 +29,8 @@ from dataclasses import dataclass
|
||||
from termcolor import colored
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
from . import compat # noqa: F401
|
||||
@@ -75,8 +73,25 @@ class PasswordPolicy:
|
||||
exclude_ambiguous: bool = False
|
||||
|
||||
|
||||
# Generation versions. Entries record which one produced them; absent means 1.
|
||||
#
|
||||
# v1 -- the original algorithm. Frozen forever: passwords are re-derived on
|
||||
# demand and never stored, so every live vault entry depends on it
|
||||
# reproducing byte-identically. Pinned by
|
||||
# src/tests/test_entropy_integrity.py::test_v1_password_vectors_are_frozen.
|
||||
# v2 -- drops the forced equal-quarters class quota, uses rejection sampling to
|
||||
# remove modulo bias, and draws from an unbounded HMAC-expanded stream.
|
||||
LEGACY_PASSWORD_GEN_VERSION = 1
|
||||
CURRENT_PASSWORD_GEN_VERSION = 2
|
||||
|
||||
|
||||
class DeterministicStream:
|
||||
"""Helper class to manage deterministic stream of values from a derived key."""
|
||||
"""Byte stream for v1: cycles ``dk`` with wraparound.
|
||||
|
||||
Retained unchanged because v1 output depends on it, including the
|
||||
wraparound. v2 uses :class:`ExpandedStream` instead -- wrapping a fixed key
|
||||
is a repeating pad, not a stream.
|
||||
"""
|
||||
|
||||
def __init__(self, dk: bytes):
|
||||
self.dk = dk
|
||||
@@ -93,6 +108,52 @@ class DeterministicStream:
|
||||
return self.index
|
||||
|
||||
|
||||
class ExpandedStream:
|
||||
"""Unbounded deterministic byte stream for v2 (audit item M3).
|
||||
|
||||
Generates SHA-256 blocks on demand as ``HMAC(dk, info || counter)`` rather
|
||||
than cycling a fixed 32-byte key, so it never repeats within any usable
|
||||
password length. ``info`` domain-separates independent streams derived from
|
||||
the same ``dk``.
|
||||
"""
|
||||
|
||||
def __init__(self, dk: bytes, info: bytes = b""):
|
||||
self._key = dk
|
||||
self._info = info
|
||||
self._block = b""
|
||||
self._pos = 0
|
||||
self._counter = 0
|
||||
|
||||
def get_value(self) -> int:
|
||||
if self._pos >= len(self._block):
|
||||
msg = self._info + self._counter.to_bytes(4, "big")
|
||||
self._block = hmac.new(self._key, msg, hashlib.sha256).digest()
|
||||
self._counter += 1
|
||||
self._pos = 0
|
||||
value = self._block[self._pos]
|
||||
self._pos += 1
|
||||
return value
|
||||
|
||||
|
||||
def _uniform_index(stream, max_exclusive: int) -> int:
|
||||
"""Uniform value in ``[0, max_exclusive)`` by rejection sampling.
|
||||
|
||||
Avoids the modulo bias in ``byte % len(alphabet)`` (audit item M2): with a
|
||||
94-character alphabet, plain modulo gives 68 characters probability 3/256
|
||||
and the other 26 only 2/256. Requires an unbounded stream, since rejection
|
||||
consumes a variable number of bytes -- hence :class:`ExpandedStream`.
|
||||
"""
|
||||
if max_exclusive <= 0:
|
||||
raise ValueError("max_exclusive must be positive")
|
||||
if max_exclusive > 256:
|
||||
raise ValueError("_uniform_index supports single-byte ranges only")
|
||||
limit = 256 - (256 % max_exclusive)
|
||||
while True:
|
||||
value = stream.get_value()
|
||||
if value < limit:
|
||||
return value % max_exclusive
|
||||
|
||||
|
||||
class PasswordGenerator:
|
||||
"""
|
||||
PasswordGenerator Class
|
||||
@@ -137,20 +198,19 @@ class PasswordGenerator:
|
||||
raise
|
||||
|
||||
def _derive_password_entropy(self, index: int) -> bytes:
|
||||
"""Derive deterministic entropy for password generation."""
|
||||
"""Derive deterministic entropy for password generation.
|
||||
|
||||
Chain: BIP-85 (64 bytes, app_no=32) -> PBKDF2-HMAC-SHA256 (empty salt,
|
||||
100k iterations) -> 32-byte derived key.
|
||||
|
||||
The empty salt is deliberate: a random salt would break the determinism
|
||||
this whole module exists to provide (see the module docstring). The
|
||||
iteration count is not doing password-stretching work here -- the input
|
||||
is already 64 bytes of high-entropy BIP-85 output, not a human secret.
|
||||
"""
|
||||
entropy = self.bip85.derive_entropy(index=index, entropy_bytes=64, app_no=32)
|
||||
logger.debug("Entropy derived for password generation.")
|
||||
|
||||
hkdf = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=None,
|
||||
info=b"password-generation",
|
||||
backend=default_backend(),
|
||||
)
|
||||
hkdf_derived = hkdf.derive(entropy)
|
||||
logger.debug("Derived key using HKDF.")
|
||||
|
||||
dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000)
|
||||
logger.debug("Derived key using PBKDF2.")
|
||||
return dk
|
||||
@@ -189,22 +249,38 @@ class PasswordGenerator:
|
||||
return shuffled
|
||||
|
||||
def generate_password(
|
||||
self, length: int = DEFAULT_PASSWORD_LENGTH, index: int = 0
|
||||
self,
|
||||
length: int = DEFAULT_PASSWORD_LENGTH,
|
||||
index: int = 0,
|
||||
gen_version: int = LEGACY_PASSWORD_GEN_VERSION,
|
||||
) -> str:
|
||||
"""
|
||||
Generates a deterministic password based on the parent seed, desired length, and index.
|
||||
|
||||
Steps:
|
||||
Common to both versions:
|
||||
1. Derive entropy using BIP-85.
|
||||
2. Use HKDF-HMAC-SHA256 to derive a key from entropy.
|
||||
3. Map the derived key to all allowed characters.
|
||||
4. Ensure the password meets complexity requirements.
|
||||
5. Shuffle the password deterministically based on the derived key.
|
||||
6. Trim or extend the password to the desired length.
|
||||
2. Use PBKDF2-HMAC-SHA256 to derive a 32-byte key from that entropy.
|
||||
|
||||
Version 1 (default, frozen) then:
|
||||
3. Maps derived bytes to characters with ``byte % len(alphabet)``.
|
||||
4. Enforces minimum counts, adds symbols, and forces an equal quota of
|
||||
each character class across the whole password.
|
||||
5. Shuffles, extends/trims to length, re-enforces, shuffles again.
|
||||
|
||||
Version 2 then:
|
||||
3. Draws exactly ``length`` characters by rejection sampling from an
|
||||
unbounded HMAC-expanded stream (no modulo bias, no repeating pad).
|
||||
4. Enforces only the policy minima, without pinning the composition.
|
||||
5. Shuffles once.
|
||||
|
||||
``gen_version`` defaults to 1 so that every caller which does not
|
||||
explicitly opt in keeps deriving exactly what it derived before. Entry
|
||||
records carry the version; see ``_generate_password_for_entry``.
|
||||
|
||||
Parameters:
|
||||
length (int): Desired length of the password.
|
||||
index (int): Index for deriving child entropy.
|
||||
gen_version (int): Generation algorithm version (1 or 2).
|
||||
|
||||
Returns:
|
||||
str: The generated password.
|
||||
@@ -227,25 +303,17 @@ class PasswordGenerator:
|
||||
)
|
||||
|
||||
dk = self._derive_password_entropy(index=index)
|
||||
all_allowed, allowed_special = self._alphabets()
|
||||
|
||||
letters = string.ascii_letters
|
||||
digits = string.digits
|
||||
if gen_version == CURRENT_PASSWORD_GEN_VERSION:
|
||||
return self._generate_password_v2(
|
||||
length, dk, all_allowed, allowed_special
|
||||
)
|
||||
if gen_version != LEGACY_PASSWORD_GEN_VERSION:
|
||||
raise ValueError(
|
||||
f"Unknown password generation version: {gen_version!r}"
|
||||
)
|
||||
|
||||
if self.policy.exclude_ambiguous:
|
||||
ambiguous = "O0Il1"
|
||||
letters = "".join(c for c in letters if c not in ambiguous)
|
||||
digits = "".join(c for c in digits if c not in ambiguous)
|
||||
|
||||
if not self.policy.include_special_chars:
|
||||
allowed_special = ""
|
||||
elif self.policy.allowed_special_chars is not None:
|
||||
allowed_special = self.policy.allowed_special_chars
|
||||
elif self.policy.special_mode == "safe":
|
||||
allowed_special = SAFE_SPECIAL_CHARS
|
||||
else:
|
||||
allowed_special = string.punctuation
|
||||
|
||||
all_allowed = letters + digits + allowed_special
|
||||
password = self._map_entropy_to_chars(dk, all_allowed)
|
||||
password = self._enforce_complexity(
|
||||
password, all_allowed, allowed_special, dk
|
||||
@@ -282,6 +350,139 @@ class PasswordGenerator:
|
||||
print(colored(f"Error: Failed to generate password: {e}", "red"))
|
||||
raise
|
||||
|
||||
def _alphabets(self) -> tuple[str, str]:
|
||||
"""Return ``(all_allowed, allowed_special)`` for the active policy."""
|
||||
letters = string.ascii_letters
|
||||
digits = string.digits
|
||||
|
||||
if self.policy.exclude_ambiguous:
|
||||
ambiguous = "O0Il1"
|
||||
letters = "".join(c for c in letters if c not in ambiguous)
|
||||
digits = "".join(c for c in digits if c not in ambiguous)
|
||||
|
||||
if not self.policy.include_special_chars:
|
||||
allowed_special = ""
|
||||
elif self.policy.allowed_special_chars is not None:
|
||||
allowed_special = self.policy.allowed_special_chars
|
||||
elif self.policy.special_mode == "safe":
|
||||
allowed_special = SAFE_SPECIAL_CHARS
|
||||
else:
|
||||
allowed_special = string.punctuation
|
||||
|
||||
return letters + digits + allowed_special, allowed_special
|
||||
|
||||
def _class_sets(self, allowed_special: str) -> dict[str, str]:
|
||||
"""Character class -> its allowed members under the active policy."""
|
||||
uppercase = string.ascii_uppercase
|
||||
lowercase = string.ascii_lowercase
|
||||
digits = string.digits
|
||||
if self.policy.exclude_ambiguous:
|
||||
ambiguous = "O0Il1"
|
||||
uppercase = "".join(c for c in uppercase if c not in ambiguous)
|
||||
lowercase = "".join(c for c in lowercase if c not in ambiguous)
|
||||
digits = "".join(c for c in digits if c not in ambiguous)
|
||||
return {
|
||||
"upper": uppercase,
|
||||
"lower": lowercase,
|
||||
"digit": digits,
|
||||
"special": allowed_special,
|
||||
}
|
||||
|
||||
def _generate_password_v2(
|
||||
self, length: int, dk: bytes, all_allowed: str, allowed_special: str
|
||||
) -> str:
|
||||
"""Version 2 generation (audit items M1, M2, M3).
|
||||
|
||||
Unlike v1 this does not pin the character-class composition. v1 forced
|
||||
exactly ``length/4`` of each class into every password, which cost
|
||||
5.7-11.5 bits over the usable length range and made any SeedPass
|
||||
password recognisable by its composition alone. v2 enforces only what
|
||||
the policy actually asks for.
|
||||
"""
|
||||
class_sets = self._class_sets(allowed_special)
|
||||
minima = {
|
||||
"upper": self.policy.min_uppercase,
|
||||
"lower": self.policy.min_lowercase,
|
||||
"digit": self.policy.min_digits,
|
||||
"special": self.policy.min_special if allowed_special else 0,
|
||||
}
|
||||
# Drop classes the policy excludes entirely, so they are never required
|
||||
# and never counted as donors.
|
||||
minima = {k: v for k, v in minima.items() if class_sets[k]}
|
||||
|
||||
required = sum(minima.values())
|
||||
if required > length:
|
||||
raise ValueError(
|
||||
f"Policy requires at least {required} characters "
|
||||
f"({', '.join(f'{v} {k}' for k, v in minima.items() if v)}) "
|
||||
f"but the requested length is {length}."
|
||||
)
|
||||
|
||||
char_stream = ExpandedStream(dk, b"seedpass-v2-chars")
|
||||
policy_stream = ExpandedStream(dk, b"seedpass-v2-policy")
|
||||
|
||||
chars = [
|
||||
all_allowed[_uniform_index(char_stream, len(all_allowed))]
|
||||
for _ in range(length)
|
||||
]
|
||||
|
||||
self._enforce_minima_v2(chars, policy_stream, class_sets, minima)
|
||||
|
||||
shuffle_key = hmac.new(dk, b"seedpass-v2-shuffle", hashlib.sha256).digest()
|
||||
chars = self._fisher_yates_hmac(chars, shuffle_key)
|
||||
return "".join(chars)
|
||||
|
||||
@staticmethod
|
||||
def _classify(char: str, class_sets: dict[str, str]) -> str | None:
|
||||
for name, members in class_sets.items():
|
||||
if char in members:
|
||||
return name
|
||||
return None
|
||||
|
||||
def _enforce_minima_v2(
|
||||
self,
|
||||
chars: list[str],
|
||||
stream: "ExpandedStream",
|
||||
class_sets: dict[str, str],
|
||||
minima: dict[str, int],
|
||||
) -> None:
|
||||
"""Raise deficient classes to their minimum without breaking others.
|
||||
|
||||
v1's ``_enforce_minimum_counts`` overwrites uniformly random positions,
|
||||
so satisfying one minimum could destroy another; v1 got away with it
|
||||
only because ``_balance_distribution`` then rewrote nearly every
|
||||
position anyway. v2 has no such backstop, so donor positions are chosen
|
||||
only from classes that currently have a surplus.
|
||||
"""
|
||||
counts = {name: 0 for name in class_sets}
|
||||
for char in chars:
|
||||
name = self._classify(char, class_sets)
|
||||
if name is not None:
|
||||
counts[name] += 1
|
||||
|
||||
for name, minimum in minima.items():
|
||||
while counts[name] < minimum:
|
||||
donors = [
|
||||
i
|
||||
for i, char in enumerate(chars)
|
||||
if (other := self._classify(char, class_sets)) != name
|
||||
and other is not None
|
||||
and counts[other] > minima.get(other, 0)
|
||||
]
|
||||
if not donors:
|
||||
# Unreachable while sum(minima) <= length, which the caller
|
||||
# enforces; kept so a future policy change fails loudly.
|
||||
raise ValueError(
|
||||
f"Cannot satisfy minimum for {name!r} without violating "
|
||||
f"another policy minimum."
|
||||
)
|
||||
position = donors[_uniform_index(stream, len(donors))]
|
||||
donor_class = self._classify(chars[position], class_sets)
|
||||
members = class_sets[name]
|
||||
chars[position] = members[_uniform_index(stream, len(members))]
|
||||
counts[donor_class] -= 1
|
||||
counts[name] += 1
|
||||
|
||||
def _count_char_types(
|
||||
self,
|
||||
password_chars: list[str],
|
||||
|
||||
@@ -15,7 +15,7 @@ from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
def generate_password(self, length: int, index: int) -> str: # noqa: D401
|
||||
def generate_password(self, length: int, index: int, gen_version: int = 1) -> str: # noqa: D401
|
||||
return "pw"
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys, password_manager):
|
||||
pm = password_manager
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw")
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i, gen_version=1: "pw")
|
||||
pm.nostr_client = SimpleNamespace()
|
||||
|
||||
pm.entry_manager.add_entry(
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Entropy and RNG integrity — the compatibility contract for derived secrets.
|
||||
|
||||
Covers audit item M4 from ``docs/entropy_audit_2026-07-31.md``.
|
||||
|
||||
Two distinct jobs live here:
|
||||
|
||||
1. **Frozen v1 password vectors.** SeedPass never stores a password. Every entry
|
||||
is re-derived on demand from ``(seed, index, length, policy)``, and entry
|
||||
records carry no algorithm version. Any change to ``generate_password``
|
||||
therefore changes every existing password in every vault, unrecoverably.
|
||||
These vectors are what live vaults re-derive today.
|
||||
|
||||
**A failure here is never "update the expected value".** It means the change
|
||||
being attempted is vault-breaking. Either revert it, or land it behind a new
|
||||
``gen_version`` while leaving v1 byte-identical -- see M1/M2/M3 in the audit.
|
||||
|
||||
Regenerate these ONLY when introducing a new generation version, and then
|
||||
only by appending a new ``V2_VECTORS`` table; the v1 table below is immutable.
|
||||
|
||||
2. **Fail-closed RNG behaviour.** Nothing else in the suite asserts what happens
|
||||
when secure randomness is unavailable. A deterministic stream passes every
|
||||
self-consistency test we have -- that is precisely how a predictable RNG
|
||||
survives a health check that only asks "does the output look random?".
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import os
|
||||
import string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
from local_bip85.bip85 import BIP85
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.password_generation import (
|
||||
CURRENT_PASSWORD_GEN_VERSION,
|
||||
PasswordGenerator,
|
||||
PasswordPolicy,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.determinism
|
||||
|
||||
|
||||
# BIP-39 canonical all-"abandon" test vector. Public, never used for real funds.
|
||||
TEST_MNEMONIC = (
|
||||
"abandon abandon abandon abandon abandon abandon "
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
)
|
||||
|
||||
|
||||
class _SeedDeriver:
|
||||
"""Mirrors EncryptionManager.derive_seed_from_mnemonic without a vault."""
|
||||
|
||||
def derive_seed_from_mnemonic(self, mnemonic, passphrase=""):
|
||||
return Bip39SeedGenerator(mnemonic).Generate(passphrase)
|
||||
|
||||
|
||||
POLICIES = {
|
||||
"default": PasswordPolicy(),
|
||||
"exclude_ambiguous": PasswordPolicy(exclude_ambiguous=True),
|
||||
"safe_special": PasswordPolicy(special_mode="safe"),
|
||||
"no_special": PasswordPolicy(include_special_chars=False),
|
||||
"custom_special": PasswordPolicy(allowed_special_chars="!@#"),
|
||||
"high_minima": PasswordPolicy(
|
||||
min_uppercase=5, min_lowercase=5, min_digits=5, min_special=5
|
||||
),
|
||||
}
|
||||
|
||||
# (policy_name, length, index, expected_password)
|
||||
#
|
||||
# Lengths 8 and 128 are MIN/MAX_PASSWORD_LENGTH; 16 is the default. 33, 40, 64
|
||||
# and 128 all cross the 32-byte DeterministicStream wrap boundary, so they pin
|
||||
# the behaviour audit item M3 proposes to change.
|
||||
V1_VECTORS = [
|
||||
("default", 8, 0, "d]C}13hI"),
|
||||
("default", 16, 0, "Cq5kO%t>0O3$0r$Q"),
|
||||
("default", 16, 1, '221x&^bX1qB"GDm~'),
|
||||
("default", 16, 7, "E1v%%o&3/npC0LO5"),
|
||||
("default", 16, 4095, "e[5D^q62SEmJ2l%>"),
|
||||
("default", 20, 3, "92mT1~D3<NgQf9<>#Wxd"),
|
||||
("default", 32, 2, "y9A2=&CKK@[Wgg>902Aqu)4JuG9>c'j2"),
|
||||
("default", 33, 2, "0g&L4aAj\\;g2o62:V4,r9k%XV]Jz-DZq5"),
|
||||
(
|
||||
"default",
|
||||
64,
|
||||
5,
|
||||
"SbtiQwfLDa6t[911Rq1]9G1?7M54(?2_M8h=o,(e]V@17Vz)FI@C1n_v-VaMcA5$",
|
||||
),
|
||||
(
|
||||
"default",
|
||||
128,
|
||||
11,
|
||||
"%3OBz\\zbo?=ads|8y`LrG^FdWE572Cfb:TB939%'0HUn340pB&\\?7M?EN5O1*P.5"
|
||||
"GBE7.=h4<Ft-UoDs3Xm&/8r0Ag^}Jr[3[f306oZ^$*e-44y3w6`ovDut:T3p01WY",
|
||||
),
|
||||
("exclude_ambiguous", 16, 0, "2t5V(h$6}yCtU2X>"),
|
||||
("exclude_ambiguous", 40, 2, 'wue$:"}JY2.9AQx\\RKn2:62,k9Z8<67j3fPYXzv@'),
|
||||
("safe_special", 16, 0, "Cq5kO_t-0O3-0r?Q"),
|
||||
("safe_special", 40, 2, "na_4qOEbr-s1L2nZ7$*6Gj4A@+DQ5f@49X+V0?y#"),
|
||||
("no_special", 16, 0, "03RrqOTi4NC1hT0r"),
|
||||
("no_special", 40, 2, "6gGz4Aru62y4is90OzDq4QX57XZqgGVLjV25g9hY"),
|
||||
("custom_special", 16, 0, "3q1O#z@4@rCrH0T#"),
|
||||
("high_minima", 24, 0, "Cc+r=_R3hrI01i0q#C(O*T01"),
|
||||
]
|
||||
|
||||
|
||||
def _generator(policy: PasswordPolicy) -> PasswordGenerator:
|
||||
seed = Bip39SeedGenerator(TEST_MNEMONIC).Generate()
|
||||
return PasswordGenerator(
|
||||
_SeedDeriver(), TEST_MNEMONIC, BIP85(seed), policy=policy
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Frozen v1 derivation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy_name,length,index,expected", V1_VECTORS)
|
||||
def test_v1_password_vectors_are_frozen(policy_name, length, index, expected):
|
||||
"""Live vaults re-derive these exact strings. Do not update on failure.
|
||||
|
||||
See this module's docstring before changing anything here.
|
||||
"""
|
||||
pg = _generator(POLICIES[policy_name])
|
||||
assert pg.generate_password(length=length, index=index) == expected, (
|
||||
f"v1 derivation changed for policy={policy_name} length={length} "
|
||||
f"index={index}. This breaks every existing vault entry. Revert the "
|
||||
f"change, or land it behind a new gen_version leaving v1 untouched."
|
||||
)
|
||||
|
||||
|
||||
def test_v1_vectors_cover_the_stream_wrap_boundary():
|
||||
"""Guards the guard: M3's fix must be caught by the table above.
|
||||
|
||||
DeterministicStream wraps a 32-byte key. If every vector were <= 32
|
||||
characters, changing the wrap behaviour would leave this file green while
|
||||
silently altering longer passwords.
|
||||
"""
|
||||
assert any(length > 32 for _, length, _, _ in V1_VECTORS)
|
||||
|
||||
|
||||
def test_v1_vectors_cover_every_policy():
|
||||
assert {name for name, _, _, _ in V1_VECTORS} == set(POLICIES)
|
||||
|
||||
|
||||
def test_derivation_is_stable_across_generator_instances():
|
||||
"""A fresh generator over the same seed must reproduce the same password."""
|
||||
first = _generator(POLICIES["default"]).generate_password(length=24, index=9)
|
||||
second = _generator(POLICIES["default"]).generate_password(length=24, index=9)
|
||||
assert first == second
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Fail-closed RNG behaviour
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_seed_generation_aborts_when_urandom_fails(monkeypatch):
|
||||
"""No secure entropy must mean no seed -- never a substituted value."""
|
||||
from seedpass.core import manager as manager_module
|
||||
|
||||
def _explode(_n):
|
||||
raise OSError("entropy pool unavailable")
|
||||
|
||||
monkeypatch.setattr(manager_module.os, "urandom", _explode)
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
manager_module.PasswordManager.generate_bip85_seed(object())
|
||||
|
||||
# The failure must propagate. A returned mnemonic here would mean the
|
||||
# generator fell back to something -- the exact COLDCARD defect class.
|
||||
assert "entropy pool unavailable" in str(excinfo.value) or isinstance(
|
||||
excinfo.value.__cause__, OSError
|
||||
)
|
||||
|
||||
|
||||
def test_seed_generation_aborts_on_short_read(monkeypatch):
|
||||
"""A partial entropy read must not be silently accepted as a full seed."""
|
||||
from seedpass.core import manager as manager_module
|
||||
|
||||
monkeypatch.setattr(manager_module.os, "urandom", lambda n: b"\x2b" * 4)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
manager_module.PasswordManager.generate_bip85_seed(object())
|
||||
|
||||
|
||||
def test_all_zero_entropy_is_not_currently_detected(monkeypatch):
|
||||
"""Recorded gap, not an endorsement.
|
||||
|
||||
If os.urandom returned all zeros, SeedPass would mint a valid-looking
|
||||
mnemonic from it. There is no health check on the entropy source. This test
|
||||
pins the CURRENT behaviour so that adding such a check is a deliberate,
|
||||
visible change rather than an accident -- and so the gap cannot be forgotten.
|
||||
|
||||
Tracked as part of M4 in docs/entropy_audit_2026-07-31.md. Realistically low
|
||||
risk: on Linux os.urandom is getrandom(2), which does not return zeros post
|
||||
boot. The threat this guards is a future code change, not a kernel failure.
|
||||
"""
|
||||
from seedpass.core import manager as manager_module
|
||||
|
||||
monkeypatch.setattr(manager_module.os, "urandom", lambda n: b"\x00" * n)
|
||||
|
||||
mnemonic = manager_module.PasswordManager.generate_bip85_seed(object())
|
||||
assert len(mnemonic.split()) == 12 # currently accepted; see docstring
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Version 2 properties (audit items M1, M2, M3)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _v2(length, index, policy=None):
|
||||
return _generator(policy or POLICIES["default"]).generate_password(
|
||||
length=length, index=index, gen_version=CURRENT_PASSWORD_GEN_VERSION
|
||||
)
|
||||
|
||||
|
||||
def _classes(pw):
|
||||
return (
|
||||
sum(c in string.ascii_uppercase for c in pw),
|
||||
sum(c in string.ascii_lowercase for c in pw),
|
||||
sum(c in string.digits for c in pw),
|
||||
sum(not c.isalnum() for c in pw),
|
||||
)
|
||||
|
||||
|
||||
def test_v2_does_not_pin_the_class_composition():
|
||||
"""The core of M1: v1 produced exactly one composition, always.
|
||||
|
||||
v1 forced length/4 of each class into every password, making any SeedPass
|
||||
password identifiable from its composition alone. v2 must vary.
|
||||
"""
|
||||
pg = _generator(POLICIES["default"])
|
||||
v1 = {
|
||||
_classes(pg.generate_password(length=16, index=i, gen_version=1))
|
||||
for i in range(120)
|
||||
}
|
||||
v2 = {
|
||||
_classes(
|
||||
pg.generate_password(
|
||||
length=16, index=i, gen_version=CURRENT_PASSWORD_GEN_VERSION
|
||||
)
|
||||
)
|
||||
for i in range(120)
|
||||
}
|
||||
assert len(v1) == 1, "v1 is expected to be degenerate; that is the finding"
|
||||
assert len(v2) > 20, f"v2 composition still too rigid: {len(v2)} distinct"
|
||||
|
||||
|
||||
def test_v2_still_satisfies_the_policy_minima():
|
||||
for length in (8, 16, 33, 64):
|
||||
for index in range(25):
|
||||
upper, lower, digits, special = _classes(_v2(length, index))
|
||||
assert upper >= 2 and lower >= 2 and digits >= 2 and special >= 2
|
||||
|
||||
|
||||
def test_v2_respects_non_default_policies():
|
||||
for name in ("exclude_ambiguous", "safe_special", "no_special", "custom_special"):
|
||||
policy = POLICIES[name]
|
||||
for index in range(10):
|
||||
pw = _v2(24, index, policy)
|
||||
assert len(pw) == 24
|
||||
if name == "no_special":
|
||||
assert all(c.isalnum() for c in pw)
|
||||
if name == "custom_special":
|
||||
assert all(c.isalnum() or c in "!@#" for c in pw)
|
||||
if name == "exclude_ambiguous":
|
||||
assert not any(c in "O0Il1" for c in pw)
|
||||
|
||||
|
||||
def test_v2_rejects_a_policy_that_cannot_fit():
|
||||
"""sum(minima) > length is impossible; say so instead of silently failing."""
|
||||
policy = PasswordPolicy(
|
||||
min_uppercase=5, min_lowercase=5, min_digits=5, min_special=5
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires at least"):
|
||||
_v2(8, 0, policy)
|
||||
|
||||
|
||||
def test_v2_has_no_modulo_bias():
|
||||
"""M2: chi-square over the 94-char alphabet. df=93, p<0.001 crit ~147."""
|
||||
pws = [_v2(64, i) for i in range(180)]
|
||||
counts = collections.Counter(c for pw in pws for c in pw)
|
||||
alphabet = string.ascii_letters + string.digits + string.punctuation
|
||||
n = sum(counts.values())
|
||||
expected = n / len(alphabet)
|
||||
chi2 = sum((counts.get(ch, 0) - expected) ** 2 / expected for ch in alphabet)
|
||||
assert chi2 < 147, f"character distribution looks biased (chi2={chi2:.1f})"
|
||||
|
||||
|
||||
def test_v2_stream_does_not_repeat_at_the_32_byte_boundary():
|
||||
"""M3: v1 cycled a 32-byte key, so position i and i+32 shared a source byte."""
|
||||
pws = [_v2(128, i) for i in range(80)]
|
||||
matches = sum(1 for pw in pws for i in range(len(pw) - 32) if pw[i] == pw[i + 32])
|
||||
total = sum(len(pw) - 32 for pw in pws)
|
||||
rate = matches / total
|
||||
chance = 1 / 94
|
||||
# Generous band: this catches a repeating pad (which would spike the rate),
|
||||
# not small sampling noise.
|
||||
assert rate < chance * 2.5, f"lag-32 repeat rate {rate:.5f} vs chance {chance:.5f}"
|
||||
|
||||
|
||||
def test_v2_is_deterministic_across_instances():
|
||||
assert _v2(24, 9) == _v2(24, 9)
|
||||
|
||||
|
||||
def test_v2_differs_from_v1():
|
||||
pg = _generator(POLICIES["default"])
|
||||
assert pg.generate_password(length=24, index=9, gen_version=1) != _v2(24, 9)
|
||||
|
||||
|
||||
def test_unknown_generation_version_is_rejected():
|
||||
"""Never guess which algorithm produced a password."""
|
||||
pg = _generator(POLICIES["default"])
|
||||
for bad in (0, 3, 99):
|
||||
with pytest.raises(ValueError, match="Unknown password generation version"):
|
||||
pg.generate_password(length=16, index=0, gen_version=bad)
|
||||
|
||||
|
||||
def test_default_gen_version_is_legacy():
|
||||
"""Any caller that does not opt in must keep deriving v1."""
|
||||
pg = _generator(POLICIES["default"])
|
||||
assert pg.generate_password(length=16, index=0) == pg.generate_password(
|
||||
length=16, index=0, gen_version=1
|
||||
)
|
||||
|
||||
|
||||
def test_new_entries_are_stamped_with_the_current_version():
|
||||
from seedpass.core.password_generation import CURRENT_PASSWORD_GEN_VERSION as cur
|
||||
|
||||
assert cur == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Entry -> version plumbing (the part that protects existing vaults)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _manager_with(pg):
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
pm = PasswordManager.__new__(PasswordManager)
|
||||
pm.password_generator = pg
|
||||
return pm
|
||||
|
||||
|
||||
def test_entry_without_gen_version_still_derives_v1():
|
||||
"""The single most important behaviour: existing vaults must not change.
|
||||
|
||||
Entries written before versioning carry no field. They must keep deriving
|
||||
through v1 forever.
|
||||
"""
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
pg = _generator(POLICIES["default"])
|
||||
pm = _manager_with(pg)
|
||||
got = PasswordManager._generate_password_for_entry(pm, {"length": 16}, 3)
|
||||
assert got == pg.generate_password(16, 3, gen_version=1)
|
||||
|
||||
|
||||
def test_entry_with_gen_version_2_derives_v2():
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
pg = _generator(POLICIES["default"])
|
||||
pm = _manager_with(pg)
|
||||
entry = {"length": 16, "gen_version": CURRENT_PASSWORD_GEN_VERSION}
|
||||
got = PasswordManager._generate_password_for_entry(pm, entry, 3)
|
||||
assert got == pg.generate_password(
|
||||
16, 3, gen_version=CURRENT_PASSWORD_GEN_VERSION
|
||||
)
|
||||
assert got != pg.generate_password(16, 3, gen_version=1)
|
||||
|
||||
|
||||
def test_policy_override_still_applies_on_a_versioned_entry():
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
pg = _generator(POLICIES["default"])
|
||||
pm = _manager_with(pg)
|
||||
entry = {
|
||||
"length": 16,
|
||||
"gen_version": CURRENT_PASSWORD_GEN_VERSION,
|
||||
"policy": {"include_special_chars": False},
|
||||
}
|
||||
got = PasswordManager._generate_password_for_entry(pm, entry, 3)
|
||||
assert got.isalnum()
|
||||
assert pg.policy == PasswordPolicy(), "base policy must be restored"
|
||||
|
||||
|
||||
def test_unreadable_entry_gen_version_fails_loudly():
|
||||
"""Never guess which algorithm produced an entry's password."""
|
||||
from seedpass.core.manager import PasswordManager
|
||||
|
||||
pm = _manager_with(_generator(POLICIES["default"]))
|
||||
with pytest.raises(ValueError, match="unreadable gen_version"):
|
||||
PasswordManager._generate_password_for_entry(
|
||||
pm, {"length": 16, "gen_version": "??"}, 3
|
||||
)
|
||||
|
||||
|
||||
def test_encryption_nonce_is_fresh_per_call(tmp_path):
|
||||
"""AES-GCM nonce reuse under one key is catastrophic, not gradual."""
|
||||
key = base64.urlsafe_b64encode(os.urandom(32))
|
||||
em = EncryptionManager(key, tmp_path)
|
||||
|
||||
payloads = [em.encrypt_data(b"identical plaintext") for _ in range(64)]
|
||||
nonces = {blob[3:15] for blob in payloads} # b"V3|" + 12-byte nonce
|
||||
|
||||
assert len(nonces) == 64, "AES-GCM nonce repeated across encrypt_data calls"
|
||||
assert len({bytes(b) for b in payloads}) == 64
|
||||
assert all(blob.startswith(b"V3|") for blob in payloads)
|
||||
assert em.decrypt_data(payloads[0]) == b"identical plaintext"
|
||||
|
||||
|
||||
def test_totp_secret_is_fresh_per_call():
|
||||
from seedpass.core.totp import random_totp_secret
|
||||
|
||||
secrets_seen = {random_totp_secret() for _ in range(64)}
|
||||
assert len(secrets_seen) == 64
|
||||
@@ -17,7 +17,7 @@ from constants import DEFAULT_PASSWORD_LENGTH
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
def generate_password(self, length: int, index: int) -> str: # noqa: D401
|
||||
def generate_password(self, length: int, index: int, gen_version: int = 1) -> str: # noqa: D401
|
||||
return f"pw-{index}-{length}"
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ def test_show_entry_details_by_index(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.confirm_action", lambda *a, **k: False
|
||||
)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i, gen_version=1: "pw123")
|
||||
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
|
||||
|
||||
pm.show_entry_details_by_index(index)
|
||||
@@ -177,7 +177,7 @@ def _detail_common(monkeypatch, pm):
|
||||
monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b")
|
||||
monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None)
|
||||
monkeypatch.setattr(pm, "notify", lambda *a, **k: None)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i, gen_version=1: "pw123")
|
||||
called = []
|
||||
monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: called.append(True))
|
||||
return called
|
||||
@@ -295,7 +295,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
pm, entry_mgr = _setup_manager(tmp_path)
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123")
|
||||
pm.password_generator = SimpleNamespace(generate_password=lambda l, i, gen_version=1: "pw123")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.confirm_action", lambda *a, **k: True
|
||||
|
||||
@@ -13,7 +13,7 @@ from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
def generate_password(self, length: int, index: int) -> str: # noqa: D401
|
||||
def generate_password(self, length: int, index: int, gen_version: int = 1) -> str: # noqa: D401
|
||||
return f"pw-{index}-{length}"
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from constants import DEFAULT_PASSWORD_LENGTH
|
||||
|
||||
|
||||
class FakePasswordGenerator:
|
||||
def generate_password(self, length: int, index: int) -> str:
|
||||
def generate_password(self, length: int, index: int, gen_version: int = 1) -> str:
|
||||
return f"pw-{index}-{length}"
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user