mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-06-28 21:46:13 +02:00
Rework Encryption to enable future support of other encryption methods (#1602)
- initial pass through to rework encryption into separate module - little more cleanup - rename function, fix some linting issues - more cleaning - fix password bug in encryption - fix linting issue - more cleanup - move prompt into prompt.py - more cleanup - update the upgrade process for new encryption classes - general cleanup - turn into enum instead of strings - store status code so tests don't fail - standardize the load and store methods in journals - get rid of old PlainJournal class - typing cleanup - more cleanup - format - fix linting issue - Fix obscure Windows line ending issue with decode See https://bugs.python.org/issue40863 - fix for python 3.11 - add more typing - don't use class variables because that's not what we want - fix more type hints - jrnlv1 encryption doesn't support encryption anymore (it's deprecated) - keep logic for password attemps inside the class that uses it - take out old line of code - add some more logging - update logging statements - clean up logging statements - run linters - fix typo - Fix for new test from develop branch There was a new test added for re-encrypting a journal. This updates the refactor to match the old (previously untested) behavior of jrnl. Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com>
This commit is contained in:
parent
e6e08e5d3e
commit
fcc8d8e3fa
19 changed files with 437 additions and 267 deletions
52
jrnl/encryption/BaseEncryption.py
Normal file
52
jrnl/encryption/BaseEncryption.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
import logging
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
|
||||
from jrnl.exception import JrnlException
|
||||
from jrnl.messages import Message
|
||||
from jrnl.messages import MsgStyle
|
||||
from jrnl.messages import MsgText
|
||||
|
||||
|
||||
class BaseEncryption(ABC):
|
||||
def __init__(self, journal_name: str, config: dict):
|
||||
logging.debug("start")
|
||||
self._encoding: str = "utf-8"
|
||||
self._journal_name: str = journal_name
|
||||
self._config: dict = config
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def encrypt(self, text: str) -> bytes:
|
||||
logging.debug("encrypting")
|
||||
return self._encrypt(text)
|
||||
|
||||
def decrypt(self, text: bytes) -> str:
|
||||
logging.debug("decrypting")
|
||||
if (result := self._decrypt(text)) is None:
|
||||
raise JrnlException(
|
||||
Message(MsgText.DecryptionFailedGeneric, MsgStyle.ERROR)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
def _encrypt(self, text: str) -> bytes:
|
||||
"""
|
||||
This is needed because self.decrypt might need
|
||||
to perform actions (e.g. prompt for password)
|
||||
before actually encrypting.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _decrypt(self, text: bytes) -> str | None:
|
||||
"""
|
||||
This is needed because self.decrypt might need
|
||||
to perform actions (e.g. prompt for password)
|
||||
before actually decrypting.
|
||||
"""
|
||||
pass
|
7
jrnl/encryption/BaseKeyEncryption.py
Normal file
7
jrnl/encryption/BaseKeyEncryption.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
from .BaseEncryption import BaseEncryption
|
||||
|
||||
|
||||
class BaseKeyEncryption(BaseEncryption):
|
||||
pass
|
81
jrnl/encryption/BasePasswordEncryption.py
Normal file
81
jrnl/encryption/BasePasswordEncryption.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
import logging
|
||||
|
||||
from jrnl.encryption.BaseEncryption import BaseEncryption
|
||||
from jrnl.exception import JrnlException
|
||||
from jrnl.keyring import get_keyring_password
|
||||
from jrnl.messages import Message
|
||||
from jrnl.messages import MsgStyle
|
||||
from jrnl.messages import MsgText
|
||||
from jrnl.prompt import create_password
|
||||
from jrnl.prompt import prompt_password
|
||||
|
||||
|
||||
class BasePasswordEncryption(BaseEncryption):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
logging.debug("start")
|
||||
self._attempts: int = 0
|
||||
self._max_attempts: int = 3
|
||||
self._password: str = ""
|
||||
self._check_keyring: bool = True
|
||||
|
||||
@property
|
||||
def check_keyring(self) -> bool:
|
||||
return self._check_keyring
|
||||
|
||||
@check_keyring.setter
|
||||
def check_keyring(self, value: bool) -> None:
|
||||
self._check_keyring = value
|
||||
|
||||
@property
|
||||
def password(self) -> str | None:
|
||||
return self._password
|
||||
|
||||
@password.setter
|
||||
def password(self, value: str) -> None:
|
||||
self._password = value
|
||||
|
||||
def clear(self):
|
||||
self.password = None
|
||||
self.check_keyring = False
|
||||
|
||||
def encrypt(self, text: str) -> bytes:
|
||||
logging.debug("encrypting")
|
||||
if not self.password:
|
||||
if self.check_keyring and (
|
||||
keyring_pw := get_keyring_password(self._journal_name)
|
||||
):
|
||||
self.password = keyring_pw
|
||||
|
||||
if not self.password:
|
||||
self.password = create_password(self._journal_name)
|
||||
|
||||
return self._encrypt(text)
|
||||
|
||||
def decrypt(self, text: bytes) -> str:
|
||||
logging.debug("decrypting")
|
||||
if not self.password:
|
||||
if self.check_keyring and (
|
||||
keyring_pw := get_keyring_password(self._journal_name)
|
||||
):
|
||||
self.password = keyring_pw
|
||||
|
||||
if not self.password:
|
||||
self._prompt_password()
|
||||
|
||||
while (result := self._decrypt(text)) is None:
|
||||
self._prompt_password()
|
||||
|
||||
return result
|
||||
|
||||
def _prompt_password(self) -> None:
|
||||
if self._attempts >= self._max_attempts:
|
||||
raise JrnlException(
|
||||
Message(MsgText.PasswordMaxTriesExceeded, MsgStyle.ERROR)
|
||||
)
|
||||
|
||||
first_try = self._attempts == 0
|
||||
self.password = prompt_password(first_try=first_try)
|
||||
self._attempts += 1
|
41
jrnl/encryption/Jrnlv1Encryption.py
Normal file
41
jrnl/encryption/Jrnlv1Encryption.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers import algorithms
|
||||
from cryptography.hazmat.primitives.ciphers import modes
|
||||
|
||||
from jrnl.encryption.BasePasswordEncryption import BasePasswordEncryption
|
||||
|
||||
|
||||
class Jrnlv1Encryption(BasePasswordEncryption):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
logging.debug("start")
|
||||
|
||||
def _encrypt(self, _: str) -> bytes:
|
||||
raise NotImplementedError
|
||||
|
||||
def _decrypt(self, text: bytes) -> str | None:
|
||||
logging.debug("decrypting")
|
||||
iv, cipher = text[:16], text[16:]
|
||||
password = self._password or ""
|
||||
decryption_key = hashlib.sha256(password.encode(self._encoding)).digest()
|
||||
decryptor = Cipher(
|
||||
algorithms.AES(decryption_key), modes.CBC(iv), default_backend()
|
||||
).decryptor()
|
||||
try:
|
||||
plain_padded = decryptor.update(cipher) + decryptor.finalize()
|
||||
if plain_padded[-1] in (" ", 32):
|
||||
# Ancient versions of jrnl. Do not judge me.
|
||||
return plain_padded.decode(self._encoding).rstrip(" ")
|
||||
else:
|
||||
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
||||
plain = unpadder.update(plain_padded) + unpadder.finalize()
|
||||
return plain.decode(self._encoding)
|
||||
except ValueError:
|
||||
return None
|
58
jrnl/encryption/Jrnlv2Encryption.py
Normal file
58
jrnl/encryption/Jrnlv2Encryption.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.fernet import InvalidToken
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from .BasePasswordEncryption import BasePasswordEncryption
|
||||
|
||||
|
||||
class Jrnlv2Encryption(BasePasswordEncryption):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
# Salt is hard-coded
|
||||
self._salt: bytes = b"\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8"
|
||||
self._key: bytes = b""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
logging.debug("start")
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
return self._password
|
||||
|
||||
@password.setter
|
||||
def password(self, value: str | None):
|
||||
self._password = value
|
||||
self._make_key()
|
||||
|
||||
def _make_key(self) -> None:
|
||||
if self._password is None:
|
||||
# Password was removed after being set
|
||||
self._key = None
|
||||
return
|
||||
password = self.password.encode(self._encoding)
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=self._salt,
|
||||
iterations=100_000,
|
||||
backend=default_backend(),
|
||||
)
|
||||
key = kdf.derive(password)
|
||||
self._key = base64.urlsafe_b64encode(key)
|
||||
|
||||
def _encrypt(self, text: str) -> bytes:
|
||||
logging.debug("encrypting")
|
||||
return Fernet(self._key).encrypt(text.encode(self._encoding))
|
||||
|
||||
def _decrypt(self, text: bytes) -> str | None:
|
||||
logging.debug("decrypting")
|
||||
try:
|
||||
return Fernet(self._key).decrypt(text).decode(self._encoding)
|
||||
except (InvalidToken, IndexError):
|
||||
return None
|
19
jrnl/encryption/NoEncryption.py
Normal file
19
jrnl/encryption/NoEncryption.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
import logging
|
||||
|
||||
from jrnl.encryption.BaseEncryption import BaseEncryption
|
||||
|
||||
|
||||
class NoEncryption(BaseEncryption):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
logging.debug("start")
|
||||
|
||||
def _encrypt(self, text: str) -> bytes:
|
||||
logging.debug("encrypting")
|
||||
return text.encode(self._encoding)
|
||||
|
||||
def _decrypt(self, text: bytes) -> str:
|
||||
logging.debug("decrypting")
|
||||
return text.decode(self._encoding)
|
34
jrnl/encryption/__init__.py
Normal file
34
jrnl/encryption/__init__.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Copyright © 2012-2022 jrnl contributors
|
||||
# License: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
from enum import Enum
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .BaseEncryption import BaseEncryption
|
||||
|
||||
|
||||
class EncryptionMethods(str, Enum):
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
NONE = "NoEncryption"
|
||||
JRNLV1 = "Jrnlv1Encryption"
|
||||
JRNLV2 = "Jrnlv2Encryption"
|
||||
|
||||
|
||||
def determine_encryption_method(config: str | bool) -> "BaseEncryption":
|
||||
ENCRYPTION_METHODS = {
|
||||
True: EncryptionMethods.JRNLV2, # the default
|
||||
False: EncryptionMethods.NONE,
|
||||
"jrnlv1": EncryptionMethods.JRNLV1,
|
||||
"jrnlv2": EncryptionMethods.JRNLV2,
|
||||
}
|
||||
|
||||
key = config
|
||||
if isinstance(config, str):
|
||||
key = config.lower()
|
||||
|
||||
my_class = ENCRYPTION_METHODS[key]
|
||||
|
||||
return getattr(import_module(f"jrnl.encryption.{my_class}"), my_class)
|
Loading…
Add table
Add a link
Reference in a new issue