Coverage for documents/management/commands/mixins.py: 0%
38 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1import base64
2import os
3from typing import TypedDict
5from cryptography.fernet import Fernet
6from cryptography.hazmat.primitives import hashes
7from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
8from django.core.management import CommandError
10from documents.settings import EXPORTER_CRYPTO_ALGO_NAME
11from documents.settings import EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
12from documents.settings import EXPORTER_CRYPTO_KEY_SIZE_NAME
13from documents.settings import EXPORTER_CRYPTO_SALT_NAME
14from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
17class CryptFields(TypedDict):
18 exporter_key: str
19 model_name: str
20 fields: list[str]
23class CryptMixin:
24 """
25 Fully based on:
26 https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet
28 To encrypt:
29 1. Call setup_crypto providing the user provided passphrase
30 2. Call encrypt_string with a value
31 3. Store the returned hexadecimal representation of the value
33 To decrypt:
34 1. Load the required parameters:
35 a. key iterations
36 b. key size
37 c. key algorithm
38 2. Call setup_crypto providing the user provided passphrase and stored salt
39 3. Call decrypt_string with a value
40 4. Use the returned value
42 """
44 # This matches to Django's default for now
45 # https://github.com/django/django/blob/adae61942/django/contrib/auth/hashers.py#L315
47 # Set the defaults to be used during export
48 # During import, these are overridden from the loaded values to ensure decryption is possible
49 key_iterations = 1_000_000
50 salt_size = 16
51 key_size = 32
52 kdf_algorithm = "pbkdf2_sha256"
54 CRYPT_FIELDS: list[CryptFields] = [
55 {
56 "exporter_key": "mail_accounts",
57 "model_name": "paperless_mail.mailaccount",
58 "fields": [
59 "password",
60 "refresh_token",
61 ],
62 },
63 {
64 "exporter_key": "social_tokens",
65 "model_name": "socialaccount.socialtoken",
66 "fields": [
67 "token",
68 "token_secret",
69 ],
70 },
71 ]
72 # O(1) lookup for per-record encryption; derived from CRYPT_FIELDS at class definition time
73 CRYPT_FIELDS_BY_MODEL: dict[str, list[str]] = {
74 cfg["model_name"]: cfg["fields"] for cfg in CRYPT_FIELDS
75 }
77 def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
78 return {
79 EXPORTER_CRYPTO_SETTINGS_NAME: {
80 EXPORTER_CRYPTO_ALGO_NAME: self.kdf_algorithm,
81 EXPORTER_CRYPTO_KEY_ITERATIONS_NAME: self.key_iterations,
82 EXPORTER_CRYPTO_KEY_SIZE_NAME: self.key_size,
83 EXPORTER_CRYPTO_SALT_NAME: self.salt,
84 },
85 }
87 def load_crypt_params(self, metadata: dict) -> None:
88 # Load up the values for setting up decryption
89 self.kdf_algorithm: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
90 EXPORTER_CRYPTO_ALGO_NAME
91 ]
92 self.key_iterations: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
93 EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
94 ]
95 self.key_size: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
96 EXPORTER_CRYPTO_KEY_SIZE_NAME
97 ]
98 self.salt: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
99 EXPORTER_CRYPTO_SALT_NAME
100 ]
102 def setup_crypto(self, *, passphrase: str, salt: str | None = None) -> None:
103 """
104 Constructs a class for encryption or decryption using the specified passphrase and salt
106 Salt is assumed to be a hexadecimal representation of a cryptographically secure random byte string.
107 If not provided, it will be derived from the system secure random
108 """
109 self.salt = salt or os.urandom(self.salt_size).hex()
111 # Derive the KDF based on loaded settings
112 if self.kdf_algorithm == "pbkdf2_sha256":
113 kdf = PBKDF2HMAC(
114 algorithm=hashes.SHA256(),
115 length=self.key_size,
116 salt=bytes.fromhex(self.salt),
117 iterations=self.key_iterations,
118 )
119 else: # pragma: no cover
120 raise CommandError(
121 f"{self.kdf_algorithm} is an unknown key derivation function",
122 )
124 key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
126 self.fernet = Fernet(key)
128 def encrypt_string(self, *, value: str) -> str:
129 """
130 Given a string value, encrypts it and returns the hexadecimal representation of the encrypted token
132 """
133 return self.fernet.encrypt(value.encode("utf-8")).hex()
135 def decrypt_string(self, *, value: str) -> str:
136 """
137 Given a string value, decrypts it and returns the original value of the field
138 """
139 return self.fernet.decrypt(bytes.fromhex(value)).decode("utf-8")