2d292af3e81527750e46a2167d30efe840ac58ca
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

1) # SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2) #
3) # SPDX-License-Identifier: MIT
4) 
5) """Exporter for the vault "storeroom" configuration format."""
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

7) from __future__ import annotations
8) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

9) import base64
10) import glob
11) import json
12) import logging
13) import os
14) import os.path
15) import struct
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

18) from derivepassphrase import exporter
19) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

20) if TYPE_CHECKING:
21)     from collections.abc import Iterator
22) 
Marco Ricci Add preliminary tests for t...

Marco Ricci authored 3 weeks ago

23)     from cryptography.hazmat.primitives import ciphers, hashes, hmac, padding
24)     from cryptography.hazmat.primitives.ciphers import algorithms, modes
25)     from cryptography.hazmat.primitives.kdf import pbkdf2
26) else:
27)     try:
28)         from cryptography.hazmat.primitives import (
29)             ciphers,
30)             hashes,
31)             hmac,
32)             padding,
33)         )
34)         from cryptography.hazmat.primitives.ciphers import algorithms, modes
35)         from cryptography.hazmat.primitives.kdf import pbkdf2
36)     except ModuleNotFoundError as exc:
37) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 3 weeks ago

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

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 3 weeks ago

44)                     raise self.exc
45) 
46)                 return func
47) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 3 weeks ago

50)         STUBBED = True
51)     else:
52)         STUBBED = False
53) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

54) STOREROOM_MASTER_KEYS_UUID = b'35b7c7ed-f71e-4adf-9051-02fb0f1e0e17'
55) VAULT_CIPHER_UUID = b'73e69e8a-cb05-4b50-9f42-59d76a511299'
56) IV_SIZE = 16
57) KEY_SIZE = MAC_SIZE = 32
58) ENCRYPTED_KEYPAIR_SIZE = 128
59) VERSION_SIZE = 1
60) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

62) 
63) 
64) class KeyPair(TypedDict):
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

65)     """A pair of AES256 keys, one for encryption and one for signing.
66) 
67)     Attributes:
68)         encryption_key:
69)             AES256 key, used for encryption with AES256-CBC (with PKCS#7
70)             padding).
71)         signing_key:
72)             AES256 key, used for signing with HMAC-SHA256.
73) 
74)     """
75) 
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

76)     encryption_key: bytes
77)     signing_key: bytes
78) 
79) 
80) class MasterKeys(TypedDict):
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

81)     """A triple of AES256 keys, for encryption, signing and hashing.
82) 
83)     Attributes:
84)         hashing_key:
85)             AES256 key, used for hashing with HMAC-SHA256 to derive
86)             a hash table slot for an item.
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 1 month ago

95)     hashing_key: bytes
96)     encryption_key: bytes
97)     signing_key: bytes
98) 
99) 
100) def derive_master_keys_keys(password: str | bytes, iterations: int) -> KeyPair:
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

101)     """Derive encryption and signing keys for the master keys data.
102) 
103)     The master password is run through a key derivation function to
104)     obtain a 64-byte string, which is then split to yield two 32-byte
105)     keys.  The key derivation function is PBKDF2, using HMAC-SHA1 and
106)     salted with the storeroom master keys UUID.
107) 
108)     Args:
109)         password:
110)             A master password for the storeroom instance.  Usually read
111)             from the `VAULT_KEY` environment variable, otherwise
112)             defaults to the username.
113)         iterations:
114)             A count of rounds for the underlying key derivation
115)             function.  Usually stored as a setting next to the encrypted
116)             master keys data.
117) 
118)     Returns:
119)         A 2-tuple of keys, the encryption key and the signing key, to
120)         decrypt and verify the master keys data with.
121) 
122)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

128)         salt=STOREROOM_MASTER_KEYS_UUID,
129)         iterations=iterations,
130)     ).derive(password)
131)     encryption_key, signing_key = struct.unpack(
132)         f'{KEY_SIZE}s {KEY_SIZE}s', master_keys_keys_blob
133)     )
134)     logger.debug(
135)         (
136)             'derived master_keys_keys bytes.fromhex(%s) (encryption) '
137)             'and bytes.fromhex(%s) (signing) '
138)             'from password bytes.fromhex(%s), '
139)             'using call '
140)             'pbkdf2(algorithm=%s, length=%d, salt=%s, iterations=%d)'
141)         ),
142)         repr(encryption_key.hex(' ')),
143)         repr(signing_key.hex(' ')),
144)         repr(password.hex(' ')),
145)         repr('SHA256'),
146)         64,
147)         repr(STOREROOM_MASTER_KEYS_UUID),
148)         iterations,
149)     )
150)     return {
151)         'encryption_key': encryption_key,
152)         'signing_key': signing_key,
153)     }
154) 
155) 
156) def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

157)     """Decrypt the master keys data.
158) 
159)     The master keys data contains:
160) 
161)     - a 16-byte IV,
162)     - a 96-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
163)       inside), and
164)     - a 32-byte MAC of the preceding 112 bytes.
165) 
166)     The decrypted payload itself consists of three 32-byte keys: the
167)     hashing, encryption and signing keys, in that order.
168) 
169)     The encrypted payload is encrypted with the encryption key, and the
170)     MAC is created based on the signing key.  As per standard
171)     cryptographic procedure, the MAC can be verified before attempting
172)     to decrypt the payload.
173) 
174)     Because the payload size is both fixed and a multiple of the
175)     cipher blocksize, in this case, the PKCS7 padding is a no-op.
176) 
177)     Args:
178)         data:
179)             The encrypted master keys data.
180)         keys:
181)             The encryption and signing keys for the master keys data.
182)             These should have previously been derived via the
183)             [`derivepassphrase.exporter.storeroom.derive_master_keys_keys`][]
184)             function.
185) 
186)     Returns:
187)         The master encryption, signing and hashing keys.
188) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

189)     Raises:
190)         cryptography.exceptions.InvalidSignature:
191)             The data does not contain a valid signature under the given
192)             key.
193)         ValueError:
194)             The format is invalid, in a non-cryptographic way.  (For
195)             example, it contains an unsupported version marker, or
196)             unexpected extra contents, or invalid padding.)
197) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

199)     ciphertext, claimed_mac = struct.unpack(
200)         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
201)     )
202)     actual_mac = hmac.HMAC(keys['signing_key'], hashes.SHA256())
203)     actual_mac.update(ciphertext)
204)     logger.debug(
205)         (
206)             'master_keys_data mac_key = bytes.fromhex(%s), '
207)             'hashed_content = bytes.fromhex(%s), '
208)             'claimed_mac = bytes.fromhex(%s), '
209)             'actual_mac = bytes.fromhex(%s)'
210)         ),
211)         repr(keys['signing_key'].hex(' ')),
212)         repr(ciphertext.hex(' ')),
213)         repr(claimed_mac.hex(' ')),
214)         repr(actual_mac.copy().finalize().hex(' ')),
215)     )
216)     actual_mac.verify(claimed_mac)
217) 
218)     iv, payload = struct.unpack(
219)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
220)     )
221)     decryptor = ciphers.Cipher(
222)         algorithms.AES256(keys['encryption_key']), modes.CBC(iv)
223)     ).decryptor()
224)     padded_plaintext = bytearray()
225)     padded_plaintext.extend(decryptor.update(payload))
226)     padded_plaintext.extend(decryptor.finalize())
227)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
228)     plaintext = bytearray()
229)     plaintext.extend(unpadder.update(padded_plaintext))
230)     plaintext.extend(unpadder.finalize())
231)     if len(plaintext) != 3 * KEY_SIZE:
232)         msg = (
233)             f'Expecting 3 encrypted keys at {3 * KEY_SIZE} bytes total, '
234)             f'but found {len(plaintext)} instead'
235)         )
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

237)     hashing_key, encryption_key, signing_key = struct.unpack(
238)         f'{KEY_SIZE}s {KEY_SIZE}s {KEY_SIZE}s', plaintext
239)     )
240)     return {
241)         'hashing_key': hashing_key,
242)         'encryption_key': encryption_key,
243)         'signing_key': signing_key,
244)     }
245) 
246) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

247) def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
248)     """Decrypt the bucket item's session keys.
249) 
250)     The bucket item's session keys are single-use keys for encrypting
251)     and signing a single item in the storage bucket.  The encrypted
252)     session key data consists of:
253) 
254)     - a 16-byte IV,
255)     - a 64-byte AES256-CBC-encrypted payload (using PKCS7 padding on the
256)       inside), and
257)     - a 32-byte MAC of the preceding 80 bytes.
258) 
259)     The encrypted payload is encrypted with the master encryption key,
260)     and the MAC is created with the master signing key.  As per standard
261)     cryptographic procedure, the MAC can be verified before attempting
262)     to decrypt the payload.
263) 
264)     Because the payload size is both fixed and a multiple of the
265)     cipher blocksize, in this case, the PKCS7 padding is a no-op.
266) 
267)     Args:
268)         data:
269)             The encrypted bucket item session key data.
270)         master_keys:
271)             The master keys.  Presumably these have previously been
272)             obtained via the
273)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
274)             function.
275) 
276)     Returns:
277)         The bucket item's encryption and signing keys.
278) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

279)     Raises:
280)         cryptography.exceptions.InvalidSignature:
281)             The data does not contain a valid signature under the given
282)             key.
283)         ValueError:
284)             The format is invalid, in a non-cryptographic way.  (For
285)             example, it contains an unsupported version marker, or
286)             unexpected extra contents, or invalid padding.)
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

287) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

293)     actual_mac.update(ciphertext)
294)     logger.debug(
295)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

297)             'mac_key = bytes.fromhex(%s) (master), '
298)             'hashed_content = bytes.fromhex(%s), '
299)             'claimed_mac = bytes.fromhex(%s), '
300)             'actual_mac = bytes.fromhex(%s)'
301)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

303)         repr(ciphertext.hex(' ')),
304)         repr(claimed_mac.hex(' ')),
305)         repr(actual_mac.copy().finalize().hex(' ')),
306)     )
307)     actual_mac.verify(claimed_mac)
308) 
309)     iv, payload = struct.unpack(
310)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
311)     )
312)     decryptor = ciphers.Cipher(
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

314)     ).decryptor()
315)     padded_plaintext = bytearray()
316)     padded_plaintext.extend(decryptor.update(payload))
317)     padded_plaintext.extend(decryptor.finalize())
318)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
319)     plaintext = bytearray()
320)     plaintext.extend(unpadder.update(padded_plaintext))
321)     plaintext.extend(unpadder.finalize())
322) 
323)     session_encryption_key, session_signing_key, inner_payload = struct.unpack(
324)         f'{KEY_SIZE}s {KEY_SIZE}s {len(plaintext) - 2 * KEY_SIZE}s',
325)         plaintext,
326)     )
327)     session_keys: KeyPair = {
328)         'encryption_key': session_encryption_key,
329)         'signing_key': session_signing_key,
330)     }
331) 
332)     logger.debug(
333)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

335)             'decrypt_aes256_cbc_and_unpad(key=bytes.fromhex(%s), '
336)             'iv=bytes.fromhex(%s))(bytes.fromhex(%s)) '
337)             '= bytes.fromhex(%s) '
338)             '= {"encryption_key": bytes.fromhex(%s), '
339)             '"signing_key": bytes.fromhex(%s)}'
340)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

