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

1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Test passphrase generation via derivepassphrase.vault.Vault.""" 

6 

7from __future__ import annotations 

8 

9import array 

10import hashlib 

11import math 

12import types 

13from typing import TYPE_CHECKING 

14 

15import hypothesis 

16import pytest 

17from hypothesis import strategies 

18from typing_extensions import TypeVar 

19 

20import tests 

21from derivepassphrase import vault 

22 

23if TYPE_CHECKING: 

24 from collections.abc import Callable, Iterator 

25 

26 from typing_extensions import Buffer 

27 

28BLOCK_SIZE = hashlib.sha1().block_size 

29DIGEST_SIZE = hashlib.sha1().digest_size 

30 

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""" 

43 

44 

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 ) 

83 

84 

85def phrases_are_interchangable( 

86 phrase1: Buffer | str, 

87 phrase2: Buffer | str, 

88 /, 

89) -> bool: 

90 """Work-alike of [`vault.Vault.phrases_are_interchangable`][]. 

91 

92 This version is not resistant to timing attacks, but faster, and 

93 supports strings directly. 

94 

95 Args: 

96 phrase1: 

97 A passphrase to compare. 

98 phrase2: 

99 A passphrase to compare. 

100 

101 Returns: 

102 True if the phrases behave identically under [`vault.Vault`][], 

103 false otherwise. 

104 

105 """ 

106 

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 ) 

113 

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

117 

118 

119class TestVault: 

120 """Test passphrase derivation with the "vault" scheme.""" 

121 

122 phrase = PHRASE 

123 

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. 

143 

144 We filter out interchangable passphrases during generation. 

145 

146 """ 

147 assert vault.Vault.create_hash( 1c

148 phrase=phrases[0], service=service 

149 ) != vault.Vault.create_hash(phrase=phrases[1], service=service) 

150 

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. 

170 

171 We filter out interchangable passphrases during generation. 

172 

173 """ 

174 assert vault.Vault.create_hash( 1d

175 phrase=phrases[0], service=service 

176 ) != vault.Vault.create_hash(phrase=phrases[1], service=service) 

177 

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. 

199 

200 We filter out interchangable passphrases during generation. 

201 

202 """ 

203 assert vault.Vault.create_hash( 1e

204 phrase=phrases[0], service=service 

205 ) != vault.Vault.create_hash(phrase=phrases[1], service=service) 

206 

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. 

232 

233 We filter out interchangable passphrases during generation. 

234 

235 """ 

236 assert vault.Vault.create_hash( 1f

237 phrase=phrases[0], service=service 

238 ) != vault.Vault.create_hash(phrase=phrases[1], service=service) 

239 

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]) 

262 

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) 

289 

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) 

320 

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

327 

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 ) 

334 

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. 

375 

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. 

380 

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) 

386 

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') 

392 

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')) 

398 

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')) 

404 

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 ) 

440 

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 ) 

476 

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]) 

499 

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 

535 

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 ) 

542 

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

564 

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 ) 

573 

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 ) 

580 

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 ) 

587 

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 ) 

596 

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 ) 

605 

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 ) 

612 

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 ) 

627 

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.' 

715 

716 T = TypeVar('T', str, bytes) 1b

717 

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

721 

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 ) 

728 

729 def test_218_only_numbers_and_very_high_repetition_limit(self) -> None: 

730 """Deriving a passphrase adheres to imposed repetition limits. 

731 

732 This example is checked explicitly against forbidden substrings. 

733 

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

759 

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

789 

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

796 

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

800 

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

814 

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

830 

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

849 

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

860 

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

867 

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 ) 

880 

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

887 

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