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 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 "<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):
"""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)

View file

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