342)         repr(iv.hex(' ')),
343)         repr(payload.hex(' ')),
344)         repr(plaintext.hex(' ')),
345)         repr(session_keys['encryption_key'].hex(' ')),
346)         repr(session_keys['signing_key'].hex(' ')),
347)     )
348) 
349)     if inner_payload:
350)         logger.debug(
351)             'ignoring misplaced inner payload bytes.fromhex(%s)',
352)             repr(inner_payload.hex(' ')),
353)         )
354) 
355)     return session_keys
356) 
357) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

358) def decrypt_contents(data: bytes, session_keys: KeyPair) -> bytes:
359)     """Decrypt the bucket item's contents.
360) 
361)     The data consists of:
362) 
363)     - a 16-byte IV,
364)     - a variable-sized AES256-CBC-encrypted payload (using PKCS7 padding
365)       on the inside), and
366)     - a 32-byte MAC of the preceding 80 bytes.
367) 
368)     The encrypted payload is encrypted with the bucket item's session
369)     encryption key, and the MAC is created with the bucket item's
370)     session signing key.  As per standard cryptographic procedure, the
371)     MAC can be verified before attempting to decrypt the payload.
372) 
373)     Args:
374)         data:
375)             The encrypted bucket item payload data.
376)         session_keys:
377)             The bucket item's session keys.  Presumably these have
378)             previously been obtained via the
379)             [`derivepassphrase.exporter.storeroom.decrypt_session_keys`][]
380)             function.
381) 
382)     Returns:
383)         The bucket item's payload.
384) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

385)     Raises:
386)         cryptography.exceptions.InvalidSignature:
387)             The data does not contain a valid signature under the given
388)             key.
389)         ValueError:
390)             The format is invalid, in a non-cryptographic way.  (For
391)             example, it contains an unsupported version marker, or
392)             unexpected extra contents, or invalid padding.)
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

393) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

399)     actual_mac.update(ciphertext)
400)     logger.debug(
401)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

403)             'mac_key = bytes.fromhex(%s), '
404)             'hashed_content = bytes.fromhex(%s), '
405)             'claimed_mac = bytes.fromhex(%s), '
406)             'actual_mac = bytes.fromhex(%s)'
407)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

409)         repr(ciphertext.hex(' ')),
410)         repr(claimed_mac.hex(' ')),
411)         repr(actual_mac.copy().finalize().hex(' ')),
412)     )
413)     actual_mac.verify(claimed_mac)
414) 
415)     iv, payload = struct.unpack(
416)         f'{IV_SIZE}s {len(ciphertext) - IV_SIZE}s', ciphertext
417)     )
418)     decryptor = ciphers.Cipher(
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

420)     ).decryptor()
421)     padded_plaintext = bytearray()
422)     padded_plaintext.extend(decryptor.update(payload))
423)     padded_plaintext.extend(decryptor.finalize())
424)     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
425)     plaintext = bytearray()
426)     plaintext.extend(unpadder.update(padded_plaintext))
427)     plaintext.extend(unpadder.finalize())
428) 
429)     logger.debug(
430)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

432)             'decrypt_aes256_cbc_and_unpad(key=bytes.fromhex(%s), '
433)             'iv=bytes.fromhex(%s))(bytes.fromhex(%s)) '
434)             '= bytes.fromhex(%s)'
435)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

437)         repr(iv.hex(' ')),
438)         repr(payload.hex(' ')),
439)         repr(plaintext.hex(' ')),
440)     )
441) 
442)     return plaintext
443) 
444) 
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 2 weeks ago

446)     """Decrypt a bucket item.
447) 
448)     Args:
449)         bucket_item:
450)             The encrypted bucket item.
451)         master_keys:
452)             The master keys.  Presumably these have previously been
453)             obtained via the
454)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
455)             function.
456) 
457)     Returns:
458)         The decrypted bucket item.
459) 
460)     Raises:
461)         cryptography.exceptions.InvalidSignature:
462)             The data does not contain a valid signature under the given
463)             key.
464)         ValueError:
465)             The format is invalid, in a non-cryptographic way.  (For
466)             example, it contains an unsupported version marker, or
467)             unexpected extra contents, or invalid padding.)
468) 
469)     """
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

470)     logger.debug(
471)         (
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

473)             'encryption_key = bytes.fromhex(%s), '
474)             'signing_key = bytes.fromhex(%s)'
475)         ),
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

477)         repr(master_keys['encryption_key'].hex(' ')),
478)         repr(master_keys['signing_key'].hex(' ')),
479)     )
480)     data_version, encrypted_session_keys, data_contents = struct.unpack(
481)         (
482)             f'B {ENCRYPTED_KEYPAIR_SIZE}s '
Marco Ricci Add docstrings and better v...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

490)     session_keys = decrypt_session_keys(encrypted_session_keys, master_keys)
491)     return decrypt_contents(data_contents, session_keys)
492) 
493) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

495)     filename: str,
496)     master_keys: MasterKeys,
497)     *,
498)     root_dir: str | bytes | os.PathLike = '.',
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

499) ) -> Iterator[bytes]:
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

500)     """Decrypt a bucket item.
501) 
502)     Args:
503)         filename:
504)             The bucket file's filename.
505)         master_keys:
506)             The master keys.  Presumably these have previously been
507)             obtained via the
508)             [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
509)             function.
510)         root_dir:
511)             The root directory of the data store.  The filename is
512)             interpreted relatively to this directory.
513) 
514)     Yields:
515)         :
516)             A decrypted bucket item.
517) 
518)     Raises:
519)         cryptography.exceptions.InvalidSignature:
520)             The data does not contain a valid signature under the given
521)             key.
522)         ValueError:
523)             The format is invalid, in a non-cryptographic way.  (For
524)             example, it contains an unsupported version marker, or
525)             unexpected extra contents, or invalid padding.)
526) 
527)     """
Marco Ricci Support exports from outsid...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

531)         header_line = bucket_file.readline()
532)         try:
533)             header = json.loads(header_line)
534)         except ValueError as exc:
535)             msg = f'Invalid bucket file: {filename}'
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

543)             )
544) 
545) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

546) def store(config: dict[str, Any], path: str, json_contents: bytes) -> None:
547)     """Store the JSON contents at path in the config structure.
548) 
549)     Traverse the config structure according to path, and set the value
550)     of the leaf to the decoded JSON contents.
551) 
552)     A path `/foo/bar/xyz` translates to the JSON structure
553)     `{"foo": {"bar": {"xyz": ...}}}`.
554) 
555)     Args:
556)         config:
557)             The (top-level) configuration structure to update.
558)         path:
559)             The path within the configuration structure to traverse.
560)         json_contents:
561)             The contents to set the item to, after JSON-decoding.
562) 
563)     Raises:
564)         json.JSONDecodeError:
565)             There was an error parsing the JSON contents.
566) 
567)     """
568)     contents = json.loads(json_contents)
569)     path_parts = [part for part in path.split('/') if part]
570)     for part in path_parts[:-1]:
571)         config = config.setdefault(part, {})
572)     if path_parts:
573)         config[path_parts[-1]] = contents
574) 
575) 
Marco Ricci Apply new ruff ruleset to c...

Marco Ricci authored 2 weeks ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

579) ) -> dict[str, Any]:
580)     """Export the full configuration stored in the storeroom.
581) 
582)     Args:
583)         storeroom_path:
Marco Ricci Move vault key and path det...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

592) 
593)     Returns:
594)         The full configuration, as stored in the storeroom.
595) 
596)         This may or may not be a valid configuration according to vault
597)         or derivepassphrase.
598) 
599)     Raises:
600)         RuntimeError:
601)             Something went wrong during data collection, e.g. we
602)             encountered unsupported or corrupted data in the storeroom.
603)         json.JSONDecodeError:
604)             An internal JSON data structure failed to parse from disk.
605)             The storeroom is probably corrupted.
606) 
607)     """
Marco Ricci Move vault key and path det...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

615)         header = json.loads(master_keys_file.readline())
616)         if header != {'version': 1}:
617)             msg = 'bad or unsupported keys version header'
618)             raise RuntimeError(msg)
619)         raw_keys_data = base64.standard_b64decode(master_keys_file.readline())
620)         encrypted_keys_params, encrypted_keys = struct.unpack(
621)             f'B {len(raw_keys_data) - 1}s', raw_keys_data
622)         )
623)         if master_keys_file.read():
624)             msg = 'trailing data; cannot make sense of .keys file'
625)             raise RuntimeError(msg)
626)     encrypted_keys_version = encrypted_keys_params >> 4
627)     if encrypted_keys_version != 1:
628)         msg = f'cannot handle version {encrypted_keys_version} encrypted keys'
629)         raise RuntimeError(msg)
630)     encrypted_keys_iterations = 2 ** (10 + (encrypted_keys_params & 0x0F))
631)     master_keys_keys = derive_master_keys_keys(
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

633)     )
634)     master_keys = decrypt_master_keys_data(encrypted_keys, master_keys_keys)
635) 
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

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

Marco Ricci authored 1 month ago

638)     for file in glob.glob(
639)         '[01][0-9a-f]', root_dir=os.fsdecode(storeroom_path)
640)     ):
641)         bucket_contents = list(
642)             decrypt_bucket_file(file, master_keys, root_dir=storeroom_path)
643)         )
Marco Ricci Add an actual storeroom exp...

Marco Ricci authored 1 month ago

644)         bucket_index = json.loads(bucket_contents.pop(0))
645)         for pos, item in enumerate(bucket_index):
646)             json_contents[item] = bucket_contents[pos]
647)             logger.debug(
648)                 'Found bucket item: %s -> %s', item, bucket_contents[pos]
649)             )
650)     dirs_to_check: dict[str, list[str]] = {}
651)     json_payload: Any
652)     for path, json_content in sorted(json_contents.items()):
653)         if path.endswith('/'):
654)             logger.debug(
655)                 'Postponing dir check: %s -> %s',
656)                 path,
657)                 json_content.decode('utf-8'),
658)             )
659)             json_payload = json.loads(json_content)
660)             if not isinstance(json_payload, list) or any(
661)                 not isinstance(x, str) for x in json_payload
662)             ):
663)                 msg = (
664)                     f'Directory index is not actually an index: '
665)                     f'{json_content!r}'
666)                 )
667)                 raise RuntimeError(msg)
668)             dirs_to_check[path] = json_payload
669)             logger.debug(
670)                 'Setting contents (empty directory): %s -> %s', path, '{}'
671)             )
672)             store(config_structure, path, b'{}')
673)         else:
674)             logger.debug(
675)                 'Setting contents: %s -> %s',
676)                 path,
677)                 json_content.decode('utf-8'),
678)             )
679)             store(config_structure, path, json_content)
680)     for _dir, namelist in dirs_to_check.items():
681)         namelist = [x.rstrip('/') for x in namelist]  # noqa: PLW2901
682)         try:
683)             obj = config_structure
684)             for part in _dir.split('/'):
685)                 if part:
686)                     obj = obj[part]
687)         except KeyError as exc:
688)             msg = f'Cannot traverse storage path: {_dir!r}'
689)             raise RuntimeError(msg) from exc
690)         if set(obj.keys()) != set(namelist):
691)             msg = f'Object key mismatch for path {_dir!r}'
692)             raise RuntimeError(msg)
693)     return config_structure
Marco Ricci Add prototype for "storeroo...

Marco Ricci authored 1 month ago

694) 
695) 
696) if __name__ == '__main__':