Coverage for tests\test_derivepassphrase_cli_export_vault.py: 100.000%

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

2#

3# SPDX-License-Identifier: Zlib

4

5 from __future__ import annotations (empty)

6

7 import base64 (empty)

8 import contextlib (empty)

9 import json (empty)

10 import pathlib (empty)

11 import types (empty)

12 from typing import TYPE_CHECKING (empty)

13

14 import hypothesis (empty)

15 import pytest (empty)

16 from hypothesis import strategies (empty)

17

18 import tests (empty)

19 from derivepassphrase import _types, cli, exporter (empty)

20 from derivepassphrase.exporter import storeroom, vault_native (empty)

21

22 cryptography = pytest.importorskip('cryptography', minversion='38.0') (empty)

23

24 from cryptography.hazmat.primitives import ( # noqa: E402 (empty)

25 ciphers,

26 hashes,

27 hmac,

28 padding,

29)

30 from cryptography.hazmat.primitives.ciphers import ( # noqa: E402 (empty)

31 algorithms,

32 modes,

33)

34

35 if TYPE_CHECKING: (empty)

36 from collections.abc import Callable

37 from typing import Any

38

39 from typing_extensions import Buffer, Literal

40

41

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

43 BAD_CONFIG = pytest.mark.parametrize( (empty)

44 'config', ['xxx', 'null', '{"version": 255}']

45 )

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

47 ['config', 'format', 'config_data'],

48 [

49 pytest.param(

50 tests.VAULT_V02_CONFIG,

51 'v0.2',

52 tests.VAULT_V02_CONFIG_DATA,

53 id='V02_CONFIG-v0.2',

54 ),

55 pytest.param(

56 tests.VAULT_V02_CONFIG,

57 'v0.3',

58 exporter.NotAVaultConfigError,

59 id='V02_CONFIG-v0.3',

60 ),

61 pytest.param(

62 tests.VAULT_V03_CONFIG,

63 'v0.2',

64 exporter.NotAVaultConfigError,

65 id='V03_CONFIG-v0.2',

66 ),

67 pytest.param(

68 tests.VAULT_V03_CONFIG,

69 'v0.3',

70 tests.VAULT_V03_CONFIG_DATA,

71 id='V03_CONFIG-v0.3',

72 ),

73 ],

74 )

75 VAULT_NATIVE_PARSER_CLASS_DATA = pytest.mark.parametrize( (empty)

76 ['config', 'parser_class', 'config_data'],

77 [

78 pytest.param(

79 tests.VAULT_V02_CONFIG,

80 vault_native.VaultNativeV02ConfigParser,

81 tests.VAULT_V02_CONFIG_DATA,

82 id='0.2',

83 ),

84 pytest.param(

85 tests.VAULT_V03_CONFIG,

86 vault_native.VaultNativeV03ConfigParser,

87 tests.VAULT_V03_CONFIG_DATA,

88 id='0.3',

89 ),

90 ],

91 )

92 BAD_MASTER_KEYS_DATA = pytest.mark.parametrize( (empty)

93 ['data', 'err_msg'],

94 [

95 pytest.param(

96 '{"version": 255}',

97 'bad or unsupported keys version header',

98 id='v255',

99 ),

100 pytest.param(

101 '{"version": 1}\nAAAA\nAAAA',

102 'trailing data; cannot make sense',

103 id='trailing-data',

104 ),

105 pytest.param(

106 '{"version": 1}\nAAAA',

107 'cannot handle version 0 encrypted keys',

108 id='v0-keys',

109 ),

110 ],

111 )

112 STOREROOM_HANDLER = pytest.mark.parametrize( (empty)

113 'handler',

114 [

115 pytest.param(storeroom.export_storeroom_data, id='handler'),

116 pytest.param(exporter.export_vault_config_data, id='dispatcher'),

117 ],

118 )

119 VAULT_NATIVE_HANDLER = pytest.mark.parametrize( (empty)

120 'handler',

121 [

122 pytest.param(vault_native.export_vault_native_data, id='handler'),

123 pytest.param(exporter.export_vault_config_data, id='dispatcher'),

124 ],

125 )

126 VAULT_NATIVE_PBKDF2_RESULT = pytest.mark.parametrize( (empty)

127 ['iterations', 'result'],

128 [

129 pytest.param(100, b'6ede361e81e9c061efcdd68aeb768b80', id='100'),

130 pytest.param(200, b'bcc7d01e075b9ffb69e702bf701187c1', id='200'),

131 ],

132 )

133 KEY_FORMATS = pytest.mark.parametrize( (empty)

134 'key',

135 [

136 None,

137 pytest.param(tests.VAULT_MASTER_KEY, id='str'),

138 pytest.param(tests.VAULT_MASTER_KEY.encode('ascii'), id='bytes'),

139 pytest.param(

140 bytearray(tests.VAULT_MASTER_KEY.encode('ascii')),

141 id='bytearray',

142 ),

143 pytest.param(

144 memoryview(tests.VAULT_MASTER_KEY.encode('ascii')),

145 id='memoryview',

146 ),

147 ],

148 )

149 PATH = pytest.mark.parametrize('path', ['.vault', None]) (empty)

150 BAD_STOREROOM_CONFIG_DATA = pytest.mark.parametrize( (empty)

151 ['zipped_config', 'error_text'],

152 [

153 pytest.param(

154 tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED,

155 'Object key mismatch',

156 id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED',

157 ),

158 pytest.param(

159 tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2,

160 'Directory index is not actually an index',

161 id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2',

162 ),

163 pytest.param(

164 tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3,

165 'Directory index is not actually an index',

166 id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3',

167 ),

168 pytest.param(

169 tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4,

170 'Object key mismatch',

171 id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4',

172 ),

173 ],

174 )

175

176

177 class TestCLI: (empty)

178 """Test the command-line interface for `derivepassphrase export vault`."""

179

180 def test_200_path_parameter(self) -> None: (empty)

181 """The path `VAULT_PATH` is supported.

182

183 Using `VAULT_PATH` as the path looks up the actual path in the

184 `VAULT_PATH` environment variable. See

185 [`exporter.get_vault_path`][] for details.

186

187 """

188 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1i

189 # TODO(the-13th-letter): Rewrite using parenthesized

190 # with-statements.

191 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

192 with contextlib.ExitStack() as stack: 1 ctx 1i

193 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1i

194 stack.enter_context( 1 ctx 1i

195 tests.isolated_vault_exporter_config(

196 monkeypatch=monkeypatch,

197 runner=runner,

198 vault_config=tests.VAULT_V03_CONFIG,

199 vault_key=tests.VAULT_MASTER_KEY,

200 )

201 )

202 monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY) 1 ctx 1i

203 result = runner.invoke( 1 ctx 1i

204 cli.derivepassphrase_export_vault,

205 ['VAULT_PATH'],

206 )

207 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1 ctx 1i

208 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1 ctx 1i

209

210 def test_201_key_parameter(self) -> None: (empty)

211 """The `--key` option is supported."""

212 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1j

213 # TODO(the-13th-letter): Rewrite using parenthesized

214 # with-statements.

215 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

216 with contextlib.ExitStack() as stack: 1 ctx 1j

217 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1j

218 stack.enter_context( 1 ctx 1j

219 tests.isolated_vault_exporter_config(

220 monkeypatch=monkeypatch,

221 runner=runner,

222 vault_config=tests.VAULT_V03_CONFIG,

223 )

224 )

225 result = runner.invoke( 1 ctx 1j

226 cli.derivepassphrase_export_vault,

227 ['-k', tests.VAULT_MASTER_KEY, '.vault'],

228 )

229 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1 ctx 1j

230 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1 ctx 1j

