Make Journal a general interface

This change moves the loading and saving mechanisms into its own
Plain- and EncryptedJournal subclasses for easier maintenance and lazy loading
of all the crypto modules.
This commit is contained in:
Matthias Vogelgesang 2014-06-26 12:41:30 +02:00
parent beeda149d4
commit 9d589f3e64
3 changed files with 148 additions and 101 deletions

85
jrnl/EncryptedJournal.py Normal file
View file

@ -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)

View file

@ -8,14 +8,6 @@ from . import time
import codecs import codecs
import re import re
from datetime import datetime 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): class Journal(object):
@ -32,82 +24,33 @@ class Journal(object):
} }
self.config.update(kwargs) self.config.update(kwargs)
# Set up date parser # Set up date parser
self.key = None # used to decrypt and encrypt the journal
self.search_tags = None # Store tags we're highlighting self.search_tags = None # Store tags we're highlighting
self.name = name self.name = name
self.open()
def __len__(self): def __len__(self):
"""Returns the number of entries""" """Returns the number of entries"""
return len(self.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): def open(self, filename=None):
"""Opens the journal file defined in the config and parses it into a list of Entries. """Opens the journal file defined in the config and parses it into a list of Entries.
Entries have the form (date, title, body).""" Entries have the form (date, title, body)."""
filename = filename or self.config['journal'] filename = filename or self.config['journal']
text = self._load(filename)
if self.config['encrypt']: self.entries = self._parse(text)
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)
self.sort() 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): def _parse(self, journal_txt):
"""Parses a journal that's stored in a string and returns a list of entries""" """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): def __repr__(self):
return "<Journal with {0} entries>".format(len(self.entries)) return "<Journal with {0} entries>".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): def sort(self):
"""Sorts the Journal's entries by date""" """Sorts the Journal's entries by date"""
self.entries = sorted(self.entries, key=lambda entry: entry.date) self.entries = sorted(self.entries, key=lambda entry: entry.date)
@ -280,3 +211,16 @@ class Journal(object):
for entry in mod_entries: for entry in mod_entries:
entry.modified = not any(entry == old_entry for old_entry in self.entries) entry.modified = not any(entry == old_entry for old_entry in self.entries)
self.entries = mod_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)

View file

@ -9,7 +9,6 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from . import Journal from . import Journal
from . import DayOneJournal
from . import util from . import util
from . import exporters from . import exporters
from . import install from . import install
@ -68,21 +67,30 @@ def guess_mode(args, config):
def encrypt(journal, filename=None): def encrypt(journal, filename=None):
""" Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """
password = util.getpass("Enter new password: ") from . import EncryptedJournal
journal.make_key(password)
journal.config['password'] = util.getpass("Enter new password: ")
journal.config['encrypt'] = True 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): if util.yesno("Do you want to store the password in your keychain?", default=True):
util.set_keychain(journal.name, password) 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): def decrypt(journal, filename=None):
""" Decrypts into new file. If filename is not set, we encrypt the journal file itself. """ """ Decrypts into new file. If filename is not set, we encrypt the journal file itself. """
journal.config['encrypt'] = False journal.config['encrypt'] = False
journal.config['password'] = "" 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): def touch_journal(filename):
@ -112,6 +120,25 @@ def update_config(config, new_config, scope, force_local=False):
config.update(new_config) 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): def run(manual_args=None):
args = parse_args(manual_args) 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] 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: else:
mode_compose = False mode_compose = False
# open journal file or folder journal = open_journal(journal_name, config)
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)
# Writing mode # Writing mode
if mode_compose: if mode_compose: