e8f3ec854c425cc36565a40adbf00d22a2febeec
Marco Ricci Change the author e-mail ad...

Marco Ricci authored 2 months ago

1) # SPDX-FileCopyrightText: 2024 Marco Ricci <software@the13thletter.info>
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

2) #
3) # SPDX-License-Identifier: MIT
4) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

5) """Exporter for the vault "storeroom" configuration format.
6) 
7) The "storeroom" format is the experimental format used in alpha and beta
8) versions of vault beyond v0.3.0.  The configuration is stored as
9) a separate directory, which acts like a hash table (i.e. has named
10) slots) and provides an impure quasi-filesystem interface.  Each hash
11) table entry is separately encrypted and authenticated.  James Coglan
12) designed this format to avoid concurrent write issues when updating or
13) synchronizing the vault configuration with e.g. a cloud service.
14) 
15) The public interface is the
16) [`derivepassphrase.exporter.storeroom.export_storeroom_data`][]
17) function.  Multiple *non-public* functions are additionally documented
18) here for didactical and educational reasons, but they are not part of
19) the module API, are subject to change without notice (including
20) removal), and should *not* be used or relied on.
21) 
22) """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

23) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

24) from __future__ import annotations
25) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

26) import base64
27) import glob
28) import json
29) import logging
30) import os
31) import os.path
32) import struct
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

33) from typing import TYPE_CHECKING, Any, TypedDict
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

34) 
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

35) from derivepassphrase import exporter
36) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

37) if TYPE_CHECKING:
38)     from collections.abc import Iterator
39) 
Marco Ricci Add preliminary tests for t...

Marco Ricci authored 2 months ago

40)     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
41)     from cryptography.hazmat.primitives.ciphers import algorithms, modes
42)     from cryptography.hazmat.primitives.kdf import pbkdf2
43) else:
44)     try:
45)         from cryptography.hazmat.primitives import (
46)             ciphers,
47)             hashes,
48)             hmac,
49)             padding,
50)         )
51)         from cryptography.hazmat.primitives.ciphers import algorithms, modes
52)         from cryptography.hazmat.primitives.kdf import pbkdf2
53)     except ModuleNotFoundError as exc:
54) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

55)         class _DummyModule:  # pragma: no cover
Marco Ricci Add preliminary tests for t...

Marco Ricci authored 2 months ago

56)             def __init__(self, exc: type[Exception]) -> None:
57)                 self.exc = exc
58) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

59)             def __getattr__(self, name: str) -> Any:  # noqa: ANN401
60)                 def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ANN401,ARG001
Marco Ricci Add preliminary tests for t...

Marco Ricci authored 2 months ago

61)                     raise self.exc
62) 
63)                 return func
64) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

65)         ciphers = hashes = hmac = padding = _DummyModule(exc)
66)         algorithms = modes = pbkdf2 = _DummyModule(exc)
Marco Ricci Add preliminary tests for t...

Marco Ricci authored 2 months ago

67)         STUBBED = True
68)     else:
69)         STUBBED = False
70) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

71) STOREROOM_MASTER_KEYS_UUID = b'35b7c7ed-f71e-4adf-9051-02fb0f1e0e17'
72) VAULT_CIPHER_UUID = b'73e69e8a-cb05-4b50-9f42-59d76a511299'
73) IV_SIZE = 16
74) KEY_SIZE = MAC_SIZE = 32
75) ENCRYPTED_KEYPAIR_SIZE = 128
76) VERSION_SIZE = 1
77) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

78) __all__ = ('export_storeroom_data',)
79) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

80) logger = logging.getLogger(__name__)
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

81) 
82) 
83) class KeyPair(TypedDict):
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

84)     """A pair of AES256 keys, one for encryption and one for signing.
85) 
86)     Attributes:
87)         encryption_key:
88)             AES256 key, used for encryption with AES256-CBC (with PKCS#7
89)             padding).
90)         signing_key:
91)             AES256 key, used for signing with HMAC-SHA256.
92) 
93)     """
94) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

95)     encryption_key: bytes
96)     signing_key: bytes
97) 
98) 
99) class MasterKeys(TypedDict):
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

100)     """A triple of AES256 keys, for encryption, signing and hashing.
101) 
102)     Attributes:
103)         hashing_key:
104)             AES256 key, used for hashing with HMAC-SHA256 to derive
105)             a hash table slot for an item.
106)         encryption_key:
107)             AES256 key, used for encryption with AES256-CBC (with PKCS#7
108)             padding).
109)         signing_key:
110)             AES256 key, used for signing with HMAC-SHA256.
111) 
112)     """
113) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

114)     hashing_key: bytes
115)     encryption_key: bytes
116)     signing_key: bytes
117) 
118) 
119) def derive_master_keys_keys(password: str | bytes, iterations: int) -> KeyPair:
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

120)     """Derive encryption and signing keys for the master keys data.
121) 
122)     The master password is run through a key derivation function to
123)     obtain a 64-byte string, which is then split to yield two 32-byte
124)     keys.  The key derivation function is PBKDF2, using HMAC-SHA1 and
125)     salted with the storeroom master keys UUID.
126) 
127)     Args:
128)         password:
129)             A master password for the storeroom instance.  Usually read
130)             from the `VAULT_KEY` environment variable, otherwise
131)             defaults to the username.
132)         iterations:
133)             A count of rounds for the underlying key derivation
134)             function.  Usually stored as a setting next to the encrypted
135)             master keys data.
136) 
137)     Returns:
138)         A 2-tuple of keys, the encryption key and the signing key, to
139)         decrypt and verify the master keys data with.
140) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

141)     Warning:
142)         Non-public function, provided for didactical and educational
143)         purposes only.  Subject to change without notice, including
144)         removal.
145) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

146)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

147)     if isinstance(password, str):
148)         password = password.encode('ASCII')
149)     master_keys_keys_blob = pbkdf2.PBKDF2HMAC(
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

150)         algorithm=hashes.SHA1(),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

151)         length=2 * KEY_SIZE,
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

152)         salt=STOREROOM_MASTER_KEYS_UUID,
153)         iterations=iterations,
154)     ).derive(password)
155)     encryption_key, signing_key = struct.unpack(
156)         f'{KEY_SIZE}s {KEY_SIZE}s', master_keys_keys_blob
157)     )
158)     logger.debug(
159)         (
160)             'derived master_keys_keys bytes.fromhex(%s) (encryption) '
161)             'and bytes.fromhex(%s) (signing) '
162)             'from password bytes.fromhex(%s), '
163)             'using call '
164)             'pbkdf2(algorithm=%s, length=%d, salt=%s, iterations=%d)'
165)         ),
166)         repr(encryption_key.hex(' ')),
167)         repr(signing_key.hex(' ')),
168)         repr(password.hex(' ')),
169)         repr('SHA256'),
170)         64,
171)         repr(STOREROOM_MASTER_KEYS_UUID),
172)         iterations,
173)     )
174)     return {
175)         'encryption_key': encryption_key,
176)         'signing_key': signing_key,
177)     }
178) 
179) 
180) def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

181)     """Decrypt the master keys data.
182) 
183)     The master keys data contains:
184) 
185)     - a 16-byte IV,
186)     - a 96-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
187)       inside), and
188)     - a 32-byte MAC of the preceding 112 bytes.
189) 
190)     The decrypted payload itself consists of three 32-byte keys: the
191)     hashing, encryption and signing keys, in that order.
192) 
193)     The encrypted payload is encrypted with the encryption key, and the
194)     MAC is created based on the signing key.  As per standard
195)     cryptographic procedure, the MAC can be verified before attempting
196)     to decrypt the payload.
197) 
198)     Because the payload size is both fixed and a multiple of the
199)     cipher blocksize, in this case, the PKCS7 padding is a no-op.
200) 
201)     Args:
202)         data:
203)             The encrypted master keys data.
204)         keys:
205)             The encryption and signing keys for the master keys data.
206)             These should have previously been derived via the
207)             [`derivepassphrase.exporter.storeroom.derive_master_keys_keys`][]
208)             function.
209) 
210)     Returns:
211)         The master encryption, signing and hashing keys.
212) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

