jrnl/jrnl/EncryptedJournal.py
Jonathan Wren bc42f74b2b
Reformat additional messages and finish centralizing exception handling (#1424)
* 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>
2022-03-19 12:30:23 -07:00

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)