Add remaining tests to the storeroom exporter for 100% coverage
Marco Ricci

Marco Ricci commited on 2024-10-10 12:18:23
Zeige 3 geänderte Dateien mit 275 Einfügungen und 39 Löschungen.


Before this commit, certain consistency checks within the storeroom
exporter that seemed difficult to test remained untested: a payload size
check in the master keys decryption routine, another payload size check
in the session keys decryption routine, and object connectivity and type
correctness checks in the top-level exporter routine.

The master and session keys decryption routines, it turns out, don't
need this explicit size check: the `struct` library, used for decoding
the payload even further, already checks this automatically.  (What *is*
needed is a wrapper to convert the exception type, in general, for the
whole decryption block.)

For the connectivity and type correctness checks in the top-level
exporter routine, I generated another couple of broken storeroom
configurations (e.g. where directory contents, encoded as a JSON array,
contain non-string elements).  We now test for each of these
configurations if they correctly fail to parse.

Finally, it turns out that many of the docstrings reported the
ciphertext sizes incorrectly, because they wrongly neglected the
padding in their calculations.  Fix this, of course.
... ...
@@ -182,14 +182,14 @@ def derive_master_keys_keys(password: str | bytes, iterations: int) -> KeyPair:
182 182
 
183 183
 
184 184
 def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
185
-    """Decrypt the master keys data.
185
+    r"""Decrypt the master keys data.
186 186
 
187 187
     The master keys data contains:
188 188
 
189 189
     - a 16-byte IV,
190
-    - a 96-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
191
-      inside), and
192
-    - a 32-byte MAC of the preceding 112 bytes.
190
+    - a 96-byte AES256-CBC-encrypted payload, plus 16 further bytes of
191
+      PKCS7 padding, and
192
+    - a 32-byte MAC of the preceding 128 bytes.
193 193
 
194 194
     The decrypted payload itself consists of three 32-byte keys: the
195 195
     hashing, encryption and signing keys, in that order.
... ...
@@ -199,8 +199,8 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
199 199
     cryptographic procedure, the MAC can be verified before attempting
200 200
     to decrypt the payload.
201 201
 
202
-    Because the payload size is both fixed and a multiple of the
203
-    cipher blocksize, in this case, the PKCS7 padding is a no-op.
202
+    Because the payload size is both fixed and a multiple of the cipher
203
+    blocksize, in this case, the PKCS7 padding always is `b'\x10' * 16`.
204 204
 
205 205
     Args:
206 206
         data:
... ...
@@ -247,6 +247,7 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
247 247
     )
248 248
     actual_mac.verify(claimed_mac)
249 249
 
250
+    try:
250 251
         iv, payload = struct.unpack(
251 252
             f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
252 253
         )
... ...
@@ -260,15 +261,12 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
260 261
         plaintext = bytearray()
261 262
         plaintext.extend(unpadder.update(padded_plaintext))
262 263
         plaintext.extend(unpadder.finalize())
263
-    if len(plaintext) != 3 * KEY_SIZE:
264
-        msg = (
265
-            f'Expecting 3 encrypted keys at {3 * KEY_SIZE} bytes total, '
266
-            f'but found {len(plaintext)} instead'
267
-        )
268
-        raise ValueError(msg)
269 264
         hashing_key, encryption_key, signing_key = struct.unpack(
270 265
             f'{KEY_SIZE}s {KEY_SIZE}s {KEY_SIZE}s', plaintext
271 266
         )
267
+    except (ValueError, struct.error) as exc:
268
+        msg = 'Invalid encrypted master keys payload'
269
+        raise ValueError(msg) from exc
272 270
     return {
273 271
         'hashing_key': hashing_key,
274 272
         'encryption_key': encryption_key,
... ...
@@ -277,24 +275,24 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
277 275
 
278 276
 
279 277
 def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
280
-    """Decrypt the bucket item's session keys.
278
+    r"""Decrypt the bucket item's session keys.
281 279
 
282 280
     The bucket item's session keys are single-use keys for encrypting
283 281
     and signing a single item in the storage bucket.  The encrypted
284 282
     session key data consists of:
285 283
 
286 284
     - a 16-byte IV,
287
-    - a 64-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
288
-      inside), and
289
-    - a 32-byte MAC of the preceding 80 bytes.
285
+    - a 64-byte AES256-CBC-encrypted payload, plus 16 further bytes of
286
+      PKCS7 padding, and
287
+    - a 32-byte MAC of the preceding 96 bytes.
290 288
 
291 289
     The encrypted payload is encrypted with the master encryption key,
292 290
     and the MAC is created with the master signing key.  As per standard
293 291
     cryptographic procedure, the MAC can be verified before attempting
294 292
     to decrypt the payload.
295 293
 