213)     Raises:
214)         cryptography.exceptions.InvalidSignature:
215)             The data does not contain a valid signature under the given
216)             key.
217)         ValueError:
218)             The format is invalid, in a non-cryptographic way.  (For
219)             example, it contains an unsupported version marker, or
220)             unexpected extra contents, or invalid padding.)
221) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

222)     Warning:
223)         Non-public function, provided for didactical and educational
224)         purposes only.  Subject to change without notice, including
225)         removal.
226) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

227)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

228)     ciphertext, claimed_mac = struct.unpack(
229)         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
230)     )
231)     actual_mac = hmac.HMAC(keys['signing_key'], hashes.SHA256())
232)     actual_mac.update(ciphertext)
233)     logger.debug(
234)         (
235)             'master_keys_data mac_key = bytes.fromhex(%s), '
236)             'hashed_content = bytes.fromhex(%s), '
237)             'claimed_mac = bytes.fromhex(%s), '
238)             'actual_mac = bytes.fromhex(%s)'
239)         ),
240)         repr(keys['signing_key'].hex(' ')),
241)         repr(ciphertext.hex(' ')),
242)         repr(claimed_mac.hex(' ')),
243)         repr(actual_mac.copy().finalize().hex(' ')),
244)     )
245)     actual_mac.verify(claimed_mac)
246) 
247)     iv, payload = struct.unpack(
248)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
249)     )
250)     decryptor = ciphers.Cipher(
251)         algorithms.AES256(keys['encryption_key']), modes.CBC(iv)
252)     ).decryptor()
253)     padded_plaintext = bytearray()
254)     padded_plaintext.extend(decryptor.update(payload))
255)     padded_plaintext.extend(decryptor.finalize())
256)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
257)     plaintext = bytearray()
258)     plaintext.extend(unpadder.update(padded_plaintext))
259)     plaintext.extend(unpadder.finalize())
260)     if len(plaintext) != 3 * KEY_SIZE:
261)         msg = (
262)             f'Expecting 3 encrypted keys at {3 * KEY_SIZE} bytes total, '
263)             f'but found {len(plaintext)} instead'
264)         )
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

265)         raise ValueError(msg)
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

266)     hashing_key, encryption_key, signing_key = struct.unpack(
267)         f'{KEY_SIZE}s {KEY_SIZE}s {KEY_SIZE}s', plaintext
268)     )
269)     return {
270)         'hashing_key': hashing_key,
271)         'encryption_key': encryption_key,
272)         'signing_key': signing_key,
273)     }
274) 
275) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

276) def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
277)     """Decrypt the bucket item's session keys.
278) 
279)     The bucket item's session keys are single-use keys for encrypting
280)     and signing a single item in the storage bucket.  The encrypted
281)     session key data consists of:
282) 
283)     - a 16-byte IV,
284)     - a 64-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
285)       inside), and
286)     - a 32-byte MAC of the preceding 80 bytes.
287) 
288)     The encrypted payload is encrypted with the master encryption key,
289)     and the MAC is created with the master signing key.  As per standard
290)     cryptographic procedure, the MAC can be verified before attempting
291)     to decrypt the payload.
292) 
293)     Because the payload size is both fixed and a multiple of the
294)     cipher blocksize, in this case, the PKCS7 padding is a no-op.
295) 
296)     Args:
297)         data:
298)             The encrypted bucket item session key data.
299)         master_keys:
300)             The master keys.  Presumably these have previously been
301)             obtained via the
302)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
303)             function.
304) 
305)     Returns:
306)         The bucket item's encryption and signing keys.
307) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

308)     Raises:
309)         cryptography.exceptions.InvalidSignature:
310)             The data does not contain a valid signature under the given
311)             key.
312)         ValueError:
313)             The format is invalid, in a non-cryptographic way.  (For
314)             example, it contains an unsupported version marker, or
315)             unexpected extra contents, or invalid padding.)
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

316) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

317)     Warning:
318)         Non-public function, provided for didactical and educational
319)         purposes only.  Subject to change without notice, including
320)         removal.
321) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

322)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

323)     ciphertext, claimed_mac = struct.unpack(
324)         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
325)     )
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

326)     actual_mac = hmac.HMAC(master_keys['signing_key'], hashes.SHA256())
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

327)     actual_mac.update(ciphertext)
328)     logger.debug(
329)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

330)             'decrypt_bucket_item (session_keys): '
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

331)             'mac_key = bytes.fromhex(%s) (master), '
332)             'hashed_content = bytes.fromhex(%s), '
333)             'claimed_mac = bytes.fromhex(%s), '
334)             'actual_mac = bytes.fromhex(%s)'
335)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

336)         repr(master_keys['signing_key'].hex(' ')),
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

337)         repr(ciphertext.hex(' ')),
338)         repr(claimed_mac.hex(' ')),
339)         repr(actual_mac.copy().finalize().hex(' ')),
340)     )
341)     actual_mac.verify(claimed_mac)
342) 
343)     iv, payload = struct.unpack(
344)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
345)     )
346)     decryptor = ciphers.Cipher(
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

347)         algorithms.AES256(master_keys['encryption_key']), modes.CBC(iv)
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

348)     ).decryptor()
349)     padded_plaintext = bytearray()
350)     padded_plaintext.extend(decryptor.update(payload))
351)     padded_plaintext.extend(decryptor.finalize())
352)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
353)     plaintext = bytearray()
354)     plaintext.extend(unpadder.update(padded_plaintext))
355)     plaintext.extend(unpadder.finalize())
356) 
357)     session_encryption_key, session_signing_key, inner_payload = struct.unpack(
358)         f'{KEY_SIZE}s {KEY_SIZE}s {len(plaintext) - 2 * KEY_SIZE}s',
359)         plaintext,
360)     )
361)     session_keys: KeyPair = {
362)         'encryption_key': session_encryption_key,
363)         'signing_key': session_signing_key,
364)     }
365) 
366)     logger.debug(
367)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

368)             'decrypt_bucket_item (session_keys): '
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

369)             'decrypt_aes256_cbc_and_unpad(key=bytes.fromhex(%s), '
370)             'iv=bytes.fromhex(%s))(bytes.fromhex(%s)) '
371)             '= bytes.fromhex(%s) '
372)             '= {"encryption_key": bytes.fromhex(%s), '
373)             '"signing_key": bytes.fromhex(%s)}'
374)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

375)         repr(master_keys['encryption_key'].hex(' ')),
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

376)         repr(iv.hex(' ')),
377)         repr(payload.hex(' ')),
378)         repr(plaintext.hex(' ')),
379)         repr(session_keys['encryption_key'].hex(' ')),
380)         repr(session_keys['signing_key'].hex(' ')),
381)     )
382) 
383)     if inner_payload:
384)         logger.debug(
385)             'ignoring misplaced inner payload bytes.fromhex(%s)',
386)             repr(inner_payload.hex(' ')),
387)         )
388) 
389)     return session_keys
390) 
391) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

392) def decrypt_contents(data: bytes, session_keys: KeyPair) -> bytes:
393)     """Decrypt the bucket item's contents.
394) 
395)     The data consists of:
396) 
397)     - a 16-byte IV,
398)     - a variable-sized AES256-CBC-encrypted payload (using PKCS7 padding
399)       on the inside), and
400)     - a 32-byte MAC of the preceding 80 bytes.
401) 
402)     The encrypted payload is encrypted with the bucket item's session
403)     encryption key, and the MAC is created with the bucket item's
404)     session signing key.  As per standard cryptographic procedure, the
405)     MAC can be verified before attempting to decrypt the payload.
406) 
407)     Args:
408)         data:
409)             The encrypted bucket item payload data.
410)         session_keys:
411)             The bucket item's session keys.  Presumably these have
412)             previously been obtained via the
413)             [`derivepassphrase.exporter.storeroom.decrypt_session_keys`][]
414)             function.
415) 
416)     Returns:
417)         The bucket item's payload.
418) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

419)     Raises:
420)         cryptography.exceptions.InvalidSignature:
421)             The data does not contain a valid signature under the given
422)             key.
423)         ValueError:
424)             The format is invalid, in a non-cryptographic way.  (For
425)             example, it contains an unsupported version marker, or
426)             unexpected extra contents, or invalid padding.)
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

427) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

428)     Warning:
429)         Non-public function, provided for didactical and educational
430)         purposes only.  Subject to change without notice, including
431)         removal.
432) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

433)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

434)     ciphertext, claimed_mac = struct.unpack(
435)         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
436)     )
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

437)     actual_mac = hmac.HMAC(session_keys['signing_key'], hashes.SHA256())
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

438)     actual_mac.update(ciphertext)
439)     logger.debug(
440)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

441)             'decrypt_bucket_item (contents): '
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

442)             'mac_key = bytes.fromhex(%s), '
443)             'hashed_content = bytes.fromhex(%s), '
444)             'claimed_mac = bytes.fromhex(%s), '
445)             'actual_mac = bytes.fromhex(%s)'
446)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

447)         repr(session_keys['signing_key'].hex(' ')),
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

448)         repr(ciphertext.hex(' ')),
449)         repr(claimed_mac.hex(' ')),
450)         repr(actual_mac.copy().finalize().hex(' ')),
451)     )
452)     actual_mac.verify(claimed_mac)
453) 
454)     iv, payload = struct.unpack(
455)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
456)     )
457)     decryptor = ciphers.Cipher(
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

458)         algorithms.AES256(session_keys['encryption_key']), modes.CBC(iv)
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

459)     ).decryptor()
460)     padded_plaintext = bytearray()
461)     padded_plaintext.extend(decryptor.update(payload))
462)     padded_plaintext.extend(decryptor.finalize())
463)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
464)     plaintext = bytearray()
465)     plaintext.extend(unpadder.update(padded_plaintext))
466)     plaintext.extend(unpadder.finalize())
467) 
468)     logger.debug(
469)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

470)             'decrypt_bucket_item (contents): '
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

471)             'decrypt_aes256_cbc_and_unpad(key=bytes.fromhex(%s), '
472)             'iv=bytes.fromhex(%s))(bytes.fromhex(%s)) '
473)             '= bytes.fromhex(%s)'
474)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

475)         repr(session_keys['encryption_key'].hex(' ')),
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

476)         repr(iv.hex(' ')),
477)         repr(payload.hex(' ')),
478)         repr(plaintext.hex(' ')),
479)     )
480) 
481)     return plaintext
482) 
483) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

484) def decrypt_bucket_item(bucket_item: bytes, master_keys: MasterKeys) -> bytes:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

485)     """Decrypt a bucket item.
486) 
487)     Args:
488)         bucket_item:
489)             The encrypted bucket item.
490)         master_keys:
491)             The master keys.  Presumably these have previously been
492)             obtained via the
493)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
494)             function.
495) 
496)     Returns:
497)         The decrypted bucket item.
498) 
499)     Raises:
500)         cryptography.exceptions.InvalidSignature:
501)             The data does not contain a valid signature under the given
502)             key.
503)         ValueError:
504)             The format is invalid, in a non-cryptographic way.  (For
505)             example, it contains an unsupported version marker, or
506)             unexpected extra contents, or invalid padding.)
507) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

508)     Warning:
509)         Non-public function, provided for didactical and educational
510)         purposes only.  Subject to change without notice, including
511)         removal.
512) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

513)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

514)     logger.debug(
515)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

516)             'decrypt_bucket_item: data = bytes.fromhex(%s), '
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

517)             'encryption_key = bytes.fromhex(%s), '
518)             'signing_key = bytes.fromhex(%s)'
519)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

520)         repr(bucket_item.hex(' ')),
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

521)         repr(master_keys['encryption_key'].hex(' ')),
522)         repr(master_keys['signing_key'].hex(' ')),
523)     )
524)     data_version, encrypted_session_keys, data_contents = struct.unpack(
525)         (
526)             f'B {ENCRYPTED_KEYPAIR_SIZE}s '
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

527)             f'{len(bucket_item) - 1 - ENCRYPTED_KEYPAIR_SIZE}s'
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

528)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 2 months ago

529)         bucket_item,
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

530)     )
531)     if data_version != 1:
532)         msg = f'Cannot handle version {data_version} encrypted data'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

533)         raise ValueError(msg)
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

534)     session_keys = decrypt_session_keys(encrypted_session_keys, master_keys)
535)     return decrypt_contents(data_contents, session_keys)
536) 
537) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

538) def decrypt_bucket_file(
Marco Ricci Support exports from outsid...

Marco Ricci authored 2 months ago

539)     filename: str,
540)     master_keys: MasterKeys,
541)     *,
542)     root_dir: str | bytes | os.PathLike = '.',
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

543) ) -> Iterator[bytes]:
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

544)     """Decrypt a complete bucket.
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

545) 
546)     Args:
547)         filename:
548)             The bucket file's filename.
549)         master_keys:
550)             The master keys.  Presumably these have previously been
551)             obtained via the
552)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
553)             function.
554)         root_dir:
555)             The root directory of the data store.  The filename is
556)             interpreted relatively to this directory.
557) 
558)     Yields:
559)         :
560)             A decrypted bucket item.
561) 
562)     Raises:
563)         cryptography.exceptions.InvalidSignature:
564)             The data does not contain a valid signature under the given
565)             key.
566)         ValueError:
567)             The format is invalid, in a non-cryptographic way.  (For
568)             example, it contains an unsupported version marker, or
569)             unexpected extra contents, or invalid padding.)
570) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

571)     Warning:
572)         Non-public function, provided for didactical and educational
573)         purposes only.  Subject to change without notice, including
574)         removal.
575) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

576)     """
Marco Ricci Support exports from outsid...

Marco Ricci authored 2 months ago

577)     with open(
578)         os.path.join(os.fsdecode(root_dir), filename), 'rb'
579)     ) as bucket_file:
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

580)         header_line = bucket_file.readline()
581)         try:
582)             header = json.loads(header_line)
583)         except ValueError as exc:
584)             msg = f'Invalid bucket file: {filename}'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

585)             raise ValueError(msg) from exc
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

586)         if header != {'version': 1}:
587)             msg = f'Invalid bucket file: {filename}'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

588)             raise ValueError(msg) from None
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

589)         for line in bucket_file:
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

590)             yield decrypt_bucket_item(
591)                 base64.standard_b64decode(line), master_keys
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

592)             )
593) 
594) 
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

595) def _store(config: dict[str, Any], path: str, json_contents: bytes) -> None:
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

596)     """Store the JSON contents at path in the config structure.
597) 
598)     Traverse the config structure according to path, and set the value
599)     of the leaf to the decoded JSON contents.
600) 
601)     A path `/foo/bar/xyz` translates to the JSON structure
602)     `{"foo": {"bar": {"xyz": ...}}}`.
603) 
604)     Args:
605)         config:
606)             The (top-level) configuration structure to update.
607)         path:
608)             The path within the configuration structure to traverse.
609)         json_contents:
610)             The contents to set the item to, after JSON-decoding.
611) 
612)     Raises:
613)         json.JSONDecodeError:
614)             There was an error parsing the JSON contents.
615) 
616)     """
617)     contents = json.loads(json_contents)
618)     path_parts = [part for part in path.split('/') if part]
619)     for part in path_parts[:-1]:
620)         config = config.setdefault(part, {})
621)     if path_parts:
622)         config[path_parts[-1]] = contents
623) 
624) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 months ago

625) def export_storeroom_data(  # noqa: C901,PLR0912,PLR0914,PLR0915
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

626)     storeroom_path: str | bytes | os.PathLike | None = None,
627)     master_keys_key: str | bytes | None = None,
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

628) ) -> dict[str, Any]:
629)     """Export the full configuration stored in the storeroom.
630) 
631)     Args:
632)         storeroom_path:
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

633)             Path to the storeroom; usually `~/.vault`.  If not given,
634)             then query [`derivepassphrase.exporter.get_vault_path`][]
635)             for the value.
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

636)         master_keys_key:
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

637)             Encryption key/password for the master keys, usually the
638)             username, or passed via the `VAULT_KEY` environment
639)             variable.  If not given, then query
640)             [`derivepassphrase.exporter.get_vault_key`][] for the value.
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

641) 
642)     Returns:
643)         The full configuration, as stored in the storeroom.
644) 
645)         This may or may not be a valid configuration according to vault
646)         or derivepassphrase.
647) 
648)     Raises:
649)         RuntimeError:
650)             Something went wrong during data collection, e.g. we
651)             encountered unsupported or corrupted data in the storeroom.
652)         json.JSONDecodeError:
653)             An internal JSON data structure failed to parse from disk.
654)             The storeroom is probably corrupted.
655) 
656)     """
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

657)     if storeroom_path is None:
658)         storeroom_path = exporter.get_vault_path()
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

659)     if master_keys_key is None:
Marco Ricci Move vault key and path det...

Marco Ricci authored 2 months ago

660)         master_keys_key = exporter.get_vault_key()
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

661)     with open(
662)         os.path.join(os.fsdecode(storeroom_path), '.keys'), encoding='utf-8'
663)     ) as master_keys_file:
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

664)         header = json.loads(master_keys_file.readline())
665)         if header != {'version': 1}:
666)             msg = 'bad or unsupported keys version header'
667)             raise RuntimeError(msg)
668)         raw_keys_data = base64.standard_b64decode(master_keys_file.readline())
669)         encrypted_keys_params, encrypted_keys = struct.unpack(
670)             f'B {len(raw_keys_data) - 1}s', raw_keys_data
671)         )
672)         if master_keys_file.read():
673)             msg = 'trailing data; cannot make sense of .keys file'
674)             raise RuntimeError(msg)
675)     encrypted_keys_version = encrypted_keys_params >> 4
676)     if encrypted_keys_version != 1:
677)         msg = f'cannot handle version {encrypted_keys_version} encrypted keys'
678)         raise RuntimeError(msg)
679)     encrypted_keys_iterations = 2 ** (10 + (encrypted_keys_params & 0x0F))
680)     master_keys_keys = derive_master_keys_keys(
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

681)         master_keys_key, encrypted_keys_iterations
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

682)     )
683)     master_keys = decrypt_master_keys_data(encrypted_keys, master_keys_keys)
684) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

685)     config_structure: dict[str, Any] = {}
686)     json_contents: dict[str, bytes] = {}
Marco Ricci Support exports from outsid...

Marco Ricci authored 2 months ago

687)     for file in glob.glob(
688)         '[01][0-9a-f]', root_dir=os.fsdecode(storeroom_path)
689)     ):
690)         bucket_contents = list(
691)             decrypt_bucket_file(file, master_keys, root_dir=storeroom_path)
692)         )
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

693)         bucket_index = json.loads(bucket_contents.pop(0))
694)         for pos, item in enumerate(bucket_index):
695)             json_contents[item] = bucket_contents[pos]
696)             logger.debug(
697)                 'Found bucket item: %s -> %s', item, bucket_contents[pos]
698)             )
699)     dirs_to_check: dict[str, list[str]] = {}
700)     json_payload: Any
701)     for path, json_content in sorted(json_contents.items()):
702)         if path.endswith('/'):
703)             logger.debug(
704)                 'Postponing dir check: %s -> %s',
705)                 path,
706)                 json_content.decode('utf-8'),
707)             )
708)             json_payload = json.loads(json_content)
709)             if not isinstance(json_payload, list) or any(
710)                 not isinstance(x, str) for x in json_payload
711)             ):
712)                 msg = (
713)                     f'Directory index is not actually an index: '
714)                     f'{json_content!r}'
715)                 )
716)                 raise RuntimeError(msg)
717)             dirs_to_check[path] = json_payload
718)             logger.debug(
719)                 'Setting contents (empty directory): %s -> %s', path, '{}'
720)             )
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

721)             _store(config_structure, path, b'{}')
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

722)         else:
723)             logger.debug(
724)                 'Setting contents: %s -> %s',
725)                 path,
726)                 json_content.decode('utf-8'),
727)             )
Marco Ricci Add vault_native exporter f...

Marco Ricci authored 2 months ago

728)             _store(config_structure, path, json_content)
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 2 months ago

729)     for _dir, namelist in dirs_to_check.items():
730)         namelist = [x.rstrip('/') for x in namelist]  # noqa: PLW2901
731)         try:
732)             obj = config_structure
733)             for part in _dir.split('/'):
734)                 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
739)         if set(obj.keys()) != set(namelist):
740)             msg = f'Object key mismatch for path {_dir!r}'
741)             raise RuntimeError(msg)
742)     return config_structure
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 3 months ago

743) 
744) 
745) if __name__ == '__main__':