diff --git a/jrnl/EncryptedJournal.py b/jrnl/EncryptedJournal.py new file mode 100644 index 00000000..9fe51927 --- /dev/null +++ b/jrnl/EncryptedJournal.py @@ -0,0 +1,85 @@ +import hashlib +import sys +from . import Journal, util +try: + from Crypto.Cipher import AES + from Crypto import Random + crypto_installed = True +except ImportError: + crypto_installed = False + + +def make_key(password): + return hashlib.sha256(password.encode("utf-8")).digest() + + +def _decrypt(cipher, key): + """Decrypts a cipher string using self.key as the key and the first 16 byte of the cipher as the IV""" + if not crypto_installed: + sys.exit("Error: PyCrypto is not installed.") + if not cipher: + return "" + crypto = AES.new(key, AES.MODE_CBC, cipher[:16]) + try: + plain = crypto.decrypt(cipher[16:]) + except ValueError: + util.prompt("ERROR: Your journal file seems to be corrupted. You do have a backup, don't you?") + sys.exit(1) + + padding_length = util.byte2int(plain[-1]) + if padding_length > AES.block_size and padding_length != 32: + # 32 is the space character and is kept for backwards compatibility + return None + elif padding_length == 32: + plain = plain.strip() + elif plain[-padding_length:] != util.int2byte(padding_length) * padding_length: + # Invalid padding! + return None + else: + plain = plain[:-padding_length] + + return plain.decode("utf-8") + + +def _encrypt(plain, key): + """Encrypt a plaintext string using key""" + if not crypto_installed: + sys.exit("Error: PyCrypto is not installed.") + Random.atfork() # A seed for PyCrypto + iv = Random.new().read(AES.block_size) + crypto = AES.new(key, AES.MODE_CBC, iv) + plain = plain.encode("utf-8") + padding_length = AES.block_size - len(plain) % AES.block_size + plain += util.int2byte(padding_length) * padding_length + return iv + crypto.encrypt(plain) + + +class EncryptedJournal(Journal.Journal): + def __init__(self, name='default', **kwargs): + super(EncryptedJournal, self).__init__(name, **kwargs) + self.config['encrypt'] = True + + def _load(self, filename): + with open(filename, "rb") as f: + journal_encrypted = f.read() + + def validate_password(password): + key = make_key(password) + return _decrypt(journal_encrypted, key) + + text = None + + if 'password' in self.config: + text = validate_password(self.config['password']) + + if text is None: + text = util.get_password(keychain=self.name, validator=validate_password) + + return text + + def _store(self, filename, text): + key = make_key(self.config['password']) + journal = _encrypt(text, key) + + with open(filename, 'wb') as f: + f.write(journal) diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 92a6774f..3c1094f0 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -8,14 +8,6 @@ from . import time import codecs import re from datetime import datetime -import sys -try: - from Crypto.Cipher import AES - from Crypto import Random - crypto_installed = True -except ImportError: - crypto_installed = False -import hashlib class Journal(object): @@ -32,82 +24,33 @@ class Journal(object): } self.config.update(kwargs) # Set up date parser - self.key = None # used to decrypt and encrypt the journal self.search_tags = None # Store tags we're highlighting self.name = name - self.open() - def __len__(self): """Returns the number of entries""" return len(self.entries) - def _decrypt(self, cipher): - """Decrypts a cipher string using self.key as the key and the first 16 byte of the cipher as the IV""" - if not crypto_installed: - sys.exit("Error: PyCrypto is not installed.") - if not cipher: - return "" - crypto = AES.new(self.key, AES.MODE_CBC, cipher[:16]) - try: - plain = crypto.decrypt(cipher[16:]) - except ValueError: - util.prompt("ERROR: Your journal file seems to be corrupted. You do have a backup, don't you?") - sys.exit(1) - - padding_length = util.byte2int(plain[-1]) - if padding_length > AES.block_size and padding_length != 32: - # 32 is the space character and is kept for backwards compatibility - return None - elif padding_length == 32: - plain = plain.strip() - elif plain[-padding_length:] != util.int2byte(padding_length) * padding_length: - # Invalid padding! - return None - else: - plain = plain[:-padding_length] - return plain.decode("utf-8") - - def _encrypt(self, plain): - """Encrypt a plaintext string using self.key as the key""" - if not crypto_installed: - sys.exit("Error: PyCrypto is not installed.") - Random.atfork() # A seed for PyCrypto - iv = Random.new().read(AES.block_size) - crypto = AES.new(self.key, AES.MODE_CBC, iv) - plain = plain.encode("utf-8") - padding_length = AES.block_size - len(plain) % AES.block_size - plain += util.int2byte(padding_length) * padding_length - return iv + crypto.encrypt(plain) - - def make_key(self, password): - """Creates an encryption key from the default password or prompts for a new password.""" - self.key = hashlib.sha256(password.encode("utf-8")).digest() - 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'] - - if self.config['encrypt']: - with open(filename, "rb") as f: - journal_encrypted = f.read() - - def validate_password(password): - self.make_key(password) - return self._decrypt(journal_encrypted) - - # Soft-deprecated: - journal = None - if 'password' in self.config: - journal = validate_password(self.config['password']) - if journal is None: - journal = util.get_password(keychain=self.name, validator=validate_password) - else: - with codecs.open(filename, "r", "utf-8") as f: - journal = f.read() - self.entries = self._parse(journal) + text = self._load(filename) + self.entries = self._parse(text) self.sort() + return self + + def write(self, filename=None): + """Dumps the journal into the config file, overwriting it""" + filename = filename or self.config['journal'] + text = u"\n".join([e.__unicode__() for e in self.entries]) + self._store(filename, text) + + def _load(self, filename): + raise NotImplementedError + + def _store(self, filename, text): + raise NotImplementedError def _parse(self, journal_txt): """Parses a journal that's stored in a string and returns a list of entries""" @@ -173,18 +116,6 @@ class Journal(object): def __repr__(self): return "".format(len(self.entries)) - def write(self, filename=None): - """Dumps the journal into the config file, overwriting it""" - filename = filename or self.config['journal'] - journal = "\n".join([e.__unicode__() for e in self.entries]) - if self.config['encrypt']: - journal = self._encrypt(journal) - with open(filename, 'wb') as journal_file: - journal_file.write(journal) - else: - with codecs.open(filename, 'w', "utf-8") as journal_file: - journal_file.write(journal) - def sort(self): """Sorts the Journal's entries by date""" self.entries = sorted(self.entries, key=lambda entry: entry.date) @@ -280,3 +211,16 @@ class Journal(object): for entry in mod_entries: entry.modified = not any(entry == old_entry for old_entry in self.entries) self.entries = mod_entries + + +class PlainJournal(Journal): + def __init__(self, name='default', **kwargs): + super(PlainJournal, self).__init__(name, **kwargs) + + def _load(self, filename): + with codecs.open(filename, "r", "utf-8") as f: + return f.read() + + def _store(self, filename, text): + with codecs.open(filename, 'w', "utf-8") as f: + f.write(text) diff --git a/jrnl/cli.py b/jrnl/cli.py index 84db971d..ca5592d3 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -9,7 +9,6 @@ from __future__ import absolute_import, unicode_literals from . import Journal -from . import DayOneJournal from . import util from . import exporters from . import install @@ -68,21 +67,30 @@ def guess_mode(args, config): def encrypt(journal, filename=None): """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ - password = util.getpass("Enter new password: ") - journal.make_key(password) + from . import EncryptedJournal + + journal.config['password'] = util.getpass("Enter new password: ") journal.config['encrypt'] = True - journal.write(filename) + + new_journal = EncryptedJournal.EncryptedJournal(None, **journal.config) + new_journal.entries = journal.entries + new_journal.write(filename) + if util.yesno("Do you want to store the password in your keychain?", default=True): util.set_keychain(journal.name, password) - util.prompt("Journal encrypted to {0}.".format(filename or journal.config['journal'])) + + util.prompt("Journal encrypted to {0}.".format(filename or new_journal.config['journal'])) def decrypt(journal, filename=None): """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ journal.config['encrypt'] = False journal.config['password'] = "" - journal.write(filename) - util.prompt("Journal decrypted to {0}.".format(filename or journal.config['journal'])) + + new_journal = Journal.PlainJournal(filename, **journal.config) + new_journal.entries = journal.entries + new_journal.write(filename) + util.prompt("Journal decrypted to {0}.".format(filename or new_journal.config['journal'])) def touch_journal(filename): @@ -112,6 +120,25 @@ def update_config(config, new_config, scope, force_local=False): config.update(new_config) +def open_journal(name, config): + """ + Creates a normal, encrypted or DayOne journal based on the passed config. + """ + if os.path.isdir(config['journal']): + if config['journal'].strip("/").endswith(".dayone") or "entries" in os.listdir(config['journal']): + from . import DayOneJournal + return DayOneJournal.DayOne(**config).open() + else: + util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal'])) + sys.exit(1) + + if not config['encrypt']: + return Journal.PlainJournal(name, **config).open() + else: + from . import EncryptedJournal + return EncryptedJournal.EncryptedJournal(name, **config).open() + + def run(manual_args=None): args = parse_args(manual_args) args.text = [p.decode('utf-8') if util.PY2 and not isinstance(p, unicode) else p for p in args.text] @@ -177,16 +204,7 @@ def run(manual_args=None): else: mode_compose = False - # open journal file or folder - if os.path.isdir(config['journal']): - if config['journal'].strip("/").endswith(".dayone") or \ - "entries" in os.listdir(config['journal']): - journal = DayOneJournal.DayOne(**config) - else: - util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal'])) - sys.exit(1) - else: - journal = Journal.Journal(journal_name, **config) + journal = open_journal(journal_name, config) # Writing mode if mode_compose: