mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-12 09:28:31 +02:00
* Update and modularize exception handling cc #1024 #1141 - Stack traces are no longer shown to users unless the --debug flag is being used - Errors, warnings, and other messages contain color as needed - Converted error messages to Enum - Adds print_msg function to centralize output (this should replace all other output in other modules) Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com> * format with black * add message to catch-all exception block * Unskip some tests (#1399) * remove skip_editor test and tag * remove useless test * unskip blank input test * formatting * rename test so it doesn't overwrite other test * unskip some dayone tests that now work * Bump ipython from 7.28.0 to 7.31.1 (#1401) Bumps [ipython](https://github.com/ipython/ipython) from 7.28.0 to 7.31.1. - [Release notes](https://github.com/ipython/ipython/releases) - [Commits](https://github.com/ipython/ipython/compare/7.28.0...7.31.1) --- updated-dependencies: - dependency-name: ipython dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update changelog [ci skip] * Bump asteval from 0.9.25 to 0.9.26 (#1400) Bumps [asteval](https://github.com/newville/asteval) from 0.9.25 to 0.9.26. - [Release notes](https://github.com/newville/asteval/releases) - [Commits](https://github.com/newville/asteval/compare/0.9.25...0.9.26) --- updated-dependencies: - dependency-name: asteval dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update changelog [ci skip] * Bump black from 21.12b0 to 22.1.0 (#1404) * Bump black from 21.12b0 to 22.1.0 Bumps [black](https://github.com/psf/black) from 21.12b0 to 22.1.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/commits/22.1.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> * Run make format Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com> * Update changelog [ci skip] * Add reference documentation to docs site and separate out "Tips and Tricks" and "External Editors" from "Recipes" (#1332) * First draft of command line reference, mostly pulled from help screen * Add first draft of config file reference, mostly pulled from advanced.md * Clean up config file doc for readability * Add --config-file and remove examples from CLI reference * Add warning about time zone in timeformat * More small changes, and adding template config keyword * Cleaning up and re-ordering config file reference * Clean up reference and anything else from advanced documentation that can live elsewhere and linking to config file reference wherever config file is mentioned * Fix syntax highlighting in command line reference, clean up content a bit, include --diagnostic * Mention version config key * Apply minor changes suggested in PR review * Rename "recipes" to "Tips and Tricks", pull "External Editors" out of it into its own page, and redirect old recipes link to tips-and-tricks * Revert broken mkdocs-redirects usage from last commit * Update changelog [ci skip] * Add --co alias for --config-override (#1397) * Add hash as a default tag symbol (#1398) * Update changelog [ci skip] * Increment version to v2.8.4-beta2 * Update changelog [ci skip] * Increment version to v2.8.4 * Update changelog [ci skip] * Bump pytest from 6.2.5 to 7.0.0 (#1407) Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.5 to 7.0.0. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/6.2.5...7.0.0) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update changelog [ci skip] * Drop support for Python 3.7 and 3.8 (#1412) * Remove Python 3.7 and 3.8 from github actions workflows * Update lockfile after running poetry update a couple times * Update poetry lock * Remove Python 3.7 and 3.8 from pyproject.toml and run poetry lock * Update changelog [ci skip] * Tidy up git ignore (#1414) * cleaned gitignore and add comments * removed colon for readbility * alphabetize files in sections Co-authored-by: nelnog <nel.nogales@gmail.com> * fix behavior that was confusing pytest * update test to match new message * whitespace change * clean up error for manually stopping the inline editor * udpate error to use new exception handling * move some exceptions and errors to the new exception handling * add line breaks to keyboard interrupt so it looks more like other exceptions * add handling for exceptions that happen earlier in the flow * add new 'NothingToDelete' error to replace old behavior * get rid of old exception * add new exception handling to 'nothing saved to file' errors * move exception for no editor configured into new handling * move exception for no alt config to new handling * get rid of old exception handling for encrypted journal * Move error for too many wrong passwords into new handling * fix merge errors * replace sys.exit call with new exception handling * replace sys.exit call with new exception handling * replace sys.exit call with new exception handling * reformat with black * clean up old code * clean up old code * clean up linting issue * update uncaught exception for new handling * update test * fix mangled lock file Co-authored-by: Micah Jerome Ellison <micah.jerome.ellison@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jrnl Bot <jrnl.bot@gmail.com> Co-authored-by: Nelson <35701520+nelnog@users.noreply.github.com> Co-authored-by: nelnog <nel.nogales@gmail.com>
204 lines
7 KiB
Python
204 lines
7 KiB
Python
import base64
|
|
import getpass
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Callable
|
|
from typing import Optional
|
|
|
|
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 import padding
|
|
from cryptography.hazmat.primitives.ciphers import Cipher
|
|
from cryptography.hazmat.primitives.ciphers import algorithms
|
|
from cryptography.hazmat.primitives.ciphers import modes
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
|
|
from .Journal import Journal
|
|
from .Journal import LegacyJournal
|
|
from .prompt import create_password
|
|
|
|
from jrnl.exception import JrnlException
|
|
from jrnl.messages import Message
|
|
from jrnl.messages import MsgText
|
|
from jrnl.messages import MsgType
|
|
|
|
|
|
def make_key(password):
|
|
password = password.encode("utf-8")
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
# Salt is hard-coded
|
|
salt=b"\xf2\xd5q\x0e\xc1\x8d.\xde\xdc\x8e6t\x89\x04\xce\xf8",
|
|
iterations=100_000,
|
|
backend=default_backend(),
|
|
)
|
|
key = kdf.derive(password)
|
|
return base64.urlsafe_b64encode(key)
|
|
|
|
|
|
def decrypt_content(
|
|
decrypt_func: Callable[[str], Optional[str]],
|
|
keychain: str = None,
|
|
max_attempts: int = 3,
|
|
) -> str:
|
|
pwd_from_keychain = keychain and get_keychain(keychain)
|
|
password = pwd_from_keychain or getpass.getpass()
|
|
result = decrypt_func(password)
|
|
# Password is bad:
|
|
if result is None and pwd_from_keychain:
|
|
set_keychain(keychain, None)
|
|
attempt = 1
|
|
while result is None and attempt < max_attempts:
|
|
print("Wrong password, try again.", file=sys.stderr)
|
|
password = getpass.getpass()
|
|
result = decrypt_func(password)
|
|
attempt += 1
|
|
|
|
if result is None:
|
|
raise JrnlException(Message(MsgText.PasswordMaxTriesExceeded, MsgType.ERROR))
|
|
|
|
return result
|
|
|
|
|
|
class EncryptedJournal(Journal):
|
|
def __init__(self, name="default", **kwargs):
|
|
super().__init__(name, **kwargs)
|
|
self.config["encrypt"] = True
|
|
self.password = None
|
|
|
|
def open(self, filename=None):
|
|
"""Opens the journal file defined in the config and parses it into a list of Entries.
|
|
Entries have the form (date, title, body)."""
|
|
filename = filename or self.config["journal"]
|
|
dirname = os.path.dirname(filename)
|
|
if not os.path.exists(filename):
|
|
if not os.path.isdir(dirname):
|
|
os.makedirs(dirname)
|
|
print(f"[Directory {dirname} created]", file=sys.stderr)
|
|
self.create_file(filename)
|
|
self.password = create_password(self.name)
|
|
|
|
print(
|
|
f"Encrypted journal '{self.name}' created at {filename}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
text = self._load(filename)
|
|
self.entries = self._parse(text)
|
|
self.sort()
|
|
logging.debug("opened %s with %d entries", self.__class__.__name__, len(self))
|
|
return self
|
|
|
|
def _load(self, filename):
|
|
"""Loads an encrypted journal from a file and tries to decrypt it.
|
|
If password is not provided, will look for password in the keychain
|
|
and otherwise ask the user to enter a password up to three times.
|
|
If the password is provided but wrong (or corrupt), this will simply
|
|
return None."""
|
|
with open(filename, "rb") as f:
|
|
journal_encrypted = f.read()
|
|
|
|
def decrypt_journal(password):
|
|
key = make_key(password)
|
|
try:
|
|
plain = Fernet(key).decrypt(journal_encrypted).decode("utf-8")
|
|
self.password = password
|
|
return plain
|
|
except (InvalidToken, IndexError):
|
|
return None
|
|
|
|
if self.password:
|
|
return decrypt_journal(self.password)
|
|
|
|
return decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)
|
|
|
|
def _store(self, filename, text):
|
|
key = make_key(self.password)
|
|
journal = Fernet(key).encrypt(text.encode("utf-8"))
|
|
with open(filename, "wb") as f:
|
|
f.write(journal)
|
|
|
|
@classmethod
|
|
def from_journal(cls, other: Journal):
|
|
new_journal = super().from_journal(other)
|
|
new_journal.password = (
|
|
other.password
|
|
if hasattr(other, "password")
|
|
else create_password(other.name)
|
|
)
|
|
|
|
return new_journal
|
|
|
|
|
|
class LegacyEncryptedJournal(LegacyJournal):
|
|
"""Legacy class to support opening journals encrypted with the jrnl 1.x
|
|
standard. You'll not be able to save these journals anymore."""
|
|
|
|
def __init__(self, name="default", **kwargs):
|
|
super().__init__(name, **kwargs)
|
|
self.config["encrypt"] = True
|
|
self.password = None
|
|
|
|
def _load(self, filename):
|
|
with open(filename, "rb") as f:
|
|
journal_encrypted = f.read()
|
|
iv, cipher = journal_encrypted[:16], journal_encrypted[16:]
|
|
|
|
def decrypt_journal(password):
|
|
decryption_key = hashlib.sha256(password.encode("utf-8")).digest()
|
|
decryptor = Cipher(
|
|
algorithms.AES(decryption_key), modes.CBC(iv), default_backend()
|
|
).decryptor()
|
|
try:
|
|
plain_padded = decryptor.update(cipher) + decryptor.finalize()
|
|
self.password = password
|
|
if plain_padded[-1] in (" ", 32):
|
|
# Ancient versions of jrnl. Do not judge me.
|
|
return plain_padded.decode("utf-8").rstrip(" ")
|
|
else:
|
|
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
|
plain = unpadder.update(plain_padded) + unpadder.finalize()
|
|
return plain.decode("utf-8")
|
|
except ValueError:
|
|
return None
|
|
|
|
if self.password:
|
|
return decrypt_journal(self.password)
|
|
return decrypt_content(keychain=self.name, decrypt_func=decrypt_journal)
|
|
|
|
|
|
def get_keychain(journal_name):
|
|
import keyring
|
|
|
|
try:
|
|
return keyring.get_password("jrnl", journal_name)
|
|
except keyring.errors.KeyringError as e:
|
|
if not isinstance(e, keyring.errors.NoKeyringError):
|
|
print("Failed to retrieve keyring", file=sys.stderr)
|
|
return ""
|
|
|
|
|
|
def set_keychain(journal_name, password):
|
|
import keyring
|
|
|
|
if password is None:
|
|
try:
|
|
keyring.delete_password("jrnl", journal_name)
|
|
except keyring.errors.KeyringError:
|
|
pass
|
|
else:
|
|
try:
|
|
keyring.set_password("jrnl", journal_name, password)
|
|
except keyring.errors.KeyringError as e:
|
|
if isinstance(e, keyring.errors.NoKeyringError):
|
|
print(
|
|
"Keyring backend not found. Please install one of the supported backends by visiting: https://pypi.org/project/keyring/",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print("Failed to retrieve keyring", file=sys.stderr)
|