Coverage for tests\test_derivepassphrase_vault.py: 100.000%

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

7 from __future__ import annotations (empty)

8

9 import array (empty)

10 import hashlib (empty)

11 import math (empty)

12 import types (empty)

13 from typing import TYPE_CHECKING (empty)

14

15 import hypothesis (empty)

16 import pytest (empty)

17 from hypothesis import strategies (empty)

18 from typing_extensions import TypeVar (empty)

19

20 import tests (empty)

21 from derivepassphrase import vault (empty)

22

23 if TYPE_CHECKING: (empty)

24 from collections.abc import Callable, Iterator

25

26 from typing_extensions import Buffer

27

28 BLOCK_SIZE = hashlib.sha1().block_size (empty)

29 DIGEST_SIZE = hashlib.sha1().digest_size (empty)

30

31 PHRASE = b'She cells C shells bye the sea shoars' (empty)

32"""The standard passphrase from <i>vault</i>(1)'s test suite.""" (empty)

33 GOOGLE_PHRASE = rb': 4TVH#5:aZl8LueOT\{' (empty)

34""" (empty)

35 The standard derived passphrase for the "google" service, from

36<i>vault</i>(1)'s test suite.

37"""

38 TWITTER_PHRASE = rb"[ (HN_N:lI&<ro=)3'g9" (empty)

39""" (empty)

40 The standard derived passphrase for the "twitter" service, from

41<i>vault</i>(1)'s test suite.

42"""

43

44

45 class Parametrize(types.SimpleNamespace): (empty)

46 ENTROPY_RESULTS = pytest.mark.parametrize( (empty)

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( (empty)

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( (empty)

77 ['service', 'expected'],

78 [

79 (b'google', GOOGLE_PHRASE),

80 ('twitter', TWITTER_PHRASE),

81 ],

82 )

83

84

85 def phrases_are_interchangable( (empty)

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: 5 ctx 1cdefg

108 return ( 5 ctx 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)) 5 ctx 1cdefg

115 phrase2 = canon(vault.Vault._get_binary_string(phrase2)) 5 ctx 1cdefg

116 return phrase1 == phrase2 5 ctx 1cdefg

117

118

119 class TestVault: (empty)

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

121

122 phrase = PHRASE (empty)

123

124 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1c

148 phrase=phrases[0], service=service

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

150

151 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1d

175 phrase=phrases[0], service=service

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

177

178 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1e

204 phrase=phrases[0], service=service

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

206

207 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1f

237 phrase=phrases[0], service=service

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

239

240 @hypothesis.given( (empty)

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( (empty)

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( 1 ctx 1y

260 phrase=phrase, service=services[0]

261 ) != vault.Vault.create_hash(phrase=phrase, service=services[1])

262

263 @hypothesis.given( (empty)

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( (empty)

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) 1 ctx 1r

286 assert vault.Vault.create_hash( 1 ctx 1r

287 phrase=phrases[0], service=service

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

289

290 @hypothesis.given( (empty)

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( (empty)

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) 1 ctx 1s

317 assert vault.Vault.create_hash( 1 ctx 1s

318 phrase=phrases[0], service=service

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

320

321 @Parametrize.SAMPLE_SERVICES_AND_PHRASES (empty)

322 def test_200_basic_configuration( (empty)

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 1 ctx 1z

327

328 def test_201_phrase_dependence(self) -> None: (empty)

329 """The derived passphrase is dependent on the master passphrase."""

330 assert ( 1 ctx 1A

331 vault.Vault(phrase=(self.phrase + b'X')).generate('google')

332 == b'n+oIz6sL>K*lTEWYRO%7'

333 )

334

335 @hypothesis.given( (empty)

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( (empty)

349 reason='phrases are interchangable',

350 raises=AssertionError,

351 )

352 @hypothesis.example( (empty)

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( (empty)

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( 1 ctx 1g

384 phrase=phrases[1]

385 ).generate(service)

386

387 def test_202a_reproducibility_and_bytes_service_name(self) -> None: (empty)

388 """Deriving a passphrase works equally for byte strings."""

389 assert vault.Vault(phrase=self.phrase).generate( 1 ctx 1B

390 b'google'

391 ) == vault.Vault(phrase=self.phrase).generate('google')

392

393 def test_202b_reproducibility_and_bytearray_service_name(self) -> None: (empty)

394 """Deriving a passphrase works equally for byte arrays."""

395 assert vault.Vault(phrase=self.phrase).generate( 1 ctx 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: (empty)

400 """Deriving a passphrase works equally for memory views."""

401 assert vault.Vault(phrase=self.phrase).generate( 1 ctx 1D

402 b'google'

403 ) == vault.Vault(phrase=self.phrase).generate(memoryview(b'google'))

404

405 @hypothesis.given( (empty)

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( (empty)

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]] = { 1 ctx 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(): 1 ctx 1j

430 str_phrase = phrase 1 ctx 1j

431 bytes_phrase = phrase.encode('utf-8') 1 ctx 1j

432 assert vault.Vault(phrase=str_phrase).generate( 1 ctx 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( (empty)

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( (empty)

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]] = { 1 ctx 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(): 1 ctx 1k

466 str_service = service 1 ctx 1k

467 bytes_service = service.encode('utf-8') 1 ctx 1k

468 assert vault.Vault(phrase=phrase).generate( 1 ctx 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( (empty)

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( (empty)

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( 1 ctx 1E

497 phrase=phrase

498 ).generate(services[1])

499

500 @hypothesis.given( (empty)

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( (empty)

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: 1 ctx 1t

522 assert vault.Vault(phrase=phrase, **config).generate( 1 ctx 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: (empty)

537 """Deriving a passphrase adheres to imposed length limits."""

538 assert ( 1 ctx 1F

539 vault.Vault(phrase=self.phrase, length=4).generate('google')

540 == b'xDFu'

541 )

542

543 @hypothesis.given( (empty)

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( (empty)

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) 1 ctx 1u

563 assert len(password) == length 1 ctx 1u

564

565 def test_211_repetition_limit(self) -> None: (empty)

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

567 assert ( 1 ctx 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: (empty)

575 """Deriving a passphrase adheres to imposed limits on symbols."""

576 assert ( 1 ctx 1H

577 vault.Vault(phrase=self.phrase, symbol=0).generate('google')

578 == b'XZ4wRe0bZCazbljCaMqR'

579 )

580

581 def test_213_no_numbers(self) -> None: (empty)

582 """Deriving a passphrase adheres to imposed limits on numbers."""

583 assert ( 1 ctx 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: (empty)

589 """

590 Deriving a passphrase adheres to imposed limits on lowercase letters.

591 """

592 assert ( 1 ctx 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: (empty)

598 """Deriving a passphrase adheres to imposed counts of numbers."""

599 assert ( 1 ctx 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: (empty)

607 """Deriving a passphrase adheres to imposed counts of spaces."""

608 assert ( 1 ctx 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: (empty)

614 """Deriving a passphrase adheres to imposed counts of all types."""

615 assert ( 1 ctx 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( (empty)

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( (empty)

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( (empty)

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( (empty)

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( (empty)

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: 1 ctx 1b

685 password = vault.Vault(phrase=phrase, **config).generate(service) 1 ctx 1b

686 except ValueError as exc: # pragma: no cover 1 ctx 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) 1 ctx 1b

693 # Otherwise it's a genuine bug in the test case or the

694 # implementation, and should be raised.

695 raise 1 ctx 1b

696 n = len(password) 1 ctx 1b

697 assert n == config['length'], 'Password has wrong length.' 1 ctx 1b

698 for key in ('lower', 'upper', 'number', 'space', 'dash', 'symbol'): 1 ctx 1b

699 if config[key] > 0: 1 ctx 1b

700 assert ( 1 ctx 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'}: 1 ctx 1b

708 # Character classes overlap, so "forbidden" characters may

709 # appear via the other character class.

710 assert True 1 ctx 1b

711 else:

712 assert ( 1 ctx 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) 1 ctx 1b

717

718 def length_r_substrings(string: T, *, r: int) -> Iterator[T]: 1 ctx 1b

719 for i in range(len(string) - (r - 1)): 1 ctx 1b

720 yield string[i : i + r] 1 ctx 1b

721

722 repeat = config['repeat'] 1 ctx 1b

723 if repeat: 1 ctx 1b

724 for snippet in length_r_substrings(password, r=(repeat + 1)): 1 ctx 1b

725 assert len(set(snippet)) > 1, ( 1 ctx 1b

726 'Password does not satisfy character repeat constraints.'

727 )

728

729 def test_218_only_numbers_and_very_high_repetition_limit(self) -> None: (empty)

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( 1 ctx 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 = { 1 ctx 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: 1 ctx 1m

758 assert substring not in generated 1 ctx 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) (empty)

763 @hypothesis.given( 2 ctx 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( (empty)

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( 1 ctx 1n

785 phrase=phrase, length=length, repeat=repeat

786 ).generate(service)

787 for i in range((length + 1) - (repeat + 1)): 1 ctx 1n

788 assert len(set(password[i : i + repeat + 1])) > 1 1 ctx 1n

789

790 def test_219_very_limited_character_set(self) -> None: (empty)

791 """Deriving a passphrase works even with limited character sets."""

792 generated = vault.Vault( 1 ctx 1v

793 phrase=b'', length=24, lower=0, upper=0, space=0, symbol=0

794 ).generate('testing')

795 assert generated == b'763252593304946694588866' 1 ctx 1v

796

797 def test_220_character_set_subtraction(self) -> None: (empty)

798 """Removing allowed characters internally works."""

799 assert vault.Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf') 1 ctx 1N

800

801 @Parametrize.ENTROPY_RESULTS (empty)

802 def test_221_entropy( (empty)

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] 1 ctx 1h

807 assert math.isclose(v._entropy(), entropy) 1 ctx 1h

808 assert v._estimate_sufficient_hash_length() > 0 1 ctx 1h

809 if math.isfinite(entropy) and entropy: 1 ctx 1h

810 assert v._estimate_sufficient_hash_length(1.0) == math.ceil( 1 ctx 1h

811 entropy / 8

812 )

813 assert v._estimate_sufficient_hash_length(8.0) >= entropy 1 ctx 1h

814

815 def test_222_hash_length_estimation(self) -> None: (empty)

816 """

817 Estimating the entropy and hash length for degenerate cases works.

818 """

819 v = vault.Vault( 1 ctx 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 1 ctx 1q

829 assert v._estimate_sufficient_hash_length() > 0 1 ctx 1q

830

831 @Parametrize.SAMPLE_SERVICES_AND_PHRASES (empty)

832 def test_223_hash_length_expansion( (empty)

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) 1 ctx 1o

842 monkeypatch.setattr( 1 ctx 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) 1 ctx 1o

848 assert v.generate(service) == expected 1 ctx 1o

849

850 @Parametrize.BINARY_STRINGS (empty)

851 def test_224_binary_strings(self, s: str | bytes | bytearray) -> None: (empty)

852 """Byte string conversion is idempotent."""

853 binstr = vault.Vault._get_binary_string 1 ctx 1i

854 if isinstance(s, str): 1 ctx 1i

855 assert binstr(s) == s.encode('UTF-8') 1 ctx 1i

856 assert binstr(binstr(s)) == s.encode('UTF-8') 1 ctx 1i

857 else:

858 assert binstr(s) == bytes(s) 1 ctx 1i

859 assert binstr(binstr(s)) == bytes(s) 1 ctx 1i

860

861 def test_310_too_many_symbols(self) -> None: (empty)

862 """Deriving short passphrases with large length constraints fails."""

863 with pytest.raises( 1 ctx 1w

864 ValueError, match='requested passphrase length too short'

865 ):

866 vault.Vault(phrase=self.phrase, symbol=100) 1 ctx 1w

867

868 def test_311_no_viable_characters(self) -> None: (empty)

869 """Deriving passphrases without allowed characters fails."""

870 with pytest.raises(ValueError, match='no allowed characters left'): 1 ctx 1x

871 vault.Vault( 1 ctx 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: (empty)

882 """Character sets do not contain duplicate characters."""

883 with pytest.raises(ValueError, match='duplicate characters'): 1 ctx 1p

884 vault.Vault._subtract(b'abcdef', b'aabbccddeeff') 1 ctx 1p

885 with pytest.raises(ValueError, match='duplicate characters'): 1 ctx 1p

886 vault.Vault._subtract(b'aabbccddeeff', b'abcdef') 1 ctx 1p

887

888 def test_322_hash_length_estimation(self) -> None: (empty)

889 """Hash length estimation rejects invalid safety factors."""

890 v = vault.Vault(phrase=self.phrase) 1 ctx 1l

891 with pytest.raises(ValueError, match='invalid safety factor'): 1 ctx 1l

892 assert v._estimate_sufficient_hash_length(-1.0) 1 ctx 1l

893 with pytest.raises( 1 ctx 1l

894 TypeError, match='invalid safety factor: not a float'

895 ):

896 assert v._estimate_sufficient_hash_length(None) # type: ignore[arg-type] 1 ctx 1l