From a5f08e6081e1435f28369c94e1e6f99de070baff Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Sun, 5 Apr 2015 07:25:22 +1000 Subject: [PATCH] Introduce legacy classes --- features/encryption.feature | 1 + jrnl/EncryptedJournal.py | 38 ++++++++++++++++++++++++++++++-- jrnl/Journal.py | 31 +++++++++++++++++++++++--- jrnl/cli.py | 14 +++--------- jrnl/install.py | 2 +- jrnl/upgrade.py | 43 +++++++------------------------------ 6 files changed, 77 insertions(+), 52 deletions(-) diff --git a/features/encryption.feature b/features/encryption.feature index 2212fc33..ebb3cc02 100644 --- a/features/encryption.feature +++ b/features/encryption.feature @@ -36,6 +36,7 @@ """ Y bad doggie no biscuit + bad doggie no biscuit """ Then we should see the message "Password" and the output should contain "2013-06-10 15:40 Life is good" diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py index 16821191..dbe3695a 100644 --- a/jrnl/EncryptedJournal.py +++ b/jrnl/EncryptedJournal.py @@ -1,6 +1,8 @@ from . import Journal, util from cryptography.fernet import Fernet, InvalidToken -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import hashlib from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.backends import default_backend import base64 @@ -38,7 +40,9 @@ class EncryptedJournal(Journal.Journal): def validate_password(password): key = make_key(password) try: - return Fernet(key).decrypt(journal_encrypted).decode('utf-8') + plain = Fernet(key).decrypt(journal_encrypted).decode('utf-8') + self.config['password'] = password + return plain except (InvalidToken, IndexError): return None if password: @@ -57,3 +61,33 @@ class EncryptedJournal(Journal.Journal): dummy = Fernet(key).encrypt("") with open(filename, 'w') as f: f.write(dummy) + + +class LegacyEncryptedJournal(Journal.Journal): + def __init__(self, name='default', **kwargs): + super(LegacyEncryptedJournal, self).__init__(name, **kwargs) + self.config['encrypt'] = True + + def _load(self, filename, password=None): + with open(filename) as f: + journal_encrypted = f.read() + iv, cipher = journal_encrypted[:16], journal_encrypted[16:] + + def validate_password(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.config['password'] = password + if plain_padded[-1] == " ": + # Ancient versions of jrnl. Do not judge me. + plain = plain_padded.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 password: + return validate_password(password) + return util.get_password(keychain=self.name, validator=validate_password) diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 7371703c..7e3a36c6 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -10,6 +10,9 @@ import sys import codecs import re from datetime import datetime +import logging + +log = logging.getLogger("jrnl") class Journal(object): @@ -33,6 +36,15 @@ class Journal(object): """Returns the number of entries""" return len(self.entries) + @classmethod + def from_journal(cls, other): + """Creates a new journal by copying configuration and entries from + another journal object""" + new_journal = cls(other.name, **other.config) + new_journal.entries = other.entries + log.debug("Imported %d entries from %s to %s", len(new_journal), other.__class__.__name__, cls.__name__) + return new_journal + def import_(self, other_journal_txt): self.entries = list(frozenset(self.entries) | frozenset(self._parse(other_journal_txt))) self.sort() @@ -44,6 +56,7 @@ class Journal(object): text = self._load(filename) self.entries = self._parse(text) self.sort() + log.debug("opened %s with %d entries", self.__class__.__name__, len(self)) return self def write(self, filename=None): @@ -72,7 +85,6 @@ class Journal(object): # Initialise our current entry entries = [] current_entry = None - for line in journal_txt.splitlines(): line = line.rstrip() try: @@ -94,7 +106,7 @@ class Journal(object): # Happens when we can't parse the start of the line as an date. # In this case, just append line to our body. if current_entry: - current_entry.body += line + "\n" + current_entry.body += line + u"\n" # Append last entry if current_entry: @@ -243,10 +255,21 @@ class PlainJournal(Journal): f.write(text) -def open_journal(name, config): +def open_journal(name, config, legacy=False): """ Creates a normal, encrypted or DayOne journal based on the passed config. + If legacy is True, it will open Journals with legacy classes build for + backwards compatibility with jrnl 1.x """ + config = config.copy() + journal_conf = config['journals'].get(name) + if type(journal_conf) is dict: # We can override the default config on a by-journal basis + log.debug('Updating configuration with specific journal overrides %s', journal_conf) + config.update(journal_conf) + else: # But also just give them a string to point to the journal file + config['journal'] = journal_conf + config['journal'] = os.path.expanduser(os.path.expandvars(config['journal'])) + if os.path.isdir(config['journal']): if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']): from . import DayOneJournal @@ -259,4 +282,6 @@ def open_journal(name, config): return PlainJournal(name, **config).open() else: from . import EncryptedJournal + if legacy: + return EncryptedJournal.LegacyEncryptedJournal(name, **config).open() return EncryptedJournal.EncryptedJournal(name, **config).open() diff --git a/jrnl/cli.py b/jrnl/cli.py index fbbdefec..e0663e8e 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -18,7 +18,7 @@ import argparse import sys import logging -log = logging.getLogger(__name__) +log = logging.getLogger("jrnl") def parse_args(args=None): @@ -147,7 +147,6 @@ def run(manual_args=None): sys.exit(0) config = install.load_or_install_jrnl() - if args.ls: util.prnt(u"Journals defined in {}".format(install.CONFIG_FILE_PATH)) ml = min(max(len(k) for k in config['journals']), 20) @@ -163,6 +162,7 @@ def run(manual_args=None): journal_name = args.text[0] if (args.text and args.text[0] in config['journals']) else 'default' if journal_name is not 'default': args.text = args.text[1:] + # If the first remaining argument looks like e.g. '-3', interpret that as a limiter if not args.limit and args.text and args.text[0].startswith("-"): try: @@ -172,16 +172,7 @@ def run(manual_args=None): pass log.debug('Using journal "%s"', journal_name) - journal_conf = config['journals'].get(journal_name) - if type(journal_conf) is dict: # We can override the default config on a by-journal basis - log.debug('Updating configuration with specific jourlnal overrides %s', journal_conf) - config.update(journal_conf) - else: # But also just give them a string to point to the journal file - config['journal'] = journal_conf - config['journal'] = os.path.expanduser(os.path.expandvars(config['journal'])) - touch_journal(config['journal']) mode_compose, mode_export, mode_import = guess_mode(args, config) - log.debug('Using journal path %(journal)s', config) # How to quit writing? if "win32" in sys.platform: @@ -206,6 +197,7 @@ def run(manual_args=None): else: mode_compose = False + # This is where we finally open the journal! journal = Journal.open_journal(journal_name, config) # Import mode diff --git a/jrnl/install.py b/jrnl/install.py index 550d9935..bee35924 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -65,7 +65,7 @@ def upgrade_config(config): for key in missing_keys: config[key] = default_config[key] save_config(config) - print("[.jrnl_conf updated to newest version at {}]".format(CONFIG_FILE_PATH)) + print("[Configuration updated to newest version at {}]".format(CONFIG_FILE_PATH)) def save_config(config): diff --git a/jrnl/upgrade.py b/jrnl/upgrade.py index b8941c5b..85ad9365 100644 --- a/jrnl/upgrade.py +++ b/jrnl/upgrade.py @@ -1,37 +1,8 @@ -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -import hashlib import util from . import __version__ -from . import EncryptedJournal +from . import Journal import sys -from cryptography.fernet import Fernet - - -def upgrade_encrypted_journal(filename, key_plain): - """Decrypts a journal in memory using the jrnl 1.x encryption scheme - and returns it in plain text.""" - with open(filename) as f: - iv_cipher = f.read() - iv, cipher = iv_cipher[:16], iv_cipher[16:] - decryption_key = hashlib.sha256(key_plain.encode('utf-8')).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] == " ": - # Ancient versions of jrnl. Do not judge me. - plain = plain_padded.rstrip(" ") - else: - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - plain = unpadder.update(plain_padded) + unpadder.finalize() - except ValueError: - return None - key = EncryptedJournal.make_key(key_plain) - journal = Fernet(key).encrypt(plain) - with open(filename, 'w') as f: - f.write(journal) - return plain +from .EncryptedJournal import EncryptedJournal def upgrade_jrnl_if_necessary(config_path): @@ -57,7 +28,6 @@ Please note that jrnl 1.x is NOT forward compatible with this version of jrnl. If you choose to proceed, you will not be able to use your journals with older versions of jrnl anymore. """.format(__version__)) - encrypted_journals = {} plain_journals = {} for journal, journal_conf in config['journals'].items(): @@ -86,9 +56,12 @@ older versions of jrnl anymore. util.prompt("jrnl NOT upgraded, exiting.") sys.exit(1) - for journal, path in encrypted_journals.items(): - util.prompt("Enter password for {} journal (stored in {}).".format(journal, path)) - util.get_password(keychain=journal, validator=lambda pwd: upgrade_encrypted_journal(path, pwd)) + for journal_name, path in encrypted_journals.items(): + util.prompt("Upgrading {} journal (stored in {}).".format(journal_name, path)) + old_journal = Journal.open_journal(journal_name, config, legacy=True) + new_journal = EncryptedJournal.from_journal(old_journal) + new_journal.write() + # util.get_password(keychain=journal, validator=lambda pwd: upgrade_encrypted_journal(path, pwd)) with open(config_path + ".backup", 'w') as config_backup: config_backup.write(config_file)