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

1# SPDX-FileCopyrightText: 2026 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 enum 

11import hashlib 

12import math 

13import types 

14from typing import TYPE_CHECKING 

15 

16import hypothesis 

17import pytest 

18from hypothesis import strategies 

19 

20from derivepassphrase import vault 

21from tests.machinery import hypothesis as hypothesis_machinery 

22 

23if TYPE_CHECKING: 

24 from collections.abc import Callable, Sequence 

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

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

49 

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

57 

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} 

64 

65 

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 ) 

106 

107 

108def phrases_are_interchangable( 

109 phrase1: Buffer | str, 

110 phrase2: Buffer | str, 

111 /, 

112) -> bool: 

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

114 

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

116 supports strings directly. 

117 

118 Args: 

119 phrase1: 

120 A passphrase to compare. 

121 phrase2: 

122 A passphrase to compare. 

123 

124 Returns: 

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

126 false otherwise. 

127 

128 """ 

129 

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 ) 

136 

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

140 

141 

142class PhraseSize(str, enum.Enum): 

143 """Size of the generated phrase. 

144 

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. 

150 

151 """ 

152 

153 SHORT = enum.auto() 

154 """""" 

155 FULL = enum.auto() 

156 """""" 

157 OVERLONG = enum.auto() 

158 """""" 

159 MIXED = enum.auto() 

160 """""" 

161 

162 

163class Strategies: 

164 """Hypothesis strategies.""" 

165 

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 ) 

174 

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. 

181 

182 Args: 

183 draw: 

184 The [strategy drawing 

185 function][hypothesis.strategies.composite]. 

186 size: 

187 The desired phrase size. 

188 

189 Returns: 

190 The strategy. 

191 

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 ) 

213 

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. 

220 

221 Args: 

222 draw: 

223 The [strategy drawing 

224 function][hypothesis.strategies.composite]. 

225 size: 

226 The desired phrase size. 

227 

228 Returns: 

229 The strategy. 

230 

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

242 

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. 

249 

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

261 

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

263 

264 Args: 

265 draw: 

266 The [strategy drawing 

267 function][hypothesis.strategies.composite]. 

268 phrase: 

269 The first phrase. 

270 

271 Returns: 

272 The strategy for two interchangable phrases. 

273 

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

288 

289 

290class TestVault: 

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

292 

293 phrase = PHRASE 

294 

295 

296class TestPhraseDependence: 

297 """Test the dependence of the internal hash on the master passphrase.""" 

298 

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) 

303 

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. 

315 

316 We filter out interchangable passphrases during generation. 

317 

318 """ 

319 self._test(phrases, service) 1d

320 

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. 

333 

334 We filter out interchangable passphrases during generation. 

335 

336 """ 

337 self._test(phrases, service) 1f

338 

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. 

347 

348 We filter out interchangable passphrases during generation. 

349 

350 """ 

351 self._test(phrases, service) 1c

352 

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. 

365 

366 We filter out interchangable passphrases during generation. 

367 

368 """ 

369 self._test(phrases, service) 1e

370 

371 

372class TestServiceNameDependence: 

373 """Test the dependence of the internal hash on the service name.""" 

374 

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

393 

394 

395class TestInterchangablePhrases: 

396 """Test the interchangability of certain master passphrases.""" 

397 

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) 

403 

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

413 

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

423 

424 

425class TestBasicFunctionalityFromUpstream(TestVault): 

426 """Test passphrase derivation with the "vault" scheme: upstream tests.""" 

427 

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

434 

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 ) 

441 

442 

443class TestStringAndBinaryExchangability(TestVault): 

444 """Test the exchangability of text and byte strings in the "vault" scheme. 

445 

446 This specifically refers to UTF-8-cleanliness, and buffer-type 

447 independence. 

448 

449 """ 

450 

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

473 

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 ) 

478 

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 ) 

485 

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 ) 

493 

494 

495class TestConstraintSatisfactionFromUpstream(TestVault): 

496 """Test passphrase derivation with the "vault" scheme: upstream tests.""" 

497 

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 ) 

504 

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 ) 

513 

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 ) 

520 

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 ) 

527 

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 ) 

536 

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 ) 

545 

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 ) 

552 

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 ) 

567 

568 def test_only_numbers_and_very_high_repetition_limit(self) -> None: 

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

570 

571 This example is checked explicitly against forbidden substrings. 

572 

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

598 

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

605 

606 

607class TestConstraintSatisfactionThoroughness: 

608 """Test passphrase derivation with the "vault" scheme: constraint satisfaction.""" 

609 

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

631 

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

670 

671 

672class TestConstraintSatisfactionHeavyDuty: 

673 """Test passphrase derivation with the "vault" scheme: constraint satisfaction.""" 

674 

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

762 

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 ) 

778 

779 

780class TestUtilities(TestVault): 

781 """Test passphrase derivation with the "vault" scheme: utility tests.""" 

782 

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

786 

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

812 

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

830 

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

847 

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

854 

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 ) 

867 

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

874 

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