296
-    Because the payload size is both fixed and a multiple of the
297
-    cipher blocksize, in this case, the PKCS7 padding is a no-op.
294
+    Because the payload size is both fixed and a multiple of the cipher
295
+    blocksize, in this case, the PKCS7 padding always is `b'\x10' * 16`.
298 296
 
299 297
     Args:
300 298
         data:
... ...
@@ -341,6 +339,7 @@ def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
341 339
     )
342 340
     actual_mac.verify(claimed_mac)
343 341
 
342
+    try:
344 343
         iv, payload = struct.unpack(
345 344
             f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
346 345
         )
... ...
@@ -354,11 +353,13 @@ def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
354 353
         plaintext = bytearray()
355 354
         plaintext.extend(unpadder.update(padded_plaintext))
356 355
         plaintext.extend(unpadder.finalize())
357
-
358
-    session_encryption_key, session_signing_key, inner_payload = struct.unpack(
359
-        f'{KEY_SIZE}s {KEY_SIZE}s {len(plaintext) - 2 * KEY_SIZE}s',
360
-        plaintext,
356
+        session_encryption_key, session_signing_key = struct.unpack(
357
+            f'{KEY_SIZE}s {KEY_SIZE}s', plaintext
361 358
         )
359
+    except (ValueError, struct.error) as exc:
360
+        msg = 'Invalid encrypted session keys payload'
361
+        raise ValueError(msg) from exc
362
+
362 363
     session_keys: KeyPair = {
363 364
         'encryption_key': session_encryption_key,
364 365
         'signing_key': session_signing_key,
... ...
@@ -381,12 +382,6 @@ def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
381 382
         repr(session_keys['signing_key'].hex(' ')),
382 383
     )
383 384
 
384
-    if inner_payload:
385
-        logger.debug(
386
-            'ignoring misplaced inner payload bytes.fromhex(%s)',
387
-            repr(inner_payload.hex(' ')),
388
-        )
389
-
390 385
     return session_keys
391 386
 
392 387
 
... ...
@@ -398,7 +393,7 @@ def decrypt_contents(data: bytes, session_keys: KeyPair) -> bytes:
398 393
     - a 16-byte IV,
399 394
     - a variable-sized AES256-CBC-encrypted payload (using PKCS7 padding
400 395
       on the inside), and
401
-    - a 32-byte MAC of the preceding 80 bytes.
396
+    - a 32-byte MAC of the preceding bytes.
402 397
 
403 398
     The encrypted payload is encrypted with the bucket item's session
404 399
     encryption key, and the MAC is created with the bucket item's
... ...
@@ -726,16 +721,24 @@ def export_storeroom_data(  # noqa: C901,PLR0912,PLR0914,PLR0915
726 721
                 json_content.decode('utf-8'),
727 722
             )
728 723
             _store(config_structure, path, json_content)
729
-    for _dir, namelist in dirs_to_check.items():
724
+    # Sorted order is important; see `mabye_obj` below.
725
+    for _dir, namelist in sorted(dirs_to_check.items()):
730 726
         namelist = [x.rstrip('/') for x in namelist]  # noqa: PLW2901
731
-        try:
732
-            obj = config_structure
727
+        obj: dict[Any, Any] = config_structure
733 728
         for part in _dir.split('/'):
734 729
             if part:
735
-                    obj = obj[part]
736
-        except KeyError as exc:
737
-            msg = f'Cannot traverse storage path: {_dir!r}'
738
-            raise RuntimeError(msg) from exc
730
+                # Because we iterate paths in sorted order, parent
731
+                # directories are encountered before child directories.
732
+                # So parent directories always exist (lest we would have
733
+                # aborted earlier).
734
+                #
735
+                # Of course, the type checker doesn't necessarily know
736
+                # this, so we need to use assertions anyway.
737
+                maybe_obj = obj.get(part)
738
+                assert isinstance(
739
+                    maybe_obj, dict
740
+                ), f'Cannot traverse storage path {_dir!r}'
741
+                obj = maybe_obj
739 742
         if set(obj.keys()) != set(namelist):
740 743
             msg = f'Object key mismatch for path {_dir!r}'
741 744
             raise RuntimeError(msg)
... ...
@@ -1026,7 +1026,7 @@ _VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED_JAVASCRIPT_SOURCE = """
1026 1026
 // Executed in the top-level directory of the vault project code, in Node.js.
1027 1027
 const storeroom = require('storeroom')
1028 1028
 const Store = require('./lib/store.js')
1029
-let store = new Store(storeroom.createFileAdapter('./broken-dir', 'vault key'))
1029
+let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1030 1030
 await store._storeroom.put('/services/array/', ['entry1','entry2'])
1031 1031
 // The resulting "broken-dir" was then zipped manually.
1032 1032
 """
... ...
@@ -1067,6 +1067,157 @@ AgAAAAAAAAABAAAApIHtAgAAMWFQSwECHgMUAAIACAB4ox9ZGgj3mrkBAAAXAgAAAgAAAAAAAAAB
1067 1067
 AAAApIHIBAAAMWVQSwUGAAAAAAQABADDAAAAoQYAAAAA
1068 1068
 """
1069 1069
 
1070
+_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2_JAVASCRIPT_SOURCE = """
1071
+// Executed in the top-level directory of the vault project code, in Node.js.
1072
+const storeroom = require('storeroom')
1073
+const Store = require('./lib/store.js')
1074
+let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1075
+await store._storeroom.put('/services/array/', 'not a directory index')
1076
+// The resulting "broken-dir" was then zipped manually.
1077
+"""
1078
+VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2 = b"""
1079
+UEsDBAoAAAAAAM6NSVmrcHdV5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV3ZS9LZkJp
1080
+L0V0OUcrZmxYM3gxaFU4ZjE4YlE3S253bHoxN0IxSDE3cUhVOGdWK2RpWWY5MTdFZ0YrSStidEpZ
1081
+VXBzWVZVck45OC9uLzdsZnl2NUdGVEg2NWZxVy93YjlOc2MxeEZ4ck43Q3p4eTZ5MVAxZzFPb2VK
1082
+b0RZU3J6YXlwT0E2M3pidmk0ZTRiREMyNXhPTXl5NHBoMDFGeGdnQmpSNnpUcmR2UDk2UlZQd0I5
1083
+WitOZkZWZUlXT1NQN254ZFNYMGdFbkZ4SDBmWDkzNTFaTTZnPVBLAwQKAAAAAADOjUlZJg3/BhcC
1084
+AAAXAgAAAgAAADBieyJ2ZXJzaW9uIjoxfQpBVXJJMjNDQ2VpcW14cUZRMlV4SUpBaUoxNEtyUzh2
1085
+SXpIa2xROURBaFRlVHNFMmxPVUg4WUhTcUk1cXRGSHBqY3c1WkRkZmRtUlEwQXVGRjllY3lkam14
1086
+dDdUemRYLzNmNFUvTGlVV2dLRmQ1K1FEN3BlVlE1bWpqeHNlUEpHTDlhTWlKaGxSUVB4SmtUbjBx
1087
+U2poM1RUT0ZZbVAzV0JkdlUyWnF2RzhaSDk2cU1WcnZsQ0dMRmZTc2svVXlvcHZKdENONUVXcTRZ
1088
+SDUwNFNiejFIUVhWd2RjejlrS1BuR3J6SVA4ZmZtZnhXQ0U0TmtLb0ZPQXZuNkZvS3FZdGlGbFE9
1089
+PQpBVXBMUVMrMG9VeEZTeCtxbTB3SUtyM1MvTVJxYWJJTFlEUnY0aHlBMVE2TGR2Nlk0UmJ0enVz
1090
+NzRBc0cxbVhhenlRU2hlZVowdk0xM2ZyTFA4YlV0VHBaRyszNXF1eUhLM2NaWVJRZUxKM0JzejZz
1091
+b0xaQjNZTkpNenFxTTQrdzM1U0FZZ2lMU1NkN05NeWVrTHNhRUIzRDFOajlTRk85K3NGNEpFMWVL
1092
+UXpNMkltNk9qOUNVQjZUSTV3UitibksxN1BnY2RaeTZUMVRMWElVREVxcDg4dWdsWmRFTVcrNU9k
1093
+aE5ZbXEzZERWVWV4UnJpM1AwUmVBSi9KMGdJNkNoUUE9PVBLAwQKAAAAAADOjUlZTNfdphcCAAAX
1094
+AgAAAgAAADBmeyJ2ZXJzaW9uIjoxfQpBWVJqOVpIUktGUEVKOHM2YVY2TkRoTk5jQlZ5cGVYUmdz
1095
+cnBldFQ0cGhJRGROWFdGYzRia0daYkJxMngwRDFkcVNjYWk5UzEveDZ2K28zRE0rVEF2OVE3ZFVR
1096
+QWVKR3RmRkhJZDZxWW0ybEdNSnF5WTRNWm14aE9YdXliend0V3Q4Mnhvb041QTZNcWpINmxKQllD
1097
+UUN3ZEJjb3RER0EwRnlnVTEzeHV2WnIzT1puZnFFRGRqbzMxNkw5aExDN1RxMTYwUHpBOXJOSDMz
1098
+ZkNBcUhIVXZiYlFQQWErekw1d3dEN3FlWkY2MHdJaEwvRmk5L3JhNGJDcHZRNC9ORWpRd3c9PQpB
1099
+WWNGUDB1Y2xMMHh3ZDM2UXZXbm4wWXFsOU5WV0s3c05CMTdjdmM3N3VDZ0J2OE9XYkR5UHk5d05h
1100
+R2NQQzdzcVdZdHpZRlBHR0taVjhVUzA1YTVsV1BabDNGVFNuQXNtekxPelBlcFZxaitleDU3aEsx
1101
+QnV1bHkrUCtYQkE0YUtsaDM3c0RJL3I0UE1BVlJuMDNoSDJ5dEhDMW9PbjF0V1M5Q1NLV1pSMThh
1102
+djdTT0RBMVBNRnFYTmZKZVNTaVJiQ2htbDdOcFVLbjlXSGJZandybDlqN0JSdy9kWjhNQldCb3Ns
1103
+Nlc1dGZtdnJMVHhGRFBXYUgzSUp0T0czMEI1M3c9PVBLAwQKAAAAAADOjUlZn9rNID8CAAA/AgAA
1104
+AgAAADFkeyJ2ZXJzaW9uIjoxfQpBYWFBb3lqaGljVDZ4eXh1c0U0RVlDZCtxbE81Z0dEYTBNSFVS
1105
+MmgrSW9QMHV4UkY3b1BRS2czOHlQUEN3Ny9MYVJLQ0dQZ0RyZ2RpTWJTeUwzZ3ZNMFhseVpVMVBW
1106
+QVJvNEFETU9lbXgrOWhtS0hjQWNKMG5EeW5oSkhGYTYyb2xyQUNxekZzblhKNVBSeEVTVzVEbUh0
1107
+Ui9nRm5Wa1FvalhyVW4ybmpYMjVVanZQaXhlMU96Y0daMmQ0MjdVTGdnY1hqMkhSdjJiZldDNDUw
1108
+SGFXS3FDckZlYWlrQ2xkUUM2WGV3SkxZUjdvQUY3UjVha2ttK3M2MXNCRTVCaTg0QmJLWHluc1NG
1109
+ejE0TXFrd2JMK1VMYVk9CkFUT3dqTUFpa3Q4My9NTW5KRXQ2b3EyNFN4KzJKNDc2K2gyTmEzbHUr
1110
+MDg0cjlBT25aaUk0TmlYV0N1Q0lzakEzcTBwUHFJS1VXZHlPQW9uM2VHY0huZUppWUtVYllBaUJI
1111
+MVNmbnhQQkMzZkFMRklybkQ4Y0VqeGpPcUFUaTQ5dE1mRmtib0dNQ3dEdFY0V3NJL0tLUlRCOFd1
1112
+MnNXK2J0V3QzVWlvZG9ZeUVLTDk3ekNNemZqdGptejF4SDhHTXY5WDVnaG9NSW5RQVNvYlRreVZ4
1113
+dWo5YnlDazdNbU0vK21ZL3AwZE9oYVY0Nncwcm04UGlvWEtzdzR4bXB3ditDWC9PRXV3Uy9meDJT
1114
+Y0lOQnNuYVRiWT1QSwECHgMKAAAAAADOjUlZq3B3VeYAAADmAAAABQAAAAAAAAAAAAAApIEAAAAA
1115
+LmtleXNQSwECHgMKAAAAAADOjUlZJg3/BhcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGJQSwEC
1116
+HgMKAAAAAADOjUlZTNfdphcCAAAXAgAAAgAAAAAAAAAAAAAApIFAAwAAMGZQSwECHgMKAAAAAADO
1117
+jUlZn9rNID8CAAA/AgAAAgAAAAAAAAAAAAAApIF3BQAAMWRQSwUGAAAAAAQABADDAAAA1gcAAAAA
1118
+"""
1119
+
1120
+_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3_JAVASCRIPT_SOURCE = """
1121
+// Executed in the top-level directory of the vault project code, in Node.js.
1122
+const storeroom = require('storeroom')
1123
+const Store = require('./lib/store.js')
1124
+let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1125
+await store._storeroom.put('/services/array/', [null, 1, true, [], {}])
1126
+// The resulting "broken-dir" was then zipped manually.
1127
+"""
1128
+VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3 = b"""
1129
+UEsDBAoAAAAAAEOPSVnVlcff5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV4dVBHUDBi
1130
+YkxrUVdvWnV5ZUJQRy8xdmM2MCt6MThOa3BsS09ydFAvUTVnQmxkYVpIOG10dTE5VWZFNGdGRGRj
1131
+eHJtWUd4eXZDZFNqcVlOaDh4cTlzM3VydkdRTWFwcnhtdlZGZUxoSW4zZnVlTDAweEk0ZmlLenZN
1132
+MmthUlRsNWNORGh3eUNlWVk4dzhBcXNhYjNyVWVsOEE0eVQ0cHU2d2tmQ3dTWUdqeG5HR29EcWJK
1133
+VnVJVWNpZVBEcU9PTzU2b0MyMG9lT01adFVkTUtxV28zYnFZPVBLAwQKAAAAAABDj0lZ77OVHxcC
1134
+AAAXAgAAAgAAADBjeyJ2ZXJzaW9uIjoxfQpBZllFQVVobEkyU2lZeGlrdWh0RzRNbUN3L1V2THBN
1135
+VVhwVlB0NlRwdzRyNGdocVJhbGZWZ0hxUHFtbTczSnltdFFrNnZnR2JRdUpiQmVlYjYwOHNrMGk4
1136
+ZFJVZjNwdlc2SnUyejljQkdwOG5mTFpTdlNad1lLN09UK2gzSDNDcmoxbXNicEZUcHVldW81NXc1
1137
+dGdYMnBuWXNWTVcrczdjaHEyMUIya2lIVEZrdGt1MXlaRzhPYkVUQjNCOFNGODVVbi9CUjFEMHJ1
1138
+ME9zOWl4ZWM2VmNTMitTZndtNnNtSlk2ZW9ZNTJzOGJNRGdYMndjQ0srREdkOEo2VWp0NG5OQVE9
1139
+PQpBUWlPRnRZcmJybWUycEwxRFpGT1BjU0RHOUN2cVkvbHhTWGIwaVJUdmtIWFc2bEtHL0p4RUtU
1140
+d3RTc0RTeDhsMTUvaHRmbWpOQ2tuTzhLVEFoKzhRQm5FbjZ0a2x5Y3BmeEIrTUxLRjFCM1Q1bjcv
1141
+T0VUMExMdmgxU2k1bnRRNXhTUHZZNWtXeUMyZjhXUXFZb3FSNU5JVENMeDV6dWNsQ3dGb2kvVXc4
1142
+OWNNWjM1MHBSbThzUktJbjJFeDUrQ1JwS3ZHdnBHbFJaTmk5VHZmVkNic1FCalR3MC9aeklTdzVQ
1143
+NW9BVWE2U1ExUVFnNHg4VUNkY0s2QUNLaFluY0d4TVE9PVBLAwQKAAAAAABDj0lZGk9LVj8CAAA/
1144
+AgAAAgAAADE0eyJ2ZXJzaW9uIjoxfQpBY1g2NVpMUWk4ck9pUlIyWGEwQlFHQVhQVWF2aHNJVGVY
1145
+c2dzRk9OUmFTRzJCQlg0SGxJRHpwRUd5aDUrZ2czZVRwWDFNOERua3pMeTVzcWRkMFpmK3padTgz
1146
+Qm52Y1JPREVIVDllUW91YUtPTWltdlRYanNuSXAxUHo5VGY1TlRkRjNJVTd2V1lhUDg4WTI5NG1i
1147
+c1VVL2RKVTZqZ3ZDbUw2cE1VZ28xUU12bGJnaVp3cDV1RDFQZXlrSXdKVWdJSEgxTEpnYi9xU2tW
1148
+c25leW1XY1RXR0NobzRvZGx3S2hJWmFCelhvNFhlN2U1V2I2VHA3Rkk5VUpVcmZIRTAvcVdrZUZE
1149
+VmxlazY3cUx3ZFZXcU9DdFk9CkFhSGR0QjhydmQ0U3N4ZmJ5eU1OOHIzZEoxeHA5NmFIRTQvalNi
1150
+Z05hZWttaDkyb2ROM1F4MUlqYXZsYVkxeEt1eFF3KzlwTHFIcTF5a1JSRjQzL2RVWGFIRk5UU0NX
1151
+OVFsdmd3KzMwa1ZhSEdXRllvbFRnRWE4djQ3b3VrbGlmc01PZGM0YVNKb2R4ZUFJcVc3Q1cwdDVR
1152
+b2RUbWREUXpqc3phZkQ4R2VOd2NFQjdGMHI2RzNoZEJlQndxd3Z6eENVYnpSUmU5bEQ3NjQ3RFp1
1153
+bEo1U3c4amlvV0paTW40NlZhV3BYUXk4UnNva3hHaW00WUpybUZIQ2JkVU9qSWJsUmQ1Z3VhUDNU
1154
+M0NxeHRPdC94b1BhOD1QSwMECgAAAAAAQ49JWVJM8QYXAgAAFwIAAAIAAAAxNnsidmVyc2lvbiI6
1155
+MX0KQVlCWDF6M21qUlQrand4M2FyNkFpemxnalJZbUM0ZHg5NkxVQVBTVHNMWXJKVHFtWnd5N0Jy
1156
+OFlCcElVamorMHdlT3lNaUtLVnFwaER3RXExNWFqUmlSZUVEQURTVHZwWmlLZUlnZjR5elUzZXNP
1157
+eDJ2U2J1bXhTK0swUGZVa2tsSy9TRmRiU3EvUHFMRjBDRTVCMXNyKzJLYTB2WlJmak94R3VFeFRD
1158
+RXozN0ZlWDNNR3NCNkhZVHEzaUJWcUR6NVB6eHpCWWM5Kyt6RitLS1RnMVp2NGRtRmVQTC9JSEY5
1159
+WnV6TWlqRXdCRkE3WnJ0dkRqd3ZYcWtsMVpsR0c4eUV3PT0KQVhUWkRLVnNleldpR1RMUVZqa2hX
1160
+bXBnK05MYlM0M2MxZEpvK2xGcC9yWUJYZkw3Wll5cGdjWE5IWXNzd01nc2VSSTAzNmt6bGZkdGNa
1161
+bTdiUUN6M2JuQmZ6ZlorZFFuT2Y5STVSU2l0QzB2UmsydkQrOFdwbmRPSzNucGY5S0VpWklOSzVq
1162
+TEZGTTJDTkNmQzBabXNRUlF3T0k2N3l5ZHhjVnFDMXBnWHV6QXRXamlsSUpnN0p6eUtsY3BJUGJu
1163
+SUc0UzRSUlhIdW1wZnpoeWFZWkd6T0FDamRSYTZIMWJxYkJkZXFaSHMvQXJvM25mVjdlbjhxSUE5
1164
+aVUrbnNweXFnPT1QSwECHgMKAAAAAABDj0lZ1ZXH3+YAAADmAAAABQAAAAAAAAAAAAAApIEAAAAA
1165
+LmtleXNQSwECHgMKAAAAAABDj0lZ77OVHxcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGNQSwEC
1166
+HgMKAAAAAABDj0lZGk9LVj8CAAA/AgAAAgAAAAAAAAAAAAAApIFAAwAAMTRQSwECHgMKAAAAAABD
1167
+j0lZUkzxBhcCAAAXAgAAAgAAAAAAAAAAAAAApIGfBQAAMTZQSwUGAAAAAAQABADDAAAA1gcAAAAA
1168
+"""
1169
+
1170
+_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4_JAVASCRIPT_SOURCE = """
1171
+// Executed in the top-level directory of the vault project code, in Node.js.
1172
+const storeroom = require('storeroom')
1173
+const Store = require('./lib/store.js')
1174
+let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
1175
+await store._storeroom.put('/dir/subdir/', [])
1176
+await store._storeroom.put('/dir/', [])
1177
+// The resulting "broken-dir" was then zipped manually.
1178
+"""
1179
+VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4 = b"""
1180
+UEsDBAoAAAAAAE+5SVloORS+5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV6dWRoNkRQ
1181
+YTlNSWFabHZ5TytVYTFuamhjV2hIaTFBU0lKYW5zcXBxVlA0blN2V0twUzdZOUc2bjFSbi8vUnVM
1182
+VitwcHp5SC9RQk83R0hFenNVMzdCUzFwUmVVeGhxUVlVTE56OXZvQ0crM1ZaL3VncU44dDJiU05m
1183
+Nyt5K3hiNng2aVlFUmNZYTJ0UkhzZVdIc0laTE9ha2lDb0lRVGV3cndwYjVMM2pnd0E3SXBzaDkz
1184
+QkxHSzM5dXNYNmo0R0I2WkRUeW5JcGk4V3JkbDhnWVZCN0tVPVBLAwQKAAAAAABPuUlZ663uUhcC
1185
+AAAXAgAAAgAAADAzeyJ2ZXJzaW9uIjoxfQpBV2wzS2gzd21ZSFVZZU1RR3BLSVowdVd1VXFna09h
1186
+YmRjNzNYYXVsZTNtVS9sN2Zvd1AyS21jbFp3ZDM5V3lYVzRTcEw4R0l4YStDZW51S3V0Wm5nb0FR
1187
+bWlnaUJUbkFaais5TENCcGNIWlZNY2RBVkgxKzBFNGpsanZ1UkVwZ0tPS05LZjRsTUl1QnZ4VmFB
1188
+ZkdwNHJYNEZ4MmpPSlk1Y3NQZzBBRFBoZVAwN29GWVQ3alorSUNEK1AxNGZPdWpwMGRUeDRrTDIy
1189
+LzlqalRDNXBCNVF5NW5iOUx3Zk5DUWViSUVpaTZpbU0vRmFrK1dtV05tMndqMERSTEc4RHY3ZkE9
1190
+PQpBU0c3NTNGTVVwWmxjK3E1YXRzcC93OUNqN2JPOFlpY24wZHg2UGloTmwzUS9WSjVVeGJmU3l0
1191
+ZDFDNDBRU2xXeTJqOTJDWUd3VER6eEdBMXVnb0FCYi9kTllTelVwbHJFb3BuUVphYXdsdTVwV2x0
1192
+Y1E5WTcveWN4S2E4b0JaaGY3RkFYcGo2c01wUW9zNzI5VFVabFd4UmI4VFRtN2FrVnR1OXcvYXlK
1193
+RS9reDh4ZUYxSGJlc3Q4N1IxTGg2ODd3dS9XVUN2ZjNXYXo1VjNnZWY0RnpUTXg0bkpqSlZOd0U0
1194
+SzAxUTlaVzQ0bmVvbExPUVI1MkZDeDZvbml3RW9tenc9PVBLAwQKAAAAAABPuUlZRXky4CsCAAAr
1195
+AgAAAgAAADEweyJ2ZXJzaW9uIjoxfQpBWmlYWVlvNUdCY2d5dkFRaGtyK2ZjUkdVSkdabDd2dE5w
1196
+T2Mrd1VzbXJhQWhRN3dKdlYraGhKcTlrcWNKQnBWU0gyUTBTTVVhb29iNjBJM1NYNUNtTkJRU2FH
1197
+M3prd0Y0T2F4TnpCZUh0NFlpaDd4Y3p2ak4xR0hISDJQYW0xam05K09ja3JLVmNMVURtNXRKb2ZC
1198
+Z1E4Q2NwMGZMVkdEaURjNWF0MjVMc2piQVcvNkZFSnJ5VVBHWis4UVdYRmlWMGdtVVZybVc3VUFy
1199
+dGhJQitWNTdZS1BORi95Nng2OU43UTFQbmp1cUczdlpybzljMEJ3d012NWoyc3BMMTJHcTdzTDZE
1200
+alB1d0dHbnB2MkVZQTFLbmc9CkFTdjQwUkgzRmxzbGVlU1NjRlZNRmh3dEx6eEYxK2xpcmxEL29X
1201
+alJLQ05qVWZhUVpJTWpqMWRoVkhOakNUTWhWZ1ZONkl3b04xTnFOMEV6cmdhaTFBWnNiMm9UczYw
1202
+QkI1UGh0U0hhQ2U2WllUeE1JemFPS2FIK0w2eHhtaXIrTlQxNTRXS0x5amJMams3MU1na3Nwa0Yy
1203
+WDBJMnlaWW5IUUM0bmdEL24yZzRtSVI2Q1hWL0JOUXNzeTBEeXdGLzN6eGRRYWw5cFBtVk1qYnFu
1204
+cHY5SFNqRTg4S25naVpBWFhJWU1OVGF2L3Q3Y3dEWGdNekhKTlU0Y2xnVUtIQVZ3QT09UEsDBAoA
1205
+AAAAAE+5SVkPfKx9FwIAABcCAAACAAAAMWR7InZlcnNpb24iOjF9CkFYbHNLRzQwZG5ibTJvcXdY
1206
+U2ZrSWp3Mmxpa0lDS3hVOXU3TU52VkZ1NEJ2R1FVVitSVVdsS3MxL25TSlBtM2U2OTRvVHdoeDFo
1207
+RFF3U0M5U0QvbXd5bnpjSTloUnRCUWVXMkVMOVU5L1ZGcHFsVWY3Z1ZOMHZ0ZWpXYnV4QnhsZlRD
1208
+Tys4SFBwU2Zaa2VOUld5R2JNdzBFSU9LTmxRYjk3OUF0c1g3THR0NytaTkJnakZHYkZxaHdwa3kx
1209
+WUNDVng1UmNZZ2tma2ZjWnVncGpzc1RzNVFvK1p3QXBEcDZ4V3JjSHMxUDhvNktBRzAwcjZZbkNM
1210
+N2ErU1dwZmVNTUJhZz09CkFadVF0cFZMWmVvb292NkdyQlpnb3B6VmRGUXBlK1h6QXZuZ2dPVnZM
1211
+VWtCYVF2akl5K1VLdXVUVlFoQ1JiMVp6dGZQL2dsNnoxOEsyZW5sQlo2bGJTZnoxTlBWeUVzYXB3
1212
+dDVpUVh4azd5UkJlZks1cFlsNTduUXlmcFZQbzlreFpnOVdHTkV3NVJ5MkExemhnNGl6TWxLRmJh
1213
+UjZFZ0FjQ3NFOXAveGRLa29ZNjhOUlZmNXJDM3lMQjc3ZWgyS1hCUld2WDNZcE9XdW00OGtsbmtI
1214
+akJjMFpiQmUrT3NZb3d5cXpoRFA2ZGQxRlFnMlFjK09vc3B4V0sycld4M01HZz09UEsBAh4DCgAA
1215
+AAAAT7lJWWg5FL7mAAAA5gAAAAUAAAAAAAAAAAAAAKSBAAAAAC5rZXlzUEsBAh4DCgAAAAAAT7lJ
1216
+Weut7lIXAgAAFwIAAAIAAAAAAAAAAAAAAKSBCQEAADAzUEsBAh4DCgAAAAAAT7lJWUV5MuArAgAA
1217
+KwIAAAIAAAAAAAAAAAAAAKSBQAMAADEwUEsBAh4DCgAAAAAAT7lJWQ98rH0XAgAAFwIAAAIAAAAA
1218
+AAAAAAAAAKSBiwUAADFkUEsFBgAAAAAEAAQAwwAAAMIHAAAAAA==
1219
+"""
1220
+
1070 1221
 CANNOT_LOAD_CRYPTOGRAPHY = (
1071 1222
     'Cannot load the required Python module "cryptography".'
1072 1223
 )
... ...
@@ -17,6 +17,17 @@ from derivepassphrase.exporter import storeroom, vault_native
17 17
 
18 18
 cryptography = pytest.importorskip('cryptography', minversion='38.0')
19 19
 
20
+from cryptography.hazmat.primitives import (  # noqa: E402
21
+    ciphers,
22
+    hashes,
23
+    hmac,
24
+    padding,
25
+)
26
+from cryptography.hazmat.primitives.ciphers import (  # noqa: E402
27
+    algorithms,
28
+    modes,
29
+)
30
+
20 31
 if TYPE_CHECKING:
21 32
     from collections.abc import Callable
22 33
     from typing import Any
... ...
@@ -284,22 +295,93 @@ class TestStoreroom:
284 295
             with pytest.raises(RuntimeError, match=err_msg):
285 296
                 storeroom.export_storeroom_data()
286 297
 
298
+    @pytest.mark.parametrize(
299
+        ['zipped_config', 'error_text'],
300
+        [
301
+            pytest.param(
302
+                tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED,
303
+                'Object key mismatch',
304
+                id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED',
305
+            ),
306
+            pytest.param(
307
+                tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2,
308
+                'Directory index is not actually an index',
309
+                id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2',
310
+            ),
311
+            pytest.param(
312
+                tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3,
313
+                'Directory index is not actually an index',
314
+                id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3',
315
+            ),
316
+            pytest.param(
317
+                tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4,
318
+                'Object key mismatch',
319
+                id='VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4',
320
+            ),
321
+        ],
322
+    )
287 323
     def test_403_export_storeroom_data_bad_directory_listing(
288 324
         self,
289 325
         monkeypatch: pytest.MonkeyPatch,
326
+        zipped_config: bytes,
327
+        error_text: str,
290 328
     ) -> None:
291 329
         runner = click.testing.CliRunner(mix_stderr=False)
292 330
         with (
293 331
             tests.isolated_vault_exporter_config(
294 332
                 monkeypatch=monkeypatch,
295 333
                 runner=runner,
296
-                vault_config=tests.VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED,
334
+                vault_config=zipped_config,
297 335
                 vault_key=tests.VAULT_MASTER_KEY,
298 336
             ),
299
-            pytest.raises(RuntimeError, match='Object key mismatch'),
337
+            pytest.raises(RuntimeError, match=error_text),
300 338
         ):
301 339
             storeroom.export_storeroom_data()
302 340
 
341
+    def test_404_decrypt_keys_wrong_data_length(self) -> None:
342
+        payload = (
343
+            b"Any text here, as long as it isn't "
344
+            b'exactly 64 or 96 bytes long.'
345
+        )
346
+        assert len(payload) not in frozenset({
347
+            2 * storeroom.KEY_SIZE,
348
+            3 * storeroom.KEY_SIZE,
349
+        })
350
+        key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF'
351
+        padder = padding.PKCS7(storeroom.IV_SIZE * 8).padder()
352
+        plaintext = bytearray(padder.update(payload))
353
+        plaintext.extend(padder.finalize())
354
+        iv = b'deadbeefDEADBEEF'
355
+        assert len(iv) == storeroom.IV_SIZE
356
+        encryptor = ciphers.Cipher(
357
+            algorithms.AES256(key), modes.CBC(iv)
358
+        ).encryptor()
359
+        ciphertext = bytearray(encryptor.update(plaintext))
360
+        ciphertext.extend(encryptor.finalize())
361
+        mac_obj = hmac.HMAC(key, hashes.SHA256())
362
+        mac_obj.update(iv)
363
+        mac_obj.update(ciphertext)
364
+        data = iv + bytes(ciphertext) + mac_obj.finalize()
365
+        with pytest.raises(
366
+            ValueError,
367
+            match=r'Invalid encrypted master keys payload',
368
+        ):
369
+            storeroom.decrypt_master_keys_data(
370
+                data, {'encryption_key': key, 'signing_key': key}
371
+            )
372
+        with pytest.raises(
373
+            ValueError,
374
+            match=r'Invalid encrypted session keys payload',
375
+        ):
376
+            storeroom.decrypt_session_keys(
377
+                data,
378
+                {
379
+                    'hashing_key': key,
380
+                    'encryption_key': key,
381
+                    'signing_key': key,
382
+                },
383
+            )
384
+
303 385
 
304 386
 class TestVaultNativeConfig:
305 387
     @pytest.mark.parametrize(
306 388