mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-14 10:36:58 +01:00
Feature: Allow encrypting sensitive fields in export (#6927)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
parent
6ddb62bf3f
commit
d9002005b1
8 changed files with 583 additions and 120 deletions
|
|
@ -1,8 +1,27 @@
|
|||
import base64
|
||||
import os
|
||||
from argparse import ArgumentParser
|
||||
from typing import Optional
|
||||
from typing import TypedDict
|
||||
from typing import Union
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from django.core.management import CommandError
|
||||
|
||||
from documents.settings import EXPORTER_CRYPTO_ALGO_NAME
|
||||
from documents.settings import EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
|
||||
from documents.settings import EXPORTER_CRYPTO_KEY_SIZE_NAME
|
||||
from documents.settings import EXPORTER_CRYPTO_SALT_NAME
|
||||
from documents.settings import EXPORTER_CRYPTO_SETTINGS_NAME
|
||||
|
||||
|
||||
class CryptFields(TypedDict):
|
||||
exporter_key: str
|
||||
model_name: str
|
||||
fields: list[str]
|
||||
|
||||
|
||||
class MultiProcessMixin:
|
||||
"""
|
||||
|
|
@ -41,3 +60,109 @@ class ProgressBarMixin:
|
|||
def handle_progress_bar_mixin(self, *args, **options):
|
||||
self.no_progress_bar = options["no_progress_bar"]
|
||||
self.use_progress_bar = not self.no_progress_bar
|
||||
|
||||
|
||||
class CryptMixin:
|
||||
"""
|
||||
Fully based on:
|
||||
https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet
|
||||
|
||||
To encrypt:
|
||||
1. Call setup_crypto providing the user provided passphrase
|
||||
2. Call encrypt_string with a value
|
||||
3. Store the returned hexadecimal representation of the value
|
||||
|
||||
To decrypt:
|
||||
1. Load the required parameters:
|
||||
a. key iterations
|
||||
b. key size
|
||||
c. key algorithm
|
||||
2. Call setup_crypto providing the user provided passphrase and stored salt
|
||||
3. Call decrypt_string with a value
|
||||
4. Use the returned value
|
||||
|
||||
"""
|
||||
|
||||
# This matches to Django's default for now
|
||||
# https://github.com/django/django/blob/adae61942/django/contrib/auth/hashers.py#L315
|
||||
|
||||
# Set the defaults to be used during export
|
||||
# During import, these are overridden from the loaded values to ensure decryption is possible
|
||||
key_iterations = 1_000_000
|
||||
salt_size = 16
|
||||
key_size = 32
|
||||
kdf_algorithm = "pbkdf2_sha256"
|
||||
|
||||
CRYPT_FIELDS: CryptFields = [
|
||||
{
|
||||
"exporter_key": "mail_accounts",
|
||||
"model_name": "paperless_mail.mailaccount",
|
||||
"fields": [
|
||||
"password",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def get_crypt_params(self) -> dict[str, dict[str, Union[str, int]]]:
|
||||
return {
|
||||
EXPORTER_CRYPTO_SETTINGS_NAME: {
|
||||
EXPORTER_CRYPTO_ALGO_NAME: self.kdf_algorithm,
|
||||
EXPORTER_CRYPTO_KEY_ITERATIONS_NAME: self.key_iterations,
|
||||
EXPORTER_CRYPTO_KEY_SIZE_NAME: self.key_size,
|
||||
EXPORTER_CRYPTO_SALT_NAME: self.salt,
|
||||
},
|
||||
}
|
||||
|
||||
def load_crypt_params(self, metadata: dict):
|
||||
# Load up the values for setting up decryption
|
||||
self.kdf_algorithm: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
|
||||
EXPORTER_CRYPTO_ALGO_NAME
|
||||
]
|
||||
self.key_iterations: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
|
||||
EXPORTER_CRYPTO_KEY_ITERATIONS_NAME
|
||||
]
|
||||
self.key_size: int = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
|
||||
EXPORTER_CRYPTO_KEY_SIZE_NAME
|
||||
]
|
||||
self.salt: str = metadata[EXPORTER_CRYPTO_SETTINGS_NAME][
|
||||
EXPORTER_CRYPTO_SALT_NAME
|
||||
]
|
||||
|
||||
def setup_crypto(self, *, passphrase: str, salt: Optional[str] = None):
|
||||
"""
|
||||
Constructs a class for encryption or decryption using the specified passphrase and salt
|
||||
|
||||
Salt is assumed to be a hexadecimal representation of a cryptographically secure random byte string.
|
||||
If not provided, it will be derived from the system secure random
|
||||
"""
|
||||
self.salt = salt or os.urandom(self.salt_size).hex()
|
||||
|
||||
# Derive the KDF based on loaded settings
|
||||
if self.kdf_algorithm == "pbkdf2_sha256":
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=self.key_size,
|
||||
salt=bytes.fromhex(self.salt),
|
||||
iterations=self.key_iterations,
|
||||
)
|
||||
else: # pragma: no cover
|
||||
raise CommandError(
|
||||
f"{self.kdf_algorithm} is an unknown key derivation function",
|
||||
)
|
||||
|
||||
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
|
||||
|
||||
self.fernet = Fernet(key)
|
||||
|
||||
def encrypt_string(self, *, value: str) -> str:
|
||||
"""
|
||||
Given a string value, encrypts it and returns the hexadecimal representation of the encrypted token
|
||||
|
||||
"""
|
||||
return self.fernet.encrypt(value.encode("utf-8")).hex()
|
||||
|
||||
def decrypt_string(self, *, value: str) -> str:
|
||||
"""
|
||||
Given a string value, decrypts it and returns the original value of the field
|
||||
"""
|
||||
return self.fernet.decrypt(bytes.fromhex(value)).decode("utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue