Coverage for tests\test_derivepassphrase_vault.py: 100.000%
194 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
1# SPDX-FileCopyrightText: 2025 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 hashlib
11import math
12import types
13from typing import TYPE_CHECKING
15import hypothesis
16import pytest
17from hypothesis import strategies
18from typing_extensions import TypeVar
20import tests
21from derivepassphrase import vault
23if TYPE_CHECKING:
24 from collections.abc import Callable, Iterator
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"""
45class Parametrize(types.SimpleNamespace):
46 ENTROPY_RESULTS = pytest.mark.parametrize(
47 ['length', 'settings', 'entropy'],
48 [
49 (20, {}, math.log2(math.factorial(20)) + 20 * math.log2(94)),
50 (
51 20,
52 {'upper': 0, 'number': 0, 'space': 0, 'symbol': 0},
53 math.log2(math.factorial(20)) + 20 * math.log2(26),
54 ),
55 (0, {}, float('-inf')),
56 (
57 0,
58 {'lower': 0, 'number': 0, 'space': 0, 'symbol': 0},
59 float('-inf'),
60 ),
61 (1, {}, math.log2(94)),
62 (1, {'upper': 0, 'lower': 0, 'number': 0, 'symbol': 0}, 0.0),
63 ],
64 )
65 BINARY_STRINGS = pytest.mark.parametrize(
66 's',
67 [
68 'ñ',
69 'Düsseldorf',
70 'liberté, egalité, fraternité',
71 'ASCII',
72 b'D\xc3\xbcsseldorf',
73 bytearray([2, 3, 5, 7, 11, 13]),
74 ],
75 )
76 SAMPLE_SERVICES_AND_PHRASES = pytest.mark.parametrize(
77 ['service', 'expected'],
78 [
79 (b'google', GOOGLE_PHRASE),
80 ('twitter', TWITTER_PHRASE),
81 ],
82 )
85def phrases_are_interchangable(
86 phrase1: Buffer | str,
87 phrase2: Buffer | str,
88 /,
89) -> bool:
90 """Work-alike of [`vault.Vault.phrases_are_interchangable`][].
92 This version is not resistant to timing attacks, but faster, and
93 supports strings directly.
95 Args:
96 phrase1:
97 A passphrase to compare.
98 phrase2:
99 A passphrase to compare.
101 Returns:
102 True if the phrases behave identically under [`vault.Vault`][],
103 false otherwise.
105 """
107 def canon(bs: bytes, /) -> bytes: 1cdefg
108 return ( 1cdefg
109 hashlib.sha1(bs).digest() + b'\x00' * (BLOCK_SIZE - DIGEST_SIZE)
110 if len(bs) > BLOCK_SIZE
111 else bs.rstrip(b'\x00')
112 )
114 phrase1 = canon(vault.Vault._get_binary_string(phrase1)) 1cdefg
115 phrase2 = canon(vault.Vault._get_binary_string(phrase2)) 1cdefg
116 return phrase1 == phrase2 1cdefg
119class TestVault:
120 """Test passphrase derivation with the "vault" scheme."""
122 phrase = PHRASE
124 @hypothesis.given(
125 phrases=strategies.lists(
126 strategies.binary(min_size=1, max_size=BLOCK_SIZE // 2),
127 min_size=2,
128 max_size=2,
129 unique=True,
130 ).filter(lambda tup: not phrases_are_interchangable(*tup)),
131 service=strategies.text(
132 strategies.characters(min_codepoint=32, max_codepoint=126),
133 min_size=1,
134 max_size=BLOCK_SIZE // 2,
135 ),
136 )
137 def test_100a_create_hash_phrase_dependence_small(
138 self,
139 phrases: list[bytes],
140 service: str,
141 ) -> None:
142 """The internal hash is dependent on the master passphrase.
144 We filter out interchangable passphrases during generation.
146 """
147 assert vault.Vault.create_hash( 1c
148 phrase=phrases[0], service=service
149 ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
151 @hypothesis.given(
152 phrases=strategies.lists(
153 strategies.binary(min_size=BLOCK_SIZE, max_size=BLOCK_SIZE),
154 min_size=2,
155 max_size=2,
156 unique=True,
157 ).filter(lambda tup: not phrases_are_interchangable(*tup)),
158 service=strategies.text(
159 strategies.characters(min_codepoint=32, max_codepoint=126),
160 min_size=1,
161 max_size=BLOCK_SIZE // 2,
162 ),
163 )
164 def test_100b_create_hash_phrase_dependence_medium(
165 self,
166 phrases: list[bytes],
167 service: str,
168 ) -> None:
169 """The internal hash is dependent on the master passphrase.
171 We filter out interchangable passphrases during generation.
173 """
174 assert vault.Vault.create_hash( 1d
175 phrase=phrases[0], service=service
176 ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
178 @hypothesis.given(
179 phrases=strategies.lists(
180 strategies.binary(
181 min_size=BLOCK_SIZE + 1, max_size=BLOCK_SIZE + 8
182 ),
183 min_size=2,
184 max_size=2,
185 unique=True,
186 ).filter(lambda tup: not phrases_are_interchangable(*tup)),
187 service=strategies.text(
188 strategies.characters(min_codepoint=32, max_codepoint=126),
189 min_size=1,
190 max_size=BLOCK_SIZE // 2,
191 ),
192 )
193 def test_100c_create_hash_phrase_dependence_large(
194 self,
195 phrases: tuple[bytes, bytes],
196 service: str,
197 ) -> None:
198 """The internal hash is dependent on the master passphrase.
200 We filter out interchangable passphrases during generation.
202 """
203 assert vault.Vault.create_hash( 1e
204 phrase=phrases[0], service=service
205 ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
207 @hypothesis.given(
208 phrases=strategies.lists(
209 strategies.one_of(
210 strategies.binary(min_size=1, max_size=BLOCK_SIZE // 2),
211 strategies.binary(min_size=BLOCK_SIZE, max_size=BLOCK_SIZE),
212 strategies.binary(
213 min_size=BLOCK_SIZE + 1, max_size=BLOCK_SIZE + 8
214 ),
215 ),
216 min_size=2,
217 max_size=2,
218 unique=True,
219 ).filter(lambda tup: not phrases_are_interchangable(*tup)),
220 service=strategies.text(
221 strategies.characters(min_codepoint=32, max_codepoint=126),
222 min_size=1,
223 max_size=BLOCK_SIZE // 2,
224 ),
225 )
226 def test_100d_create_hash_phrase_dependence_mixed(
227 self,
228 phrases: list[bytes],
229 service: str,
230 ) -> None:
231 """The internal hash is dependent on the master passphrase.
233 We filter out interchangable passphrases during generation.
235 """
236 assert vault.Vault.create_hash( 1f
237 phrase=phrases[0], service=service
238 ) != vault.Vault.create_hash(phrase=phrases[1], service=service)
240 @hypothesis.given(
241 phrase=strategies.text(
242 strategies.characters(min_codepoint=32, max_codepoint=126),
243 min_size=1,
244 max_size=32,
245 ),
246 services=strategies.lists(
247 strategies.binary(min_size=1, max_size=32),
248 min_size=2,
249 max_size=2,
250 unique=True,
251 ),
252 )
253 def test_101_create_hash_service_name_dependence(
254 self,
255 phrase: str,
256 services: list[bytes],
257 ) -> None:
258 """The internal hash is dependent on the service name."""
259 assert vault.Vault.create_hash( 1y
260 phrase=phrase, service=services[0]
261 ) != vault.Vault.create_hash(phrase=phrase, service=services[1])
263 @hypothesis.given(
264 phrases=strategies.binary(max_size=BLOCK_SIZE // 2).flatmap(
265 lambda bs: strategies.tuples(
266 strategies.just(bs),
267 strategies.integers(
268 min_value=1,
269 max_value=BLOCK_SIZE - len(bs),
270 ).map(lambda num: bs + b'\x00' * num),
271 )
272 ),
273 service=strategies.text(
274 strategies.characters(min_codepoint=32, max_codepoint=126),
275 min_size=1,
276 max_size=32,
277 ),
278 )
279 def test_102a_interchangable_phrases_small(
280 self,
281 phrases: tuple[bytes, bytes],
282 service: str,
283 ) -> None:
284 """Claimed interchangable passphrases are actually interchangable."""
285 assert vault.Vault.phrases_are_interchangable(*phrases) 1r
286 assert vault.Vault.create_hash( 1r
287 phrase=phrases[0], service=service
288 ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
290 @hypothesis.given(
291 phrases=strategies.binary(
292 min_size=BLOCK_SIZE + 1, max_size=BLOCK_SIZE + 8
293 ).flatmap(
294 lambda bs: strategies.tuples(
295 strategies.just(bs),
296 strategies.just(hashlib.sha1(bs).digest()).flatmap(
297 lambda h: strategies.integers(
298 min_value=1,
299 max_value=BLOCK_SIZE - DIGEST_SIZE,
300 ).map(lambda num: h + b'\x00' * num)
301 ),
302 )
303 ),
304 service=strategies.text(
305 strategies.characters(min_codepoint=32, max_codepoint=126),
306 min_size=1,
307 max_size=32,
308 ),
309 )
310 def test_102b_interchangable_phrases_large(
311 self,
312 phrases: tuple[bytes, bytes],
313 service: str,
314 ) -> None:
315 """Claimed interchangable passphrases are actually interchangable."""
316 assert vault.Vault.phrases_are_interchangable(*phrases) 1s
317 assert vault.Vault.create_hash( 1s
318 phrase=phrases[0], service=service
319 ) == vault.Vault.create_hash(phrase=phrases[1], service=service)
321 @Parametrize.SAMPLE_SERVICES_AND_PHRASES
322 def test_200_basic_configuration(
323 self, service: bytes | str, expected: bytes
324 ) -> None:
325 """Deriving a passphrase principally works."""
326 assert vault.Vault(phrase=self.phrase).generate(service) == expected 1z
328 def test_201_phrase_dependence(self) -> None:
329 """The derived passphrase is dependent on the master passphrase."""
330 assert ( 1A
331 vault.Vault(phrase=(self.phrase + b'X')).generate('google')
332 == b'n+oIz6sL>K*lTEWYRO%7'
333 )
335 @hypothesis.given(
336 phrases=strategies.lists(
337 strategies.binary(min_size=1, max_size=32),
338 min_size=2,
339 max_size=2,
340 unique=True,
341 ).filter(lambda tup: not phrases_are_interchangable(*tup)),
342 service=strategies.text(
343 strategies.characters(min_codepoint=32, max_codepoint=126),
344 min_size=1,
345 max_size=32,
346 ),
347 )
348 @hypothesis.example(phrases=[b'\x00', b'\x00\x00'], service='0').xfail(
349 reason='phrases are interchangable',
350 raises=AssertionError,
351 )
352 @hypothesis.example(
353 phrases=[
354 (
355 b'plnlrtfpijpuhqylxbgqiiyipieyxvfs'
356 b'avzgxbbcfusqkozwpngsyejqlmjsytrmd'
357 ),
358 b"eBkXQTfuBqp'cTcar&g*",
359 ],
360 service='any service name here',
361 ).xfail(
362 reason=(
363 'phrases are interchangable (Wikipedia example:'
364 'https://en.wikipedia.org/w/index.php?title=PBKDF2&oldid=1264881215#HMAC_collisions'
365 ')'
366 ),
367 raises=AssertionError,
368 )
369 def test_201a_phrase_dependence(
370 self,
371 phrases: list[bytes],
372 service: str,
373 ) -> None:
374 """The derived passphrase is dependent on the master passphrase.
376 Certain pairs of master passphrases are known to be
377 interchangable; see [`vault.Vault.phrases_are_interchangable`][].
378 These are excluded from consideration by the hypothesis
379 strategy.
381 """
382 # See test_100_create_hash_phrase_dependence for context.
383 assert vault.Vault(phrase=phrases[0]).generate(service) != vault.Vault( 1g
384 phrase=phrases[1]
385 ).generate(service)
387 def test_202a_reproducibility_and_bytes_service_name(self) -> None:
388 """Deriving a passphrase works equally for byte strings."""
389 assert vault.Vault(phrase=self.phrase).generate( 1B
390 b'google'
391 ) == vault.Vault(phrase=self.phrase).generate('google')
393 def test_202b_reproducibility_and_bytearray_service_name(self) -> None:
394 """Deriving a passphrase works equally for byte arrays."""
395 assert vault.Vault(phrase=self.phrase).generate( 1C
396 b'google'
397 ) == vault.Vault(phrase=self.phrase).generate(bytearray(b'google'))
399 def test_202c_reproducibility_and_buffer_like_service_name(self) -> None:
400 """Deriving a passphrase works equally for memory views."""
401 assert vault.Vault(phrase=self.phrase).generate( 1D
402 b'google'
403 ) == vault.Vault(phrase=self.phrase).generate(memoryview(b'google'))
405 @hypothesis.given(
406 phrase=strategies.text(
407 strategies.characters(min_codepoint=32, max_codepoint=126),
408 min_size=1,
409 max_size=32,
410 ),
411 service=strategies.text(
412 strategies.characters(min_codepoint=32, max_codepoint=126),
413 min_size=1,
414 max_size=32,
415 ),
416 )
417 def test_203a_reproducibility_and_binary_phrases(
418 self,
419 phrase: str,
420 service: str,
421 ) -> None:
422 """Binary and text master passphrases generate the same passphrases."""
423 buffer_types: dict[str, Callable[..., Buffer]] = { 1j
424 'bytes': bytes,
425 'bytearray': bytearray,
426 'memoryview': memoryview,
427 'array.array': lambda data: array.array('B', data),
428 }
429 for type_name, buffer_type in buffer_types.items(): 1j
430 str_phrase = phrase 1j
431 bytes_phrase = phrase.encode('utf-8') 1j
432 assert vault.Vault(phrase=str_phrase).generate( 1j
433 service
434 ) == vault.Vault(phrase=buffer_type(bytes_phrase)).generate(
435 service
436 ), (
437 f'{str_phrase!r} and {type_name}({bytes_phrase!r}) '
438 'master passphrases generate different passphrases'
439 )
441 @hypothesis.given(
442 phrase=strategies.text(
443 strategies.characters(min_codepoint=32, max_codepoint=126),
444 min_size=1,
445 max_size=32,
446 ),
447 service=strategies.text(
448 strategies.characters(min_codepoint=32, max_codepoint=126),
449 min_size=1,
450 max_size=32,
451 ),
452 )
453 def test_203b_reproducibility_and_binary_service_name(
454 self,
455 phrase: str,
456 service: str,
457 ) -> None:
458 """Binary and text service names generate the same passphrases."""
459 buffer_types: dict[str, Callable[..., Buffer]] = { 1k
460 'bytes': bytes,
461 'bytearray': bytearray,
462 'memoryview': memoryview,
463 'array.array': lambda data: array.array('B', data),
464 }
465 for type_name, buffer_type in buffer_types.items(): 1k
466 str_service = service 1k
467 bytes_service = service.encode('utf-8') 1k
468 assert vault.Vault(phrase=phrase).generate( 1k
469 str_service
470 ) == vault.Vault(phrase=phrase).generate(
471 buffer_type(bytes_service)
472 ), (
473 f'{str_service!r} and {type_name}({bytes_service!r}) '
474 'service name generate different passphrases'
475 )
477 @hypothesis.given(
478 phrase=strategies.text(
479 strategies.characters(min_codepoint=32, max_codepoint=126),
480 min_size=1,
481 max_size=32,
482 ),
483 services=strategies.lists(
484 strategies.binary(min_size=1, max_size=32),
485 min_size=2,
486 max_size=2,
487 unique=True,
488 ),
489 )
490 def test_204a_service_name_dependence(
491 self,
492 phrase: str,
493 services: list[bytes],
494 ) -> None:
495 """The derived passphrase is dependent on the service name."""
496 assert vault.Vault(phrase=phrase).generate(services[0]) != vault.Vault( 1E
497 phrase=phrase
498 ).generate(services[1])
500 @hypothesis.given(
501 phrase=strategies.text(
502 strategies.characters(min_codepoint=32, max_codepoint=126),
503 min_size=1,
504 max_size=32,
505 ),
506 config=tests.vault_full_service_config(),
507 services=strategies.lists(
508 strategies.binary(min_size=1, max_size=32),
509 min_size=2,
510 max_size=2,
511 unique=True,
512 ),
513 )
514 def test_204b_service_name_dependence_with_config(
515 self,
516 phrase: str,
517 config: dict[str, int],
518 services: list[bytes],
519 ) -> None:
520 """The derived passphrase is dependent on the service name."""
521 try: 1t
522 assert vault.Vault(phrase=phrase, **config).generate( 1t
523 services[0]
524 ) != vault.Vault(phrase=phrase, **config).generate(services[1])
525 except ValueError as exc: # pragma: no cover
526 # The service configuration strategy attempts to only
527 # generate satisfiable configurations. It is possible,
528 # though rare, that this fails, and that unsatisfiability is
529 # only recognized when actually deriving a passphrase. In
530 # that case, reject the generated configuration.
531 hypothesis.assume('no allowed characters left' not in exc.args)
532 # Otherwise it's a genuine bug in the test case or the
533 # implementation, and should be raised.
534 raise
536 def test_210_nonstandard_length(self) -> None:
537 """Deriving a passphrase adheres to imposed length limits."""
538 assert ( 1F
539 vault.Vault(phrase=self.phrase, length=4).generate('google')
540 == b'xDFu'
541 )
543 @hypothesis.given(
544 phrase=strategies.one_of(
545 strategies.binary(min_size=1, max_size=100),
546 strategies.text(
547 min_size=1,
548 max_size=100,
549 alphabet=strategies.characters(max_codepoint=255),
550 ),
551 ),
552 length=strategies.integers(min_value=1, max_value=200),
553 service=strategies.text(min_size=1, max_size=100),
554 )
555 def test_210a_password_with_length(
556 self,
557 phrase: str | bytes,
558 length: int,
559 service: str,
560 ) -> None:
561 """Derived passphrases have the requested length."""
562 password = vault.Vault(phrase=phrase, length=length).generate(service) 1u
563 assert len(password) == length 1u
565 def test_211_repetition_limit(self) -> None:
566 """Deriving a passphrase adheres to imposed repetition limits."""
567 assert ( 1G
568 vault.Vault(
569 phrase=b'', length=24, symbol=0, number=0, repeat=1
570 ).generate('asd')
571 == b'IVTDzACftqopUXqDHPkuCIhV'
572 )
574 def test_212_without_symbols(self) -> None:
575 """Deriving a passphrase adheres to imposed limits on symbols."""
576 assert ( 1H
577 vault.Vault(phrase=self.phrase, symbol=0).generate('google')
578 == b'XZ4wRe0bZCazbljCaMqR'
579 )
581 def test_213_no_numbers(self) -> None:
582 """Deriving a passphrase adheres to imposed limits on numbers."""
583 assert ( 1I
584 vault.Vault(phrase=self.phrase, number=0).generate('google')
585 == b'_*$TVH.%^aZl(LUeOT?>'
586 )
588 def test_214_no_lowercase_letters(self) -> None:
589 """
590 Deriving a passphrase adheres to imposed limits on lowercase letters.
591 """
592 assert ( 1J
593 vault.Vault(phrase=self.phrase, lower=0).generate('google')
594 == b':{?)+7~@OA:L]!0E$)(+'
595 )
597 def test_215_at_least_5_digits(self) -> None:
598 """Deriving a passphrase adheres to imposed counts of numbers."""
599 assert ( 1K
600 vault.Vault(phrase=self.phrase, length=8, number=5).generate(
601 'songkick'
602 )
603 == b'i0908.7['
604 )
606 def test_216_lots_of_spaces(self) -> None:
607 """Deriving a passphrase adheres to imposed counts of spaces."""
608 assert ( 1L
609 vault.Vault(phrase=self.phrase, space=12).generate('songkick')
610 == b' c 6 Bq % 5fR '
611 )
613 def test_217_all_character_classes(self) -> None:
614 """Deriving a passphrase adheres to imposed counts of all types."""
615 assert ( 1M
616 vault.Vault(
617 phrase=self.phrase,
618 lower=2,
619 upper=2,
620 number=1,
621 space=3,
622 dash=2,
623 symbol=1,
624 ).generate('google')
625 == b': : fv_wqt>a-4w1S R'
626 )
628 @hypothesis.given(
629 phrase=strategies.one_of(
630 strategies.binary(min_size=1), strategies.text(min_size=1)
631 ),
632 config=tests.vault_full_service_config(),
633 service=strategies.text(min_size=1),
634 )
635 @hypothesis.example(
636 phrase=b'\x00',
637 config={
638 'lower': 0,
639 'upper': 0,
640 'number': 0,
641 'space': 2,
642 'dash': 0,
643 'symbol': 1,
644 'repeat': 2,
645 'length': 3,
646 },
647 service='0',
648 ).via('regression test')
649 @hypothesis.example(
650 phrase=b'\x00',
651 config={
652 'lower': 0,
653 'upper': 0,
654 'number': 0,
655 'space': 1,
656 'dash': 0,
657 'symbol': 0,
658 'repeat': 9,
659 'length': 5,
660 },
661 service='0',
662 ).via('regression test')
663 @hypothesis.example(
664 phrase=b'\x00',
665 config={
666 'lower': 0,
667 'upper': 0,
668 'number': 0,
669 'space': 1,
670 'dash': 0,
671 'symbol': 0,
672 'repeat': 0,
673 'length': 5,
674 },
675 service='0',
676 ).via('branch coverage (test function): "no repeats" case')
677 def test_217a_all_length_character_and_occurrence_constraints_satisfied(
678 self,
679 phrase: str | bytes,
680 config: dict[str, int],
681 service: str,
682 ) -> None:
683 """Derived passphrases obey character and occurrence restraints."""
684 try: 1b
685 password = vault.Vault(phrase=phrase, **config).generate(service) 1b
686 except ValueError as exc: # pragma: no cover 1b
687 # The service configuration strategy attempts to only
688 # generate satisfiable configurations. It is possible,
689 # though rare, that this fails, and that unsatisfiability is
690 # only recognized when actually deriving a passphrase. In
691 # that case, reject the generated configuration.
692 hypothesis.assume('no allowed characters left' not in exc.args) 1b
693 # Otherwise it's a genuine bug in the test case or the
694 # implementation, and should be raised.
695 raise 1b
696 n = len(password) 1b
697 assert n == config['length'], 'Password has wrong length.' 1b
698 for key in ('lower', 'upper', 'number', 'space', 'dash', 'symbol'): 1b
699 if config[key] > 0: 1b
700 assert ( 1b
701 sum(c in vault.Vault.CHARSETS[key] for c in password)
702 >= config[key]
703 ), (
704 'Password does not satisfy '
705 'character occurrence constraints.'
706 )
707 elif key in {'dash', 'symbol'}: 1b
708 # Character classes overlap, so "forbidden" characters may
709 # appear via the other character class.
710 assert True 1b
711 else:
712 assert ( 1b
713 sum(c in vault.Vault.CHARSETS[key] for c in password) == 0
714 ), 'Password does not satisfy character ban constraints.'
716 T = TypeVar('T', str, bytes) 1b
718 def length_r_substrings(string: T, *, r: int) -> Iterator[T]: 1b
719 for i in range(len(string) - (r - 1)): 1b
720 yield string[i : i + r] 1b
722 repeat = config['repeat'] 1b
723 if repeat: 1b
724 for snippet in length_r_substrings(password, r=(repeat + 1)): 1b
725 assert len(set(snippet)) > 1, ( 1b
726 'Password does not satisfy character repeat constraints.'
727 )
729 def test_218_only_numbers_and_very_high_repetition_limit(self) -> None:
730 """Deriving a passphrase adheres to imposed repetition limits.
732 This example is checked explicitly against forbidden substrings.
734 """
735 generated = vault.Vault( 1m
736 phrase=b'',
737 length=40,
738 lower=0,
739 upper=0,
740 space=0,
741 dash=0,
742 symbol=0,
743 repeat=4,
744 ).generate('abcdef')
745 forbidden_substrings = { 1m
746 b'0000',
747 b'1111',
748 b'2222',
749 b'3333',
750 b'4444',
751 b'5555',
752 b'6666',
753 b'7777',
754 b'8888',
755 b'9999',
756 }
757 for substring in forbidden_substrings: 1m
758 assert substring not in generated 1m
760 # This test has time complexity `O(length * repeat)`, both of which
761 # are chosen by hypothesis and thus outside our control.
762 @hypothesis.settings(deadline=None)
763 @hypothesis.given( 1an
764 phrase=strategies.one_of(
765 strategies.binary(min_size=1, max_size=100),
766 strategies.text(
767 min_size=1,
768 max_size=100,
769 alphabet=strategies.characters(max_codepoint=255),
770 ),
771 ),
772 length=strategies.integers(min_value=2, max_value=200),
773 repeat=strategies.integers(min_value=1, max_value=200),
774 service=strategies.text(min_size=1, max_size=1000),
775 )
776 def test_218a_arbitrary_repetition_limit(
777 self,
778 phrase: str | bytes,
779 length: int,
780 repeat: int,
781 service: str,
782 ) -> None:
783 """Derived passphrases obey the given occurrence constraint."""
784 password = vault.Vault( 1n
785 phrase=phrase, length=length, repeat=repeat
786 ).generate(service)
787 for i in range((length + 1) - (repeat + 1)): 1n
788 assert len(set(password[i : i + repeat + 1])) > 1 1n
790 def test_219_very_limited_character_set(self) -> None:
791 """Deriving a passphrase works even with limited character sets."""
792 generated = vault.Vault( 1v
793 phrase=b'', length=24, lower=0, upper=0, space=0, symbol=0
794 ).generate('testing')
795 assert generated == b'763252593304946694588866' 1v
797 def test_220_character_set_subtraction(self) -> None:
798 """Removing allowed characters internally works."""
799 assert vault.Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf') 1N
801 @Parametrize.ENTROPY_RESULTS
802 def test_221_entropy(
803 self, length: int, settings: dict[str, int], entropy: int
804 ) -> None:
805 """Estimating the entropy and sufficient hash length works."""
806 v = vault.Vault(length=length, **settings) # type: ignore[arg-type] 1h
807 assert math.isclose(v._entropy(), entropy) 1h
808 assert v._estimate_sufficient_hash_length() > 0 1h
809 if math.isfinite(entropy) and entropy: 1h
810 assert v._estimate_sufficient_hash_length(1.0) == math.ceil( 1h
811 entropy / 8
812 )
813 assert v._estimate_sufficient_hash_length(8.0) >= entropy 1h
815 def test_222_hash_length_estimation(self) -> None:
816 """
817 Estimating the entropy and hash length for degenerate cases works.
818 """
819 v = vault.Vault( 1q
820 phrase=self.phrase,
821 lower=0,
822 upper=0,
823 number=0,
824 symbol=0,
825 space=1,
826 length=1,
827 )
828 assert v._entropy() == 0.0 1q
829 assert v._estimate_sufficient_hash_length() > 0 1q
831 @Parametrize.SAMPLE_SERVICES_AND_PHRASES
832 def test_223_hash_length_expansion(
833 self,
834 monkeypatch: pytest.MonkeyPatch,
835 service: str | bytes,
836 expected: bytes,
837 ) -> None:
838 """
839 Estimating the entropy and hash length for the degenerate case works.
840 """
841 v = vault.Vault(phrase=self.phrase) 1o
842 monkeypatch.setattr( 1o
843 v,
844 '_estimate_sufficient_hash_length',
845 lambda *args, **kwargs: 1, # noqa: ARG005
846 )
847 assert v._estimate_sufficient_hash_length() < len(self.phrase) 1o
848 assert v.generate(service) == expected 1o
850 @Parametrize.BINARY_STRINGS
851 def test_224_binary_strings(self, s: str | bytes | bytearray) -> None:
852 """Byte string conversion is idempotent."""
853 binstr = vault.Vault._get_binary_string 1i
854 if isinstance(s, str): 1i
855 assert binstr(s) == s.encode('UTF-8') 1i
856 assert binstr(binstr(s)) == s.encode('UTF-8') 1i
857 else:
858 assert binstr(s) == bytes(s) 1i
859 assert binstr(binstr(s)) == bytes(s) 1i
861 def test_310_too_many_symbols(self) -> None:
862 """Deriving short passphrases with large length constraints fails."""
863 with pytest.raises( 1w
864 ValueError, match='requested passphrase length too short'
865 ):
866 vault.Vault(phrase=self.phrase, symbol=100) 1w
868 def test_311_no_viable_characters(self) -> None:
869 """Deriving passphrases without allowed characters fails."""
870 with pytest.raises(ValueError, match='no allowed characters left'): 1x
871 vault.Vault( 1x
872 phrase=self.phrase,
873 lower=0,
874 upper=0,
875 number=0,
876 space=0,
877 dash=0,
878 symbol=0,
879 )
881 def test_320_character_set_subtraction_duplicate(self) -> None:
882 """Character sets do not contain duplicate characters."""
883 with pytest.raises(ValueError, match='duplicate characters'): 1p
884 vault.Vault._subtract(b'abcdef', b'aabbccddeeff') 1p
885 with pytest.raises(ValueError, match='duplicate characters'): 1p
886 vault.Vault._subtract(b'aabbccddeeff', b'abcdef') 1p
888 def test_322_hash_length_estimation(self) -> None:
889 """Hash length estimation rejects invalid safety factors."""
890 v = vault.Vault(phrase=self.phrase) 1l
891 with pytest.raises(ValueError, match='invalid safety factor'): 1l
892 assert v._estimate_sufficient_hash_length(-1.0) 1l
893 with pytest.raises( 1l
894 TypeError, match='invalid safety factor: not a float'
895 ):
896 assert v._estimate_sufficient_hash_length(None) # type: ignore[arg-type] 1l