Coverage for tests\test_derivepassphrase_cli_export_vault.py: 100.000%
220 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-23 12:17 +0200
1# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5from __future__ import annotations
7import base64
8import contextlib
9import json
10import pathlib
11import types
12from typing import TYPE_CHECKING
14import hypothesis
15import pytest
16from hypothesis import strategies
18import tests
19from derivepassphrase import _types, cli, exporter
20from derivepassphrase.exporter import storeroom, vault_native
22cryptography = pytest.importorskip('cryptography', minversion='38.0')
24from cryptography.hazmat.primitives import ( # noqa: E402
25 ciphers,
26 hashes,
27 hmac,
28 padding,
29)
30from cryptography.hazmat.primitives.ciphers import ( # noqa: E402
31 algorithms,
32 modes,
33)
35if TYPE_CHECKING:
36 from collections.abc import Callable
37 from typing import Any
39 from typing_extensions import Buffer, Literal
42class Parametrize(types.SimpleNamespace):
43 BAD_CONFIG = pytest.mark.parametrize(
44 'config', ['xxx', 'null', '{"version": 255}']
45 )
46 VAULT_NATIVE_CONFIG_DATA = pytest.mark.parametrize(
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(
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(
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(
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(
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(
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(
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])
150 BAD_STOREROOM_CONFIG_DATA = pytest.mark.parametrize(
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 )
177class TestCLI:
178 """Test the command-line interface for `derivepassphrase export vault`."""
180 def test_200_path_parameter(self) -> None:
181 """The path `VAULT_PATH` is supported.
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.
187 """
188 runner = tests.CliRunner(mix_stderr=False) 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: 1i
193 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1i
194 stack.enter_context( 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) 1i
203 result = runner.invoke( 1i
204 cli.derivepassphrase_export_vault,
205 ['VAULT_PATH'],
206 )
207 assert result.clean_exit(empty_stderr=True), 'expected clean exit' 1i
208 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1i
210 def test_201_key_parameter(self) -> None:
211 """The `--key` option is supported."""
212 runner = tests.CliRunner(mix_stderr=False) 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: 1j
217 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1j
218 stack.enter_context( 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( 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' 1j
230 assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA 1j
232 @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA
233 def test_210_load_vault_v02_v03_storeroom(
234 self,
235 config: str | bytes,
236 format: str,
237 config_data: dict[str, Any],
238 ) -> None:
239 """Passing a specific format works.
241 Passing a specific format name causes `derivepassphrase export
242 vault` to only attempt decoding in that named format.
244 """
245 runner = tests.CliRunner(mix_stderr=False) 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: 1k
250 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1k
251 stack.enter_context( 1k
252 tests.isolated_vault_exporter_config(
253 monkeypatch=monkeypatch,
254 runner=runner,
255 vault_config=config,
256 )
257 )
258 result = runner.invoke( 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' 1k
263 assert json.loads(result.stdout) == config_data 1k
265 # test_300_invalid_format is found in
266 # tests.test_derivepassphrase_export::Test002CLI
268 def test_301_vault_config_not_found(
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) 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: 1l
278 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1l
279 stack.enter_context( 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( 1l
288 cli.derivepassphrase_export_vault,
289 ['does-not-exist.txt'],
290 )
291 assert result.error_exit( 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 1l
300 def test_302_vault_config_invalid(
301 self,
302 caplog: pytest.LogCaptureFixture,
303 ) -> None:
304 """Fail to parse invalid vault configurations (files)."""
305 runner = tests.CliRunner(mix_stderr=False) 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: 1m
310 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1m
311 stack.enter_context( 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( 1m
320 cli.derivepassphrase_export_vault,
321 ['.vault'],
322 )
323 assert result.error_exit( 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 1m
329 def test_302a_vault_config_invalid_just_a_directory(
330 self,
331 caplog: pytest.LogCaptureFixture,
332 ) -> None:
333 """Fail to parse invalid vault configurations (directories)."""
334 runner = tests.CliRunner(mix_stderr=False) 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: 1d
339 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1d
340 stack.enter_context( 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') 1d
349 p.unlink() 1d
350 p.mkdir() 1d
351 result = runner.invoke( 1d
352 cli.derivepassphrase_export_vault,
353 [str(p)],
354 )
355 assert result.error_exit( 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 1d
361 def test_403_invalid_vault_config_bad_signature(
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) 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: 1n
371 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1n
372 stack.enter_context( 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( 1n
381 cli.derivepassphrase_export_vault,
382 ['-f', 'v0.3', '.vault'],
383 )
384 assert result.error_exit( 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 1n
390 def test_500_vault_config_invalid_internal(
391 self,
392 caplog: pytest.LogCaptureFixture,
393 ) -> None:
394 """The decoded vault configuration data is valid."""
395 runner = tests.CliRunner(mix_stderr=False) 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: 1e
400 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1e
401 stack.enter_context( 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 )
410 def export_vault_config_data(*_args: Any, **_kwargs: Any) -> None: 1e
411 return None 1e
413 monkeypatch.setattr( 1e
414 exporter,
415 'export_vault_config_data',
416 export_vault_config_data,
417 )
418 result = runner.invoke( 1e
419 cli.derivepassphrase_export_vault,
420 ['.vault'],
421 )
422 assert result.error_exit( 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 1e
429class TestStoreroom:
430 """Test the "storeroom" handler and handler machinery."""
432 @Parametrize.PATH
433 @Parametrize.KEY_FORMATS
434 @Parametrize.STOREROOM_HANDLER
435 def test_200_export_data_path_and_keys_type(
436 self,
437 path: str | None,
438 key: str | Buffer | None,
439 handler: exporter.ExportVaultConfigDataFunction,
440 ) -> None:
441 """Support different argument types.
443 The [`exporter.export_vault_config_data`][] dispatcher supports
444 them as well.
446 """
447 runner = tests.CliRunner(mix_stderr=False) 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: 1p
452 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1p
453 stack.enter_context( 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 ( 1p
462 handler(path, key, format='storeroom')
463 == tests.VAULT_STOREROOM_CONFIG_DATA
464 )
466 def test_400_decrypt_bucket_item_unknown_version(self) -> None:
467 """Fail on unknown versions of the master keys file."""
468 bucket_item = ( 1s
469 b'\xff' + bytes(storeroom.ENCRYPTED_KEYPAIR_SIZE) + bytes(3)
470 )
471 master_keys = _types.StoreroomMasterKeys( 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'): 1s
477 storeroom._decrypt_bucket_item(bucket_item, master_keys) 1s
479 @Parametrize.BAD_CONFIG
480 def test_401_decrypt_bucket_file_bad_json_or_version(
481 self,
482 config: str,
483 ) -> None:
484 """Fail on bad or unsupported bucket file contents.
486 These include unknown versions, invalid JSON, or JSON of the
487 wrong shape.
489 """
490 runner = tests.CliRunner(mix_stderr=False) 1f
491 master_keys = _types.StoreroomMasterKeys( 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: 1f
500 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1f
501 stack.enter_context( 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') 1f
509 with p.open('w', encoding='UTF-8') as outfile: 1f
510 print(config, file=outfile) 1f
511 with pytest.raises(ValueError, match='Invalid bucket file: '): 1f
512 list(storeroom._decrypt_bucket_file(p, master_keys)) 1f
514 @Parametrize.BAD_MASTER_KEYS_DATA
515 @Parametrize.STOREROOM_HANDLER
516 def test_402_export_storeroom_data_bad_master_keys_file(
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.
524 These include unknown versions, and data of the wrong shape.
526 """
527 runner = tests.CliRunner(mix_stderr=False) 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: 1g
532 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1g
533 stack.enter_context( 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') 1g
542 with p.open('w', encoding='UTF-8') as outfile: 1g
543 print(data, file=outfile) 1g
544 with pytest.raises(RuntimeError, match=err_msg): 1g
545 handler(format='storeroom') 1g
547 @Parametrize.BAD_STOREROOM_CONFIG_DATA
548 @Parametrize.STOREROOM_HANDLER
549 def test_403_export_storeroom_data_bad_directory_listing(
550 self,
551 zipped_config: bytes,
552 error_text: str,
553 handler: exporter.ExportVaultConfigDataFunction,
554 ) -> None:
555 """Fail on bad decoded directory structures.
557 If the decoded configuration contains directories whose
558 structures are inconsistent, it detects this and fails:
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.
568 """
569 runner = tests.CliRunner(mix_stderr=False) 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: 1o
574 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1o
575 stack.enter_context( 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)) 1o
584 handler(format='storeroom') 1o
586 def test_404_decrypt_keys_wrong_data_length(self) -> None:
587 """Fail on internal structural data of the wrong size.
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.)
595 """
596 payload = ( 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({ 1b
600 2 * storeroom.KEY_SIZE,
601 3 * storeroom.KEY_SIZE,
602 })
603 key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF' 1b
604 padder = padding.PKCS7(storeroom.IV_SIZE * 8).padder() 1b
605 plaintext = bytearray(padder.update(payload)) 1b
606 plaintext.extend(padder.finalize()) 1b
607 iv = b'deadbeefDEADBEEF' 1b
608 assert len(iv) == storeroom.IV_SIZE 1b
609 encryptor = ciphers.Cipher( 1b
610 algorithms.AES256(key), modes.CBC(iv)
611 ).encryptor()
612 ciphertext = bytearray(encryptor.update(plaintext)) 1b
613 ciphertext.extend(encryptor.finalize()) 1b
614 mac_obj = hmac.HMAC(key, hashes.SHA256()) 1b
615 mac_obj.update(iv) 1b
616 mac_obj.update(ciphertext) 1b
617 data = iv + bytes(ciphertext) + mac_obj.finalize() 1b
618 with pytest.raises( 1b
619 ValueError,
620 match=r'Invalid encrypted master keys payload',
621 ):
622 storeroom._decrypt_master_keys_data( 1b
623 data,
624 _types.StoreroomKeyPair(encryption_key=key, signing_key=key),
625 )
626 with pytest.raises( 1b
627 ValueError,
628 match=r'Invalid encrypted session keys payload',
629 ):
630 storeroom._decrypt_session_keys( 1b
631 data,
632 _types.StoreroomMasterKeys(
633 hashing_key=key, encryption_key=key, signing_key=key
634 ),
635 )
637 @hypothesis.given(
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:
643 """Fail on bad MAC values."""
644 key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF' 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): 1q
650 storeroom._decrypt_master_keys_data( 1q
651 data,
652 _types.StoreroomKeyPair(encryption_key=key, signing_key=key),
653 )
654 with pytest.raises(cryptography.exceptions.InvalidSignature): 1q
655 storeroom._decrypt_session_keys( 1q
656 data,
657 _types.StoreroomMasterKeys(
658 hashing_key=key, encryption_key=key, signing_key=key
659 ),
660 )
663class TestVaultNativeConfig:
664 """Test the vault-native handler and handler machinery."""
666 @Parametrize.VAULT_NATIVE_PBKDF2_RESULT
667 def test_200_pbkdf2_manually(self, iterations: int, result: bytes) -> None:
668 """The PBKDF2 helper function works."""
669 assert ( 1u
670 vault_native.VaultNativeConfigParser._pbkdf2(
671 tests.VAULT_MASTER_KEY.encode('utf-8'), 32, iterations
672 )
673 == result
674 )
676 @Parametrize.VAULT_NATIVE_CONFIG_DATA
677 @Parametrize.VAULT_NATIVE_HANDLER
678 def test_201_export_vault_native_data_explicit_version(
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.
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.
694 """
695 runner = tests.CliRunner(mix_stderr=False) 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: 1h
700 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1h
701 stack.enter_context( 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): 1h
710 with pytest.raises(config_data): 1h
711 handler(None, format=format) 1h
712 else:
713 parsed_config = handler(None, format=format) 1h
714 assert parsed_config == config_data 1h
716 @Parametrize.PATH
717 @Parametrize.KEY_FORMATS
718 @Parametrize.VAULT_NATIVE_HANDLER
719 def test_202_export_data_path_and_keys_type(
720 self,
721 path: str | None,
722 key: str | Buffer | None,
723 handler: exporter.ExportVaultConfigDataFunction,
724 ) -> None:
725 """The handler supports different argument types.
727 The [`exporter.export_vault_config_data`][] dispatcher supports
728 them as well.
730 """
731 runner = tests.CliRunner(mix_stderr=False) 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: 1r
736 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1r
737 stack.enter_context( 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 ( 1r
746 handler(path, key, format='v0.3')
747 == tests.VAULT_V03_CONFIG_DATA
748 )
750 @Parametrize.VAULT_NATIVE_PARSER_CLASS_DATA
751 def test_300_result_caching(
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."""
759 def null_func(name: str) -> Callable[..., None]: 1c
760 def func(*_args: Any, **_kwargs: Any) -> None: # pragma: no cover 1c
761 msg = f'disallowed and stubbed out function {name} called'
762 raise AssertionError(msg)
764 return func 1c
766 runner = tests.CliRunner(mix_stderr=False) 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: 1c
771 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1c
772 stack.enter_context( 1c
773 tests.isolated_vault_exporter_config(
774 monkeypatch=monkeypatch,
775 runner=runner,
776 vault_config=config,
777 )
778 )
779 parser = parser_class( 1c
780 base64.b64decode(config), tests.VAULT_MASTER_KEY
781 )
782 assert parser() == config_data 1c
783 # Now stub out all functions used to calculate the above result.
784 monkeypatch.setattr( 1c
785 parser, '_parse_contents', null_func('_parse_contents')
786 )
787 monkeypatch.setattr( 1c
788 parser, '_derive_keys', null_func('_derive_keys')
789 )
790 monkeypatch.setattr( 1c
791 parser, '_check_signature', null_func('_check_signature')
792 )
793 monkeypatch.setattr( 1c
794 parser, '_decrypt_payload', null_func('_decrypt_payload')
795 )
796 assert parser() == config_data 1c
797 super_call = vault_native.VaultNativeConfigParser.__call__ 1c
798 assert super_call(parser) == config_data 1c
800 def test_400_no_password(self) -> None:
801 """Fail on empty master keys/master passphrases."""
802 with pytest.raises(ValueError, match='Password must not be empty'): 1t
803 vault_native.VaultNativeV03ConfigParser(b'', b'') 1t