diff --git a/features/steps/core.py b/features/steps/core.py index 6f9bfb60..0f726671 100644 --- a/features/steps/core.py +++ b/features/steps/core.py @@ -42,7 +42,7 @@ def open_journal(journal_name="default"): config.update(journal_conf) else: # But also just give them a string to point to the journal file config['journal'] = journal_conf - return Journal.Journal(**config) + return Journal.open_journal(journal_name, config) @given('we use the config "{config_file}"') def set_config(context, config_file): diff --git a/jrnl/DayOneJournal.py b/jrnl/DayOneJournal.py index d5649eb3..4ae69858 100644 --- a/jrnl/DayOneJournal.py +++ b/jrnl/DayOneJournal.py @@ -50,6 +50,7 @@ class DayOne(Journal.Journal): entry.tags = [self.config['tagsymbols'][0] + tag for tag in dict_entry.get("Tags", [])] self.entries.append(entry) self.sort() + return self def write(self): """Writes only the entries that have been modified into plist files.""" 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 35bdc395..fb1a5717 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -4,20 +4,16 @@ from __future__ import absolute_import from . import Entry from . import util -import codecs -try: import parsedatetime.parsedatetime_consts as pdt -except ImportError: import parsedatetime as pdt -import re from datetime import datetime -import dateutil +import os import sys +import codecs +import re +import dateutil try: - from Crypto.Cipher import AES - from Crypto import Random - crypto_installed = True + import parsedatetime.parsedatetime_consts as pdt except ImportError: - crypto_installed = False -import hashlib + import parsedatetime as pdt class Journal(object): @@ -37,82 +33,33 @@ class Journal(object): consts = pdt.Constants(usePyICU=False) consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday self.dateparse = pdt.Calendar(consts) - 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""" @@ -178,18 +125,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 = u"\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) @@ -321,3 +256,35 @@ 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) + + +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(u"[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 PlainJournal(name, **config).open() + else: + from . import EncryptedJournal + return EncryptedJournal.EncryptedJournal(name, **config).open() diff --git a/jrnl/__init__.py b/jrnl/__init__.py index 260d7f10..29fd44d5 100644 --- a/jrnl/__init__.py +++ b/jrnl/__init__.py @@ -12,7 +12,3 @@ __version__ = '1.8.4' __author__ = 'Manuel Ebert' __license__ = 'MIT License' __copyright__ = 'Copyright 2013 - 2014 Manuel Ebert' - -from . import Journal -from . import cli -from .cli import run diff --git a/jrnl/cli.py b/jrnl/cli.py index fbd1057f..a745ded1 100644 --- a/jrnl/cli.py +++ b/jrnl/cli.py @@ -9,7 +9,6 @@ from __future__ import absolute_import from . import Journal -from . import DayOneJournal from . import util from . import exporters from . import install @@ -67,21 +66,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.set_keychain(journal.name, journal.config['password']) + + 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): @@ -111,6 +119,8 @@ def update_config(config, new_config, scope, force_local=False): config.update(new_config) + + 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] @@ -172,16 +182,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(u"[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 = Journal.open_journal(journal_name, config) # Writing mode if mode_compose: diff --git a/jrnl/util.py b/jrnl/util.py index ecddddce..39691898 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -3,7 +3,6 @@ import sys import os import getpass as gp -import keyring import json if "win32" in sys.platform: import colorama @@ -51,10 +50,12 @@ def get_password(validator, keychain=None, max_attempts=3): def get_keychain(journal_name): + import keyring return keyring.get_password('jrnl', journal_name) def set_keychain(journal_name, password): + import keyring if password is None: try: keyring.delete_password('jrnl', journal_name) diff --git a/setup.py b/setup.py index 2a7027fa..a320a585 100644 --- a/setup.py +++ b/setup.py @@ -85,7 +85,7 @@ setup( long_description=__doc__, entry_points={ 'console_scripts': [ - 'jrnl = jrnl:run', + 'jrnl = jrnl.cli:run', ], }, classifiers=[