231

232 @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA (empty)

233 def test_210_load_vault_v02_v03_storeroom( (empty)

234 self,

235 config: str | bytes,

236 format: str,

237 config_data: dict[str, Any],

238 ) -> None:

239 """Passing a specific format works.

240

241 Passing a specific format name causes `derivepassphrase export

242 vault` to only attempt decoding in that named format.

243

244 """

245 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1k

246 # TODO(the-13th-letter): Rewrite using parenthesized

247 # with-statements.

248 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

249 with contextlib.ExitStack() as stack: 1 ctx 1k

250 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1k

251 stack.enter_context( 1 ctx 1k

252 tests.isolated_vault_exporter_config(

253 monkeypatch=monkeypatch,

254 runner=runner,

255 vault_config=config,

256 )

257 )

258 result = runner.invoke( 1 ctx 1k

259 cli.derivepassphrase_export_vault,

260 ['-f', format, '-k', tests.VAULT_MASTER_KEY, 'VAULT_PATH'],

261 )

262 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1 ctx 1k

263 assert json.loads(result.stdout) == config_data 1 ctx 1k

264

265 # test_300_invalid_format is found in

266 # tests.test_derivepassphrase_export::Test002CLI

267

268 def test_301_vault_config_not_found( (empty)

269 self,

270 caplog: pytest.LogCaptureFixture,

271 ) -> None:

272 """Fail when trying to decode non-existant files/directories."""

273 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1l

274 # TODO(the-13th-letter): Rewrite using parenthesized

275 # with-statements.

276 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

277 with contextlib.ExitStack() as stack: 1 ctx 1l

278 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1l

279 stack.enter_context( 1 ctx 1l

280 tests.isolated_vault_exporter_config(

281 monkeypatch=monkeypatch,

282 runner=runner,

283 vault_config=tests.VAULT_V03_CONFIG,

284 vault_key=tests.VAULT_MASTER_KEY,

285 )

286 )

287 result = runner.invoke( 1 ctx 1l

288 cli.derivepassphrase_export_vault,

289 ['does-not-exist.txt'],

290 )

291 assert result.error_exit( 1 ctx 1l

292 error=(

293 "Cannot parse 'does-not-exist.txt' "

294 'as a valid vault-native config'

295 ),

296 record_tuples=caplog.record_tuples,

297 ), 'expected error exit and known error message'

298 assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr 1 ctx 1l

299

300 def test_302_vault_config_invalid( (empty)

301 self,

302 caplog: pytest.LogCaptureFixture,

303 ) -> None:

304 """Fail to parse invalid vault configurations (files)."""

305 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1m

306 # TODO(the-13th-letter): Rewrite using parenthesized

307 # with-statements.

308 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

309 with contextlib.ExitStack() as stack: 1 ctx 1m

310 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1m

311 stack.enter_context( 1 ctx 1m

312 tests.isolated_vault_exporter_config(

313 monkeypatch=monkeypatch,

314 runner=runner,

315 vault_config='',

316 vault_key=tests.VAULT_MASTER_KEY,

317 )

318 )

319 result = runner.invoke( 1 ctx 1m

320 cli.derivepassphrase_export_vault,

321 ['.vault'],

322 )

323 assert result.error_exit( 1 ctx 1m

324 error="Cannot parse '.vault' as a valid vault-native config",

325 record_tuples=caplog.record_tuples,

326 ), 'expected error exit and known error message'

327 assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr 1 ctx 1m

328

329 def test_302a_vault_config_invalid_just_a_directory( (empty)

330 self,

331 caplog: pytest.LogCaptureFixture,

332 ) -> None:

333 """Fail to parse invalid vault configurations (directories)."""

334 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1d

335 # TODO(the-13th-letter): Rewrite using parenthesized

336 # with-statements.

337 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

338 with contextlib.ExitStack() as stack: 1 ctx 1d

339 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1d

340 stack.enter_context( 1 ctx 1d

341 tests.isolated_vault_exporter_config(

342 monkeypatch=monkeypatch,

343 runner=runner,

344 vault_config='',

345 vault_key=tests.VAULT_MASTER_KEY,

346 )

347 )

348 p = pathlib.Path('.vault') 1 ctx 1d

349 p.unlink() 1 ctx 1d

350 p.mkdir() 1 ctx 1d

351 result = runner.invoke( 1 ctx 1d

352 cli.derivepassphrase_export_vault,

353 [str(p)],

354 )

355 assert result.error_exit( 1 ctx 1d

356 error="Cannot parse '.vault' as a valid vault-native config",

357 record_tuples=caplog.record_tuples,

358 ), 'expected error exit and known error message'

359 assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr 1 ctx 1d

360

361 def test_403_invalid_vault_config_bad_signature( (empty)

362 self,

363 caplog: pytest.LogCaptureFixture,

364 ) -> None:

365 """Fail to parse vault configurations with invalid integrity checks."""

366 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1n

367 # TODO(the-13th-letter): Rewrite using parenthesized

368 # with-statements.

369 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

370 with contextlib.ExitStack() as stack: 1 ctx 1n

371 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1n

372 stack.enter_context( 1 ctx 1n

373 tests.isolated_vault_exporter_config(

374 monkeypatch=monkeypatch,

375 runner=runner,

376 vault_config=tests.VAULT_V02_CONFIG,

377 vault_key=tests.VAULT_MASTER_KEY,

378 )

379 )

380 result = runner.invoke( 1 ctx 1n

381 cli.derivepassphrase_export_vault,

382 ['-f', 'v0.3', '.vault'],

383 )

384 assert result.error_exit( 1 ctx 1n

385 error="Cannot parse '.vault' as a valid vault-native config",

386 record_tuples=caplog.record_tuples,

387 ), 'expected error exit and known error message'

388 assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr 1 ctx 1n

389

390 def test_500_vault_config_invalid_internal( (empty)

391 self,

392 caplog: pytest.LogCaptureFixture,

393 ) -> None:

394 """The decoded vault configuration data is valid."""

395 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1e

396 # TODO(the-13th-letter): Rewrite using parenthesized

397 # with-statements.

398 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

399 with contextlib.ExitStack() as stack: 1 ctx 1e

400 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1e

401 stack.enter_context( 1 ctx 1e

402 tests.isolated_vault_exporter_config(

403 monkeypatch=monkeypatch,

404 runner=runner,

405 vault_config=tests.VAULT_V03_CONFIG,

406 vault_key=tests.VAULT_MASTER_KEY,

407 )

408 )

409

410 def export_vault_config_data(*_args: Any, **_kwargs: Any) -> None: 1 ctx 1e

411 return None 1 ctx 1e

412

413 monkeypatch.setattr( 1 ctx 1e

414 exporter,

415 'export_vault_config_data',

416 export_vault_config_data,

417 )

418 result = runner.invoke( 1 ctx 1e

419 cli.derivepassphrase_export_vault,

420 ['.vault'],

421 )

422 assert result.error_exit( 1 ctx 1e

423 error='Invalid vault config: ',

424 record_tuples=caplog.record_tuples,

425 ), 'expected error exit and known error message'

426 assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr 1 ctx 1e

427

428

429 class TestStoreroom: (empty)

430 """Test the "storeroom" handler and handler machinery."""

431

432 @Parametrize.PATH (empty)

433 @Parametrize.KEY_FORMATS (empty)

434 @Parametrize.STOREROOM_HANDLER (empty)

435 def test_200_export_data_path_and_keys_type( (empty)

436 self,

437 path: str | None,

438 key: str | Buffer | None,

439 handler: exporter.ExportVaultConfigDataFunction,

440 ) -> None:

441 """Support different argument types.

442

443 The [`exporter.export_vault_config_data`][] dispatcher supports

444 them as well.

445

446 """

447 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1p

448 # TODO(the-13th-letter): Rewrite using parenthesized

449 # with-statements.

450 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

451 with contextlib.ExitStack() as stack: 1 ctx 1p

452 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1p

453 stack.enter_context( 1 ctx 1p

454 tests.isolated_vault_exporter_config(

455 monkeypatch=monkeypatch,

456 runner=runner,

457 vault_config=tests.VAULT_STOREROOM_CONFIG_ZIPPED,

458 vault_key=tests.VAULT_MASTER_KEY,

459 )

460 )

461 assert ( 1 ctx 1p

462 handler(path, key, format='storeroom')

463 == tests.VAULT_STOREROOM_CONFIG_DATA

464 )

465

466 def test_400_decrypt_bucket_item_unknown_version(self) -> None: (empty)

467 """Fail on unknown versions of the master keys file."""

468 bucket_item = ( 1 ctx 1s

469 b'\xff' + bytes(storeroom.ENCRYPTED_KEYPAIR_SIZE) + bytes(3)

470 )

471 master_keys = _types.StoreroomMasterKeys( 1 ctx 1s

472 encryption_key=bytes(storeroom.KEY_SIZE),

473 signing_key=bytes(storeroom.KEY_SIZE),

474 hashing_key=bytes(storeroom.KEY_SIZE),

475 )

476 with pytest.raises(ValueError, match='Cannot handle version 255'): 1 ctx 1s

477 storeroom._decrypt_bucket_item(bucket_item, master_keys) 1 ctx 1s

478

479 @Parametrize.BAD_CONFIG (empty)

480 def test_401_decrypt_bucket_file_bad_json_or_version( (empty)

481 self,

482 config: str,

483 ) -> None:

484 """Fail on bad or unsupported bucket file contents.

485

486 These include unknown versions, invalid JSON, or JSON of the

487 wrong shape.

488

489 """

490 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1f

491 master_keys = _types.StoreroomMasterKeys( 1 ctx 1f

492 encryption_key=bytes(storeroom.KEY_SIZE),

493 signing_key=bytes(storeroom.KEY_SIZE),

494 hashing_key=bytes(storeroom.KEY_SIZE),

495 )

496 # TODO(the-13th-letter): Rewrite using parenthesized

497 # with-statements.

498 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

499 with contextlib.ExitStack() as stack: 1 ctx 1f

500 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1f

501 stack.enter_context( 1 ctx 1f

502 tests.isolated_vault_exporter_config(

503 monkeypatch=monkeypatch,

504 runner=runner,

505 vault_config=tests.VAULT_STOREROOM_CONFIG_ZIPPED,

506 )

507 )

508 p = pathlib.Path('.vault', '20') 1 ctx 1f

509 with p.open('w', encoding='UTF-8') as outfile: 1 ctx 1f

510 print(config, file=outfile) 1 ctx 1f

511 with pytest.raises(ValueError, match='Invalid bucket file: '): 1 ctx 1f

512 list(storeroom._decrypt_bucket_file(p, master_keys)) 1 ctx 1f

513

514 @Parametrize.BAD_MASTER_KEYS_DATA (empty)

515 @Parametrize.STOREROOM_HANDLER (empty)

516 def test_402_export_storeroom_data_bad_master_keys_file( (empty)

517 self,

518 data: str,

519 err_msg: str,

520 handler: exporter.ExportVaultConfigDataFunction,

521 ) -> None:

522 """Fail on bad or unsupported master keys file contents.

523

524 These include unknown versions, and data of the wrong shape.

525

526 """

527 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1g

528 # TODO(the-13th-letter): Rewrite using parenthesized

529 # with-statements.

530 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

531 with contextlib.ExitStack() as stack: 1 ctx 1g

532 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1g

533 stack.enter_context( 1 ctx 1g

534 tests.isolated_vault_exporter_config(

535 monkeypatch=monkeypatch,

536 runner=runner,

537 vault_config=tests.VAULT_STOREROOM_CONFIG_ZIPPED,

538 vault_key=tests.VAULT_MASTER_KEY,

539 )

540 )

541 p = pathlib.Path('.vault', '.keys') 1 ctx 1g

542 with p.open('w', encoding='UTF-8') as outfile: 1 ctx 1g

543 print(data, file=outfile) 1 ctx 1g

544 with pytest.raises(RuntimeError, match=err_msg): 1 ctx 1g

545 handler(format='storeroom') 1 ctx 1g

546

547 @Parametrize.BAD_STOREROOM_CONFIG_DATA (empty)

548 @Parametrize.STOREROOM_HANDLER (empty)

549 def test_403_export_storeroom_data_bad_directory_listing( (empty)

550 self,

551 zipped_config: bytes,

552 error_text: str,

553 handler: exporter.ExportVaultConfigDataFunction,

554 ) -> None:

555 """Fail on bad decoded directory structures.

556

557 If the decoded configuration contains directories whose

558 structures are inconsistent, it detects this and fails:

559

560 - The key indicates a directory, but the contents don't.

561 - The directory indicates children with invalid path names.

562 - The directory indicates children that are missing from the

563 configuration entirely.

564 - The configuration contains nested subdirectories, but the

565 higher-level directories don't indicate their

566 subdirectories.

567

568 """

569 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1o

570 # TODO(the-13th-letter): Rewrite using parenthesized

571 # with-statements.

572 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

573 with contextlib.ExitStack() as stack: 1 ctx 1o

574 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1o

575 stack.enter_context( 1 ctx 1o

576 tests.isolated_vault_exporter_config(

577 monkeypatch=monkeypatch,

578 runner=runner,

579 vault_config=zipped_config,

580 vault_key=tests.VAULT_MASTER_KEY,

581 )

582 )

583 stack.enter_context(pytest.raises(RuntimeError, match=error_text)) 1 ctx 1o

584 handler(format='storeroom') 1 ctx 1o

585

586 def test_404_decrypt_keys_wrong_data_length(self) -> None: (empty)

587 """Fail on internal structural data of the wrong size.

588

589 Specifically, fail on internal structural data such as master

590 keys or session keys that is correctly encrypted according to

591 its MAC, but is of the wrong shape. (Since the data usually are

592 keys and thus are opaque, the only detectable shape violation is

593 the wrong size of the data.)

594

595 """

596 payload = ( 1 ctx 1b

597 b"Any text here, as long as it isn't exactly 64 or 96 bytes long."

598 )

599 assert len(payload) not in frozenset({ 1 ctx 1b

600 2 * storeroom.KEY_SIZE,

601 3 * storeroom.KEY_SIZE,

602 })

603 key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF' 1 ctx 1b

604 padder = padding.PKCS7(storeroom.IV_SIZE * 8).padder() 1 ctx 1b

605 plaintext = bytearray(padder.update(payload)) 1 ctx 1b

606 plaintext.extend(padder.finalize()) 1 ctx 1b

607 iv = b'deadbeefDEADBEEF' 1 ctx 1b

608 assert len(iv) == storeroom.IV_SIZE 1 ctx 1b

609 encryptor = ciphers.Cipher( 1 ctx 1b

610 algorithms.AES256(key), modes.CBC(iv)

611 ).encryptor()

612 ciphertext = bytearray(encryptor.update(plaintext)) 1 ctx 1b

613 ciphertext.extend(encryptor.finalize()) 1 ctx 1b

614 mac_obj = hmac.HMAC(key, hashes.SHA256()) 1 ctx 1b

615 mac_obj.update(iv) 1 ctx 1b

616 mac_obj.update(ciphertext) 1 ctx 1b

617 data = iv + bytes(ciphertext) + mac_obj.finalize() 1 ctx 1b

618 with pytest.raises( 1 ctx 1b

619 ValueError,

620 match=r'Invalid encrypted master keys payload',

621 ):

622 storeroom._decrypt_master_keys_data( 1 ctx 1b

623 data,

624 _types.StoreroomKeyPair(encryption_key=key, signing_key=key),

625 )

626 with pytest.raises( 1 ctx 1b

627 ValueError,

628 match=r'Invalid encrypted session keys payload',

629 ):

630 storeroom._decrypt_session_keys( 1 ctx 1b

631 data,

632 _types.StoreroomMasterKeys(

633 hashing_key=key, encryption_key=key, signing_key=key

634 ),

635 )

636

637 @hypothesis.given( (empty)

638 data=strategies.binary(

639 min_size=storeroom.MAC_SIZE, max_size=storeroom.MAC_SIZE

640 ),

641 )

642 def test_405_decrypt_keys_invalid_signature(self, data: bytes) -> None: (empty)

643 """Fail on bad MAC values."""

644 key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF' 1 ctx 1q

645 # Guessing a correct payload plus MAC would be a pre-image

646 # attack on the underlying hash function (SHA-256), i.e. is

647 # computationally infeasible, and the chance of finding one by

648 # such random sampling is astronomically tiny.

649 with pytest.raises(cryptography.exceptions.InvalidSignature): 1 ctx 1q

650 storeroom._decrypt_master_keys_data( 1 ctx 1q

651 data,

652 _types.StoreroomKeyPair(encryption_key=key, signing_key=key),

653 )

654 with pytest.raises(cryptography.exceptions.InvalidSignature): 1 ctx 1q

655 storeroom._decrypt_session_keys( 1 ctx 1q

656 data,

657 _types.StoreroomMasterKeys(

658 hashing_key=key, encryption_key=key, signing_key=key

659 ),

660 )

661

662

663 class TestVaultNativeConfig: (empty)

664 """Test the vault-native handler and handler machinery."""

665

666 @Parametrize.VAULT_NATIVE_PBKDF2_RESULT (empty)

667 def test_200_pbkdf2_manually(self, iterations: int, result: bytes) -> None: (empty)

668 """The PBKDF2 helper function works."""

669 assert ( 1 ctx 1u

670 vault_native.VaultNativeConfigParser._pbkdf2(

671 tests.VAULT_MASTER_KEY.encode('utf-8'), 32, iterations

672 )

673 == result

674 )

675

676 @Parametrize.VAULT_NATIVE_CONFIG_DATA (empty)

677 @Parametrize.VAULT_NATIVE_HANDLER (empty)

678 def test_201_export_vault_native_data_explicit_version( (empty)

679 self,

680 config: str,

681 format: Literal['v0.2', 'v0.3'],

682 config_data: _types.VaultConfig | type[Exception],

683 handler: exporter.ExportVaultConfigDataFunction,

684 ) -> None:

685 """Accept data only of the correct version.

686

687 Note: Historic behavior

688 `derivepassphrase` versions prior to 0.5 automatically tried

689 to parse vault-native configurations as v0.3-type, then

690 v0.2-type. Since `derivepassphrase` 0.5, the command-line

691 interface still tries multi-version parsing, but the API

692 no longer does.

693

694 """

695 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1h

696 # TODO(the-13th-letter): Rewrite using parenthesized

697 # with-statements.

698 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

699 with contextlib.ExitStack() as stack: 1 ctx 1h

700 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1h

701 stack.enter_context( 1 ctx 1h

702 tests.isolated_vault_exporter_config(

703 monkeypatch=monkeypatch,

704 runner=runner,

705 vault_config=config,

706 vault_key=tests.VAULT_MASTER_KEY,

707 )

708 )

709 if isinstance(config_data, type): 1 ctx 1h

710 with pytest.raises(config_data): 1 ctx 1h

711 handler(None, format=format) 1 ctx 1h

712 else:

713 parsed_config = handler(None, format=format) 1 ctx 1h

714 assert parsed_config == config_data 1 ctx 1h

715

716 @Parametrize.PATH (empty)

717 @Parametrize.KEY_FORMATS (empty)

718 @Parametrize.VAULT_NATIVE_HANDLER (empty)

719 def test_202_export_data_path_and_keys_type( (empty)

720 self,

721 path: str | None,

722 key: str | Buffer | None,

723 handler: exporter.ExportVaultConfigDataFunction,

724 ) -> None:

725 """The handler supports different argument types.

726

727 The [`exporter.export_vault_config_data`][] dispatcher supports

728 them as well.

729

730 """

731 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1r

732 # TODO(the-13th-letter): Rewrite using parenthesized

733 # with-statements.

734 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

735 with contextlib.ExitStack() as stack: 1 ctx 1r

736 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1r

737 stack.enter_context( 1 ctx 1r

738 tests.isolated_vault_exporter_config(

739 monkeypatch=monkeypatch,

740 runner=runner,

741 vault_config=tests.VAULT_V03_CONFIG,

742 vault_key=tests.VAULT_MASTER_KEY,

743 )

744 )

745 assert ( 1 ctx 1r

746 handler(path, key, format='v0.3')

747 == tests.VAULT_V03_CONFIG_DATA

748 )

749

750 @Parametrize.VAULT_NATIVE_PARSER_CLASS_DATA (empty)

751 def test_300_result_caching( (empty)

752 self,

753 config: str,

754 parser_class: type[vault_native.VaultNativeConfigParser],

755 config_data: dict[str, Any],

756 ) -> None:

757 """Cache the results of decrypting/decoding a configuration."""

758

759 def null_func(name: str) -> Callable[..., None]: 1 ctx 1c

760 def func(*_args: Any, **_kwargs: Any) -> None: # pragma: no cover 1 ctx 1c

761 msg = f'disallowed and stubbed out function {name} called'

762 raise AssertionError(msg)

763

764 return func 1 ctx 1c

765

766 runner = tests.CliRunner(mix_stderr=False) 1 ctx 1c

767 # TODO(the-13th-letter): Rewrite using parenthesized

768 # with-statements.

769 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9

770 with contextlib.ExitStack() as stack: 1 ctx 1c

771 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1 ctx 1c

772 stack.enter_context( 1 ctx 1c

773 tests.isolated_vault_exporter_config(

774 monkeypatch=monkeypatch,

775 runner=runner,

776 vault_config=config,

777 )

778 )

779 parser = parser_class( 1 ctx 1c

780 base64.b64decode(config), tests.VAULT_MASTER_KEY

781 )

782 assert parser() == config_data 1 ctx 1c

783 # Now stub out all functions used to calculate the above result.

784 monkeypatch.setattr( 1 ctx 1c

785 parser, '_parse_contents', null_func('_parse_contents')

786 )

787 monkeypatch.setattr( 1 ctx 1c

788 parser, '_derive_keys', null_func('_derive_keys')

789 )

790 monkeypatch.setattr( 1 ctx 1c

791 parser, '_check_signature', null_func('_check_signature')

792 )

793 monkeypatch.setattr( 1 ctx 1c

794 parser, '_decrypt_payload', null_func('_decrypt_payload')

795 )

796 assert parser() == config_data 1 ctx 1c

797 super_call = vault_native.VaultNativeConfigParser.__call__ 1 ctx 1c

798 assert super_call(parser) == config_data 1 ctx 1c

799

800 def test_400_no_password(self) -> None: (empty)

801 """Fail on empty master keys/master passphrases."""

802 with pytest.raises(ValueError, match='Password must not be empty'): 1 ctx 1t

803 vault_native.VaultNativeV03ConfigParser(b'', b'') 1 ctx 1t