Coverage for tests / test_derivepassphrase_vault.py: 100.000%
238 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Test passphrase generation via derivepassphrase.vault.Vault."""
7from __future__ import annotations
9import array
10import enum
11import hashlib
12import math
13import types
14from typing import TYPE_CHECKING
16import hypothesis
17import pytest
18from hypothesis import strategies
20from derivepassphrase import vault
21from tests.machinery import hypothesis as hypothesis_machinery
23if TYPE_CHECKING:
24 from collections.abc import Callable, Sequence
26 from typing_extensions import Buffer
28BLOCK_SIZE = hashlib.sha1().block_size
29DIGEST_SIZE = hashlib.sha1().digest_size
31PHRASE = b"She cells C shells bye the sea shoars"
32"""The standard passphrase from <i>vault</i>(1)'s test suite."""
33GOOGLE_PHRASE = rb": 4TVH#5:aZl8LueOT\{"
34"""
35The standard derived passphrase for the "google" service, from
36<i>vault</i>(1)'s test suite.
37"""
38TWITTER_PHRASE = rb"[ (HN_N:lI&<ro=)3'g9"
39"""
40The standard derived passphrase for the "twitter" service, from
41<i>vault</i>(1)'s test suite.
42"""
43INTERCHANGABLE_PHRASE1 = (
44 b"plnlrtfpijpuhqylxbgqiiyipieyxvfsavzgxbbcfusqkozwpngsyejqlmjsytrmd"
45)
46"""
47One of two [interchangable passphrases][INTERCHANGABLE_PASSPHRASES],
48based on [Wikipedia's page on PBKDF2 (HMAC collisions)][HMAC_COL].
50[INTERCHANGABLE_PASSPHRASES]: https://the13thletter.info/derivepassphrase/0.x/explanation/faq-vault-interchangable-passphrases/ 'What are "interchangable passphrases" in `vault`, and what does that mean in practice?'
51[HMAC_COL]: https://en.wikipedia.org/w/index.php?title=PBKDF2&oldid=1264881215#HMAC_collisions
52"""
53INTERCHANGABLE_PHRASE2 = b"eBkXQTfuBqp'cTcar&g*"
54"""
55Same purpose as [`INTERCHANGABLE_PHRASE1`][INTERCHANGABLE_PHRASE1].
56"""
58buffer_types: dict[str, Callable[..., Buffer]] = {
59 "bytes": bytes,
60 "bytearray": bytearray,
61 "memoryview": memoryview,
62 "array.array": lambda data: array.array("B", data),
63}
66class Parametrize(types.SimpleNamespace):
67 ENTROPY_RESULTS = pytest.mark.parametrize(
68 ["length", "settings", "entropy"],
69 [
70 (20, {}, math.log2(math.factorial(20)) + 20 * math.log2(94)),
71 (
72 20,
73 {"upper": 0, "number": 0, "space": 0, "symbol": 0},
74 math.log2(math.factorial(20)) + 20 * math.log2(26),
75 ),
76 (0, {}, float("-inf")),
77 (
78 0,
79 {"lower": 0, "number": 0, "space": 0, "symbol": 0},
80 float("-inf"),
81 ),
82 (1, {}, math.log2(94)),
83 (1, {"upper": 0, "lower": 0, "number": 0, "symbol": 0}, 0.0),
84 ],
85 )
86 BINARY_STRINGS = hypothesis_machinery.explicit_examples(
87 "s",
88 [
89 "ñ",
90 "Düsseldorf",
91 "Liberté, égalité, fraternité",
92 "ASCII",
93 "こんにちは。",
94 b"D\xc3\xbcsseldorf",
95 bytearray([2, 3, 5, 7, 11, 13]),
96 ],
97 )
98 SAMPLE_SERVICES_AND_PHRASES = pytest.mark.parametrize(
99 ["service", "expected"],
100 [
101 (b"google", GOOGLE_PHRASE),
102 ("twitter", TWITTER_PHRASE),
103 ],
104 ids=["google", "twitter"],
105 )
108def phrases_are_interchangable(
109 phrase1: Buffer | str,
110 phrase2: Buffer | str,
111 /,
112) -> bool:
113 """Work-alike of [`vault.Vault.phrases_are_interchangable`][].
115 This version is not resistant to timing attacks, but faster, and
116 supports strings directly.
118 Args:
119 phrase1:
120 A passphrase to compare.
121 phrase2:
122 A passphrase to compare.
124 Returns:
125 True if the phrases behave identically under [`vault.Vault`][],
126 false otherwise.
128 """
130 def canon(bs: bytes, /) -> bytes: 1cfed
131 return ( 1cfed
132 hashlib.sha1(bs).digest() + b"\x00" * (BLOCK_SIZE - DIGEST_SIZE)
133 if len(bs) > BLOCK_SIZE
134 else bs.rstrip(b"\x00")
135 )
137 phrase1 = canon(vault.Vault._get_binary_string(phrase1)) 1cfed
138 phrase2 = canon(vault.Vault._get_binary_string(phrase2)) 1cfed
139 return phrase1 == phrase2 1cfed
142class PhraseSize(str, enum.Enum):
143 """Size of the generated phrase.
145 Attributes:
146 SHORT: A phrase shorter than the SHA-1 block size.
147 FULL: A phrase exactly as long as the SHA-1 block size.
148 OVERLONG: A phrase longer than the SHA-1 block size.
149 MIXED: A `SHORT`, `FULL` or `OVERLONG` phrase.
151 """
153 SHORT = enum.auto()
154 """"""
155 FULL = enum.auto()
156 """"""
157 OVERLONG = enum.auto()
158 """"""
159 MIXED = enum.auto()
160 """"""
163class Strategies:
164 """Hypothesis strategies."""
166 @staticmethod
167 def text_strategy() -> strategies.SearchStrategy[str]:
168 """Return a strategy for textual master passphrases or service names."""
169 return strategies.text(
170 strategies.characters(min_codepoint=32, max_codepoint=126),
171 min_size=1,
172 max_size=BLOCK_SIZE // 2,
173 )
175 @strategies.composite
176 @staticmethod
177 def binary_phrase_strategy(
178 draw: strategies.DrawFn, size: PhraseSize = PhraseSize.MIXED
179 ) -> Buffer:
180 """Return a strategy for binary master passphrases.
182 Args:
183 draw:
184 The [strategy drawing
185 function][hypothesis.strategies.composite].
186 size:
187 The desired phrase size.
189 Returns:
190 The strategy.
192 """
193 if size == PhraseSize.MIXED: 1cfed
194 size = draw( 1e
195 strategies.sampled_from([
196 PhraseSize.SHORT,
197 PhraseSize.FULL,
198 PhraseSize.OVERLONG,
199 ]),
200 label="concrete_size",
201 )
202 min_size, max_size = ( 1cfed
203 (1, BLOCK_SIZE // 2)
204 if size == PhraseSize.SHORT
205 else (BLOCK_SIZE, BLOCK_SIZE)
206 if size == PhraseSize.FULL
207 else (BLOCK_SIZE + 1, BLOCK_SIZE + 8)
208 )
209 return draw( 1cfed
210 strategies.binary(min_size=min_size, max_size=max_size),
211 label="phrase",
212 )
214 @strategies.composite
215 @staticmethod
216 def pair_of_binary_phrases_strategy(
217 draw: strategies.DrawFn, size: PhraseSize = PhraseSize.MIXED
218 ) -> tuple[Buffer, Buffer]:
219 """Return a strategy for two non-interchangable binary master passphrases.
221 Args:
222 draw:
223 The [strategy drawing
224 function][hypothesis.strategies.composite].
225 size:
226 The desired phrase size.
228 Returns:
229 The strategy.
231 """
232 phrase1 = draw( 1cfed
233 Strategies.binary_phrase_strategy(size=size), label="phrase1"
234 )
235 phrase2 = draw( 1cfed
236 Strategies.binary_phrase_strategy(size=size).filter(
237 lambda p: not phrases_are_interchangable(phrase1, p)
238 ),
239 label="phrase2",
240 )
241 return (phrase1, phrase2) 1cfed
243 @strategies.composite
244 @staticmethod
245 def make_interchangable_phrases(
246 draw: strategies.DrawFn, phrase: Buffer
247 ) -> tuple[Buffer, Buffer]:
248 """Transform a phrase into a pair of interchangable phrases.
250 For phrases of size 64 (the SHA-1 block size), [in 99.6% of the
251 cases][INTERCHANGABLE_PASSPHRASES], it is infeasible for us to
252 find a second interchangable phrase. (It would be equivalent to
253 mounting a pre-image attack on an SHA-1, a cryptographically
254 infeasible action.) However, in the remaining 0.4% of cases,
255 the phrase of size 64 is padded with NUL bytes at the end, and
256 we can generate the second interchangable phrase by altering the
257 padding. For other phrase sizes, no such problems exist: we can
258 obtain interchangable phrases by adding padding (if the phrase
259 is shorter than 64 bytes) or by computing the SHA-1 value of the
260 phrase (if it is longer than 64 bytes).
262 [INTERCHANGABLE_PASSPHRASES]: https://the13thletter.info/derivepassphrase/0.x/explanation/faq-vault-interchangable-passphrases/ 'What are "interchangable passphrases" in `vault`, and what does that mean in practice?'
264 Args:
265 draw:
266 The [strategy drawing
267 function][hypothesis.strategies.composite].
268 phrase:
269 The first phrase.
271 Returns:
272 The strategy for two interchangable phrases.
274 """
275 p = bytes(phrase) 1cd
276 hypothesis.assume(p.rstrip(b"\x00") != p or len(p) != BLOCK_SIZE) 1cd
277 base = ( 1cd
278 hashlib.sha1(p).digest()
279 if len(p) > BLOCK_SIZE
280 else p.rstrip(b"\x00") or b"\x00"
281 )
282 zero_filled = [ 1cd
283 base + bytes(i)
284 for i in range(BLOCK_SIZE - len(base) + 1)
285 if base + bytes(i) != p
286 ]
287 return (p, draw(strategies.sampled_from(zero_filled))) 1cd
290class TestVault:
291 """Test passphrase derivation with the "vault" scheme."""
293 phrase = PHRASE
296class TestPhraseDependence:
297 """Test the dependence of the internal hash on the master passphrase."""
299 def _test(self, phrases: Sequence[bytes], service: str) -> None:
300 assert vault.Vault.create_hash( 1cfed
301 phrase=phrases[0], service=service
302 ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
304 @hypothesis.given(
305 phrases=Strategies.pair_of_binary_phrases_strategy(
306 size=PhraseSize.SHORT
307 ),
308 service=Strategies.text_strategy(),
309 )
310 @hypothesis.example(phrases=[b"\x00", b"\x00\x00"], service="0").xfail(
311 reason="phrases are interchangable", raises=AssertionError
312 )
313 def test_small(self, phrases: Sequence[bytes], service: str) -> None:
314 """The internal hash is dependent on the master passphrase.
316 We filter out interchangable passphrases during generation.
318 """
319 self._test(phrases, service) 1d
321 @hypothesis.given(
322 phrases=Strategies.pair_of_binary_phrases_strategy(
323 size=PhraseSize.FULL
324 ),
325 service=Strategies.text_strategy(),
326 )
327 @hypothesis.example(
328 phrases=[b"\x01" * DIGEST_SIZE, b"\x01" * DIGEST_SIZE],
329 service="service",
330 ).xfail(reason="phrases are interchangable", raises=AssertionError)
331 def test_medium(self, phrases: Sequence[bytes], service: str) -> None:
332 """The internal hash is dependent on the master passphrase.
334 We filter out interchangable passphrases during generation.
336 """
337 self._test(phrases, service) 1f
339 @hypothesis.given(
340 phrases=Strategies.pair_of_binary_phrases_strategy(
341 size=PhraseSize.OVERLONG
342 ),
343 service=Strategies.text_strategy(),
344 )
345 def test_large(self, phrases: Sequence[bytes], service: str) -> None:
346 """The internal hash is dependent on the master passphrase.
348 We filter out interchangable passphrases during generation.
350 """
351 self._test(phrases, service) 1c
353 @hypothesis.given(
354 phrases=Strategies.pair_of_binary_phrases_strategy(
355 size=PhraseSize.MIXED
356 ),
357 service=Strategies.text_strategy(),
358 )
359 @hypothesis.example(
360 phrases=[INTERCHANGABLE_PHRASE1, INTERCHANGABLE_PHRASE2],
361 service="any service name here",
362 ).xfail(reason="phrases are interchangable", raises=AssertionError)
363 def test_mixed(self, phrases: Sequence[bytes], service: str) -> None:
364 """The internal hash is dependent on the master passphrase.
366 We filter out interchangable passphrases during generation.
368 """
369 self._test(phrases, service) 1e
372class TestServiceNameDependence:
373 """Test the dependence of the internal hash on the service name."""
375 @hypothesis.given(
376 phrase=Strategies.text_strategy(),
377 services=strategies.lists(
378 Strategies.text_strategy(),
379 min_size=2,
380 max_size=2,
381 unique=True,
382 ),
383 )
384 def test_service_name_dependence(
385 self,
386 phrase: str,
387 services: list[bytes],
388 ) -> None:
389 """The internal hash is dependent on the service name."""
390 assert vault.Vault.create_hash( 1s
391 phrase=phrase, service=services[0]
392 ) != vault.Vault.create_hash(phrase=phrase, service=services[1])
395class TestInterchangablePhrases:
396 """Test the interchangability of certain master passphrases."""
398 def _test(self, phrases: Sequence[bytes], service: str) -> None:
399 assert vault.Vault.phrases_are_interchangable(*phrases) 1cd
400 assert vault.Vault.create_hash( 1cd
401 phrase=phrases[0], service=service
402 ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
404 @hypothesis.given(
405 phrases=Strategies.binary_phrase_strategy(
406 size=PhraseSize.SHORT
407 ).flatmap(Strategies.make_interchangable_phrases),
408 service=Strategies.text_strategy(),
409 )
410 def test_small(self, phrases: Sequence[bytes], service: str) -> None:
411 """Claimed interchangable passphrases are actually interchangable."""
412 self._test(phrases, service) 1d
414 @hypothesis.given(
415 phrases=Strategies.binary_phrase_strategy(
416 size=PhraseSize.OVERLONG,
417 ).flatmap(Strategies.make_interchangable_phrases),
418 service=Strategies.text_strategy(),
419 )
420 def test_large(self, phrases: Sequence[bytes], service: str) -> None:
421 """Claimed interchangable passphrases are actually interchangable."""
422 self._test(phrases, service) 1c
425class TestBasicFunctionalityFromUpstream(TestVault):
426 """Test passphrase derivation with the "vault" scheme: upstream tests."""
428 @Parametrize.SAMPLE_SERVICES_AND_PHRASES
429 def test_basic_configuration(
430 self, service: bytes | str, expected: bytes
431 ) -> None:
432 """Deriving a passphrase principally works."""
433 assert vault.Vault(phrase=self.phrase).generate(service) == expected 1t
435 def test_phrase_dependence(self) -> None:
436 """The derived passphrase is dependent on the master passphrase."""
437 assert ( 1u
438 vault.Vault(phrase=(self.phrase + b"X")).generate("google")
439 == b"n+oIz6sL>K*lTEWYRO%7"
440 )
443class TestStringAndBinaryExchangability(TestVault):
444 """Test the exchangability of text and byte strings in the "vault" scheme.
446 This specifically refers to UTF-8-cleanliness, and buffer-type
447 independence.
449 """
451 @hypothesis_machinery.explicit_examples(
452 ["phrase", "service"],
453 [
454 pytest.param(PHRASE.decode("UTF-8"), "google", id="google"),
455 pytest.param(PHRASE.decode("UTF-8"), "twitter", id="twitter"),
456 pytest.param(PHRASE.decode("UTF-8"), "email", id="email"),
457 ],
458 )
459 @hypothesis.given(
460 phrase=Strategies.text_strategy(),
461 service=Strategies.text_strategy(),
462 )
463 def test_binary_service_name_and_phrase(
464 self,
465 phrase: str,
466 service: str,
467 ) -> None:
468 """Binary and text inputs generate the same passphrases."""
469 v0 = vault.Vault(phrase=phrase) 1g
470 str_service = service 1g
471 result = v0.generate(str_service) 1g
472 bytes_service = service.encode("utf-8") 1g
474 for type_name, buffer_type in buffer_types.items(): 1g
475 assert v0.generate(buffer_type(bytes_service)) == result, ( 1g
476 f"mismatched result when using the {type_name} service name"
477 )
479 for type_name, buffer_type in buffer_types.items(): 1g
480 v = vault.Vault(phrase=buffer_type(phrase.encode("utf-8"))) 1g
481 assert v.generate(str_service) == result, ( 1g
482 f"mismatched result when using the {type_name} "
483 "master passphrase"
484 )
486 for type_name, buffer_type in buffer_types.items(): 1g
487 v = vault.Vault(phrase=buffer_type(phrase.encode("utf-8"))) 1g
488 for type_name2, buffer_type2 in buffer_types.items(): 1g
489 assert v.generate(buffer_type2(bytes_service)) == result, ( 1g
490 f"mismatched result when using the {type_name} "
491 f"master passphrase and the {type_name2} service name"
492 )
495class TestConstraintSatisfactionFromUpstream(TestVault):
496 """Test passphrase derivation with the "vault" scheme: upstream tests."""
498 def test_nonstandard_length(self) -> None:
499 """Deriving a passphrase adheres to imposed length limits."""
500 assert ( 1v
501 vault.Vault(phrase=self.phrase, length=4).generate("google")
502 == b"xDFu"
503 )
505 def test_repetition_limit(self) -> None:
506 """Deriving a passphrase adheres to imposed repetition limits."""
507 assert ( 1w
508 vault.Vault(
509 phrase=b"", length=24, symbol=0, number=0, repeat=1
510 ).generate("asd")
511 == b"IVTDzACftqopUXqDHPkuCIhV"
512 )
514 def test_without_symbols(self) -> None:
515 """Deriving a passphrase adheres to imposed limits on symbols."""
516 assert ( 1x
517 vault.Vault(phrase=self.phrase, symbol=0).generate("google")
518 == b"XZ4wRe0bZCazbljCaMqR"
519 )
521 def test_no_numbers(self) -> None:
522 """Deriving a passphrase adheres to imposed limits on numbers."""
523 assert ( 1y
524 vault.Vault(phrase=self.phrase, number=0).generate("google")
525 == b"_*$TVH.%^aZl(LUeOT?>"
526 )
528 def test_no_lowercase_letters(self) -> None:
529 """
530 Deriving a passphrase adheres to imposed limits on lowercase letters.
531 """
532 assert ( 1z
533 vault.Vault(phrase=self.phrase, lower=0).generate("google")
534 == b":{?)+7~@OA:L]!0E$)(+"
535 )
537 def test_at_least_5_digits(self) -> None:
538 """Deriving a passphrase adheres to imposed counts of numbers."""
539 assert ( 1A
540 vault.Vault(phrase=self.phrase, length=8, number=5).generate(
541 "songkick"
542 )
543 == b"i0908.7["
544 )
546 def test_lots_of_spaces(self) -> None:
547 """Deriving a passphrase adheres to imposed counts of spaces."""
548 assert ( 1B
549 vault.Vault(phrase=self.phrase, space=12).generate("songkick")
550 == b" c 6 Bq % 5fR "
551 )
553 def test_all_character_classes(self) -> None:
554 """Deriving a passphrase adheres to imposed counts of all types."""
555 assert ( 1C
556 vault.Vault(
557 phrase=self.phrase,
558 lower=2,
559 upper=2,
560 number=1,
561 space=3,
562 dash=2,
563 symbol=1,
564 ).generate("google")
565 == b": : fv_wqt>a-4w1S R"
566 )
568 def test_only_numbers_and_very_high_repetition_limit(self) -> None:
569 """Deriving a passphrase adheres to imposed repetition limits.
571 This example is checked explicitly against forbidden substrings.
573 """
574 generated = vault.Vault( 1l
575 phrase=b"",
576 length=40,
577 lower=0,
578 upper=0,
579 space=0,
580 dash=0,
581 symbol=0,
582 repeat=4,
583 ).generate("abcdef")
584 forbidden_substrings = { 1l
585 b"00000",
586 b"11111",
587 b"22222",
588 b"33333",
589 b"44444",
590 b"55555",
591 b"66666",
592 b"77777",
593 b"88888",
594 b"99999",
595 }
596 for substring in forbidden_substrings: 1l
597 assert substring not in generated 1l
599 def test_very_limited_character_set(self) -> None:
600 """Deriving a passphrase works even with limited character sets."""
601 generated = vault.Vault( 1o
602 phrase=b"", length=24, lower=0, upper=0, space=0, symbol=0
603 ).generate("testing")
604 assert generated == b"763252593304946694588866" 1o
607class TestConstraintSatisfactionThoroughness:
608 """Test passphrase derivation with the "vault" scheme: constraint satisfaction."""
610 @hypothesis.given(
611 phrase=strategies.one_of(
612 strategies.binary(min_size=1, max_size=100),
613 strategies.text(
614 min_size=1,
615 max_size=100,
616 alphabet=strategies.characters(max_codepoint=255),
617 ),
618 ),
619 length=strategies.integers(min_value=1, max_value=200),
620 service=strategies.text(min_size=1, max_size=100),
621 )
622 def test_password_with_length(
623 self,
624 phrase: str | bytes,
625 length: int,
626 service: str,
627 ) -> None:
628 """Derived passphrases have the requested length."""
629 password = vault.Vault(phrase=phrase, length=length).generate(service) 1p
630 assert len(password) == length 1p
632 # This test has time complexity `O(length * repeat)`, both of which
633 # are chosen by hypothesis and thus outside our control.
634 @hypothesis.settings(deadline=None)
635 @hypothesis.given( 1ah
636 phrase=strategies.one_of(
637 strategies.binary(min_size=1, max_size=100),
638 strategies.text(
639 min_size=1,
640 max_size=100,
641 alphabet=strategies.characters(max_codepoint=255),
642 ),
643 ),
644 length=strategies.integers(min_value=2, max_value=200),
645 repeat=strategies.integers(min_value=1, max_value=200),
646 service=strategies.text(min_size=1, max_size=1000),
647 )
648 def test_arbitrary_repetition_limit(
649 self,
650 phrase: str | bytes,
651 length: int,
652 repeat: int,
653 service: str,
654 ) -> None:
655 """Derived passphrases obey the given occurrence constraint."""
656 password = vault.Vault( 1h
657 phrase=phrase, length=length, repeat=repeat
658 ).generate(service)
659 last_char: str | int | None = None 1h
660 highest_count = 0 1h
661 count = 0 1h
662 for ch in password: 1h
663 if ch != last_char: 1h
664 last_char = ch 1h
665 count = 0 1h
666 else:
667 count += 1 1h
668 highest_count = max(highest_count, count) 1h
669 assert count <= repeat 1h
672class TestConstraintSatisfactionHeavyDuty:
673 """Test passphrase derivation with the "vault" scheme: constraint satisfaction."""
675 @hypothesis.given(
676 phrase=strategies.one_of(
677 strategies.binary(min_size=1), strategies.text(min_size=1)
678 ),
679 config=hypothesis_machinery.vault_full_service_config(),
680 service=strategies.text(min_size=1),
681 )
682 @hypothesis.example(
683 phrase=b"\x00",
684 config={
685 "lower": 0,
686 "upper": 0,
687 "number": 0,
688 "space": 2,
689 "dash": 0,
690 "symbol": 1,
691 "repeat": 2,
692 "length": 3,
693 },
694 service="0",
695 ).via("regression test")
696 @hypothesis.example(
697 phrase=b"\x00",
698 config={
699 "lower": 0,
700 "upper": 0,
701 "number": 0,
702 "space": 1,
703 "dash": 0,
704 "symbol": 0,
705 "repeat": 9,
706 "length": 5,
707 },
708 service="0",
709 ).via("regression test")
710 @hypothesis.example(
711 phrase=b"\x00",
712 config={
713 "lower": 0,
714 "upper": 0,
715 "number": 0,
716 "space": 1,
717 "dash": 0,
718 "symbol": 0,
719 "repeat": 0,
720 "length": 5,
721 },
722 service="0",
723 ).via('branch coverage (test function): "no repeats" case')
724 def test_all_length_character_and_occurrence_constraints_satisfied(
725 self,
726 phrase: str | bytes,
727 config: dict[str, int],
728 service: str,
729 ) -> None:
730 """Derived passphrases obey character and occurrence constraints."""
731 try: 1b
732 password = vault.Vault(phrase=phrase, **config).generate(service) 1b
733 except ValueError as exc: # pragma: no cover 1b
734 # The service configuration strategy attempts to only
735 # generate satisfiable configurations. It is possible,
736 # though rare, that this fails, and that unsatisfiability is
737 # only recognized when actually deriving a passphrase. In
738 # that case, reject the generated configuration.
739 hypothesis.assume("no allowed characters left" not in exc.args) 1b
740 # Otherwise it's a genuine bug in the test case or the
741 # implementation, and should be raised.
742 raise 1b
743 n = len(password) 1b
744 assert n == config["length"], "Password has wrong length." 1b
745 for key in ("lower", "upper", "number", "space", "dash", "symbol"): 1b
746 if config[key] > 0: 1b
747 assert ( 1b
748 sum(c in vault.Vault.CHARSETS[key] for c in password)
749 >= config[key]
750 ), (
751 "Password does not satisfy "
752 "character occurrence constraints."
753 )
754 elif key in {"dash", "symbol"}: 1b
755 # Character classes overlap, so "forbidden" characters may
756 # appear via the other character class.
757 assert True 1b
758 else:
759 assert ( 1b
760 sum(c in vault.Vault.CHARSETS[key] for c in password) == 0
761 ), "Password does not satisfy character ban constraints."
763 repeat = config["repeat"] 1b
764 if repeat: 1b
765 last_char: str | int | None = None 1b
766 highest_count = 0 1b
767 count = 0 1b
768 for ch in password: 1b
769 if ch != last_char: 1b
770 last_char = ch 1b
771 count = 0 1b
772 else:
773 count += 1 1b
774 highest_count = max(highest_count, count) 1b
775 assert count <= repeat, ( 1b
776 "Password does not satisfy character repeat constraints."
777 )
780class TestUtilities(TestVault):
781 """Test passphrase derivation with the "vault" scheme: utility tests."""
783 def test_character_set_subtraction(self) -> None:
784 """Removing allowed characters internally works."""
785 assert vault.Vault._subtract(b"be", b"abcdef") == bytearray(b"acdf") 1D
787 # TODO(the-13th-letter): Strongly consider removing this test, and
788 # the underlying functionality `Vault._entropy` and
789 # `Vault._estimate_sufficient_hash_length`, in favor of a heuristic
790 # estimate that works in "most cases". This test strongly suggests
791 # that we know how to correctly calculate both the entropy of
792 # a passphrase derivation template and a bound on how many bits of
793 # entropy the template will actually need. We don't, neither the
794 # template entropy (because the passphrase characters are not
795 # stochastically independent of each other, thanks to mandatory
796 # characters and repetition constraints) nor the actual used entropy
797 # (because we still use some form of rejection sampling with
798 # worst-case infinite retry chains).
799 @Parametrize.ENTROPY_RESULTS
800 def test_entropy(
801 self, length: int, settings: dict[str, int], entropy: int
802 ) -> None:
803 """Estimating the entropy and sufficient hash length works."""
804 v = vault.Vault(length=length, **settings) # type: ignore[arg-type] 1i
805 assert math.isclose(v._entropy(), entropy) 1i
806 assert v._estimate_sufficient_hash_length() > 0 1i
807 if math.isfinite(entropy) and entropy: 1i
808 assert v._estimate_sufficient_hash_length(1.0) == math.ceil( 1i
809 entropy / 8
810 )
811 assert v._estimate_sufficient_hash_length(8.0) >= entropy 1i
813 @Parametrize.SAMPLE_SERVICES_AND_PHRASES
814 def test_wrong_initial_hash_length(
815 self,
816 monkeypatch: pytest.MonkeyPatch,
817 service: str | bytes,
818 expected: bytes,
819 ) -> None:
820 """
821 Generating passphrases with the wrong starting hash length still works.
822 """
823 v = vault.Vault(phrase=self.phrase) 1n
824 monkeypatch.setattr( 1n
825 v,
826 "_estimate_sufficient_hash_length",
827 lambda *_args, **_kwargs: 1,
828 )
829 assert v.generate(service) == expected 1n
831 @Parametrize.BINARY_STRINGS
832 @hypothesis.given( 1aj
833 s=strategies.text().flatmap(
834 lambda s: strategies.sampled_from([
835 s,
836 s.encode("UTF-8"),
837 bytearray(s.encode("UTF-8")),
838 ])
839 )
840 )
841 def test_binary_strings(self, s: str | bytes | bytearray) -> None:
842 """Byte string conversion is idempotent."""
843 binstr = vault.Vault._get_binary_string 1j
844 expected = s.encode("UTF-8") if isinstance(s, str) else bytes(s) 1j
845 assert binstr(s) == expected 1j
846 assert binstr(binstr(s)) == binstr(s) 1j
848 def test_too_many_symbols(self) -> None:
849 """Deriving short passphrases with large length constraints fails."""
850 with pytest.raises( 1q
851 ValueError, match="requested passphrase length too short"
852 ):
853 vault.Vault(phrase=self.phrase, symbol=100) 1q
855 def test_no_viable_characters(self) -> None:
856 """Deriving passphrases without allowed characters fails."""
857 with pytest.raises(ValueError, match="no allowed characters left"): 1r
858 vault.Vault( 1r
859 phrase=self.phrase,
860 lower=0,
861 upper=0,
862 number=0,
863 space=0,
864 dash=0,
865 symbol=0,
866 )
868 def test_character_set_subtraction_duplicate(self) -> None:
869 """Character sets do not contain duplicate characters."""
870 with pytest.raises(ValueError, match="duplicate characters"): 1m
871 vault.Vault._subtract(b"abcdef", b"aabbccddeeff") 1m
872 with pytest.raises(ValueError, match="duplicate characters"): 1m
873 vault.Vault._subtract(b"aabbccddeeff", b"abcdef") 1m
875 def test_invalid_hash_length_estimation_safety_factor(self) -> None:
876 """Hash length estimation rejects invalid safety factors."""
877 v = vault.Vault(phrase=self.phrase) 1k
878 with pytest.raises(ValueError, match="invalid safety factor"): 1k
879 assert v._estimate_sufficient_hash_length(-1.0) 1k
880 with pytest.raises( 1k
881 TypeError, match="invalid safety factor: not a float"
882 ):
883 assert v._estimate_sufficient_hash_length(None) # type: ignore[arg-type] 1k