From 34b730a5c9f940d2705d51d955941c82e9ab33dc Mon Sep 17 00:00:00 2001 From: Manuel Ebert Date: Thu, 17 Oct 2013 14:55:59 -0700 Subject: [PATCH] Saves password to keyring Closes #96 and deprecates password field in config --- CHANGELOG.md | 4 ++++ jrnl/Journal.py | 41 +++++++++++++---------------------------- jrnl/__init__.py | 2 +- jrnl/install.py | 7 ++++--- jrnl/jrnl.py | 22 +++++++++++----------- jrnl/util.py | 38 +++++++++++++++++++++++++++++++++++++- requirements.txt | 1 + setup.py | 3 ++- 8 files changed, 73 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e231dd9..e94bf47c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +#### 1.6.0 + +* [Improved] Passwords are now saved in the key-chain. The `password` field in `.jrnl_config` is soft-deprecated. + #### 1.5.7 * [Improved] The `~` in journal config paths will now expand properly to e.g. `/Users/maebert` diff --git a/jrnl/Journal.py b/jrnl/Journal.py index 11b31952..116b6c9a 100644 --- a/jrnl/Journal.py +++ b/jrnl/Journal.py @@ -12,18 +12,13 @@ except ImportError: import parsedatetime.parsedatetime as pdt import re from datetime import datetime import time -try: import simplejson as json -except ImportError: import json import sys -import glob try: from Crypto.Cipher import AES from Crypto import Random crypto_installed = True except ImportError: crypto_installed = False -if "win32" in sys.platform: import pyreadline as readline -else: import readline import hashlib try: import colorama @@ -33,14 +28,13 @@ except ImportError: import plistlib import pytz import uuid - +from functools import partial class Journal(object): - def __init__(self, **kwargs): + def __init__(self, name='default', **kwargs): self.config = { 'journal': "journal.txt", 'encrypt': False, - 'password': "", 'default_hour': 9, 'default_minute': 0, 'timeformat': "%Y-%m-%d %H:%M", @@ -54,7 +48,8 @@ class Journal(object): 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.search_tags = None # Store tags we're highlighting + self.name = name journal_txt = self.open() self.entries = self.parse(journal_txt) @@ -77,7 +72,7 @@ class Journal(object): 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) + sys.exit(1) padding = " ".encode("utf-8") if not plain.endswith(padding): # Journals are always padded return None @@ -95,33 +90,23 @@ class Journal(object): plain += b" " * (AES.block_size - len(plain) % AES.block_size) return iv + crypto.encrypt(plain) - def make_key(self, prompt="Password: "): + def make_key(self, password): """Creates an encryption key from the default password or prompts for a new password.""" - password = self.config['password'] or util.getpass(prompt) 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'] - journal = None + + def validate_password(journal, password): + self.make_key(password) + return self._decrypt(journal) + if self.config['encrypt']: with open(filename, "rb") as f: - journal = f.read() - decrypted = None - attempts = 0 - while decrypted is None: - self.make_key() - decrypted = self._decrypt(journal) - if decrypted is None: - attempts += 1 - self.config['password'] = None # This password doesn't work. - if attempts < 3: - util.prompt("Wrong password, try again.") - else: - util.prompt("Extremely wrong password.") - sys.exit(-1) - journal = decrypted + journal_encrypted = f.read() + journal = util.get_password(keychain=self.name, validator=partial(validate_password, journal_encrypted)) else: with codecs.open(filename, "r", "utf-8") as f: journal = f.read() diff --git a/jrnl/__init__.py b/jrnl/__init__.py index 42dce17e..6124d668 100644 --- a/jrnl/__init__.py +++ b/jrnl/__init__.py @@ -7,7 +7,7 @@ jrnl is a simple journal application for your command line. """ __title__ = 'jrnl' -__version__ = '1.5.7' +__version__ = '1.6.0' __author__ = 'Manuel Ebert' __license__ = 'MIT License' __copyright__ = 'Copyright 2013 Manuel Ebert' diff --git a/jrnl/install.py b/jrnl/install.py index 2037e8aa..d09e5150 100644 --- a/jrnl/install.py +++ b/jrnl/install.py @@ -73,18 +73,19 @@ def install_jrnl(config_path='~/.jrnl_config'): password = getpass.getpass("Enter password for journal (leave blank for no encryption): ") if password: default_config['encrypt'] = True + if util.yesno("Do you want to store the password in your keychain?", default=True): + util.set_keychain("default", password) print("Journal will be encrypted.") - print("If you want to, you can store your password in .jrnl_config and will never be bothered about it again.") else: password = None - print("PyCrypto not found. To encrypt your journal, install the PyCrypto package from http://www.pycrypto.org and run 'jrnl --encrypt'. For now, your journal will be stored in plain text.") + print("PyCrypto not found. To encrypt your journal, install the PyCrypto package from http://www.pycrypto.org or with 'pip install pycrypto' and run 'jrnl --encrypt'. For now, your journal will be stored in plain text.") # Use highlighting: if not module_exists("colorama"): print("colorama not found. To turn on highlighting, install colorama and set highlight to true in your .jrnl_conf.") default_config['highlight'] = False - open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there + open(default_config['journals']['default'], 'a').close() # Touch to make sure it's there # Write config to ~/.jrnl_conf with open(config_path, 'w') as f: diff --git a/jrnl/jrnl.py b/jrnl/jrnl.py index acd5eff9..eec9b227 100755 --- a/jrnl/jrnl.py +++ b/jrnl/jrnl.py @@ -86,10 +86,12 @@ def get_text_from_editor(config): def encrypt(journal, filename=None): """ Encrypt into new file. If filename is not set, we encrypt the journal file itself. """ - journal.config['password'] = "" - journal.make_key(prompt="Enter new password:") + password = util.getpass("Enter new password: ") + journal.make_key(password) journal.config['encrypt'] = True 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'])) def decrypt(journal, filename=None): @@ -109,12 +111,11 @@ def update_config(config, new_config, scope): """Updates a config dict with new values - either global if scope is None of config['journals'][scope] is just a string pointing to a journal file, or within the scope""" - if scope and type(config['journals'][scope]) is dict: # Update to journal specific + if scope and type(config['journals'][scope]) is dict: # Update to journal specific config['journals'][scope].update(new_config) else: config.update(new_config) - def cli(manual_args=None): if not os.path.exists(CONFIG_PATH): config = install.install_jrnl(CONFIG_PATH) @@ -142,9 +143,9 @@ def cli(manual_args=None): if journal_name is not 'default': args.text = args.text[1:] journal_conf = config['journals'].get(journal_name) - if type(journal_conf) is dict: # We can override the default config on a by-journal basis + if type(journal_conf) is dict: # We can override the default config on a by-journal basis config.update(journal_conf) - else: # But also just give them a string to point to the journal file + else: # But also just give them a string to point to the journal file config['journal'] = journal_conf config['journal'] = os.path.expanduser(config['journal']) touch_journal(config['journal']) @@ -159,7 +160,7 @@ def cli(manual_args=None): 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(**config) + journal = Journal.Journal(journal_name, **config) if mode_compose and not args.text: if config['editor']: @@ -205,22 +206,21 @@ def cli(manual_args=None): encrypt(journal, filename=args.encrypt) # Not encrypting to a separate file: update config! if not args.encrypt: - update_config(original_config, {"encrypt": True, "password": ""}, journal_name) + update_config(original_config, {"encrypt": True}, journal_name) install.save_config(original_config, config_path=CONFIG_PATH) elif args.decrypt is not False: decrypt(journal, filename=args.decrypt) # Not decrypting to a separate file: update config! if not args.decrypt: - update_config(original_config, {"encrypt": False, "password": ""}, journal_name) + update_config(original_config, {"encrypt": False}, journal_name) install.save_config(original_config, config_path=CONFIG_PATH) elif args.delete_last: last_entry = journal.entries.pop() util.prompt("[Deleted Entry:]") - print(last_entry) + print(last_entry.pprint()) journal.write() if __name__ == "__main__": cli() - diff --git a/jrnl/util.py b/jrnl/util.py index 4f6031d5..e0667cab 100644 --- a/jrnl/util.py +++ b/jrnl/util.py @@ -4,6 +4,7 @@ import sys import os from tzlocal import get_localzone import getpass as gp +import keyring import pytz PY3 = sys.version_info[0] == 3 @@ -14,12 +15,42 @@ STDOUT = sys.stdout TEST = False __cached_tz = None -def getpass(prompt): +def getpass(prompt="Password: "): if not TEST: return gp.getpass(prompt) else: return py23_input(prompt) +def get_password(validator, keychain=None, max_attempts=3): + pwd_from_keychain = keychain and get_keychain(keychain) + password = pwd_from_keychain or getpass() + result = validator(password) + # Password is bad: + if not result and pwd_from_keychain: + set_keychain(keychain, None) + attempt = 1 + while not result and attempt < max_attempts: + prompt("Wrong password, try again.") + password = getpass() + result = validator(password) + attempt += 1 + if result: + return result + else: + prompt("Extremely wrong password.") + sys.exit(1) + +def get_keychain(journal_name): + return keyring.get_password('jrnl', journal_name) + +def set_keychain(journal_name, password): + if password is None: + try: + keyring.delete_password('jrnl', journal_name) + except: + pass + else: + keyring.set_password('jrnl', journal_name, password) def u(s): """Mock unicode function for python 2 and 3 compatibility.""" @@ -35,6 +66,11 @@ def py23_input(msg): STDERR.write(u(msg)) return STDIN.readline().strip() +def yesno(prompt, default=True): + prompt = prompt.strip() + (" [Yn]" if default else "[yN]") + raw = py23_input(prompt) + return {'y': True, 'n': False}.get(raw.lower(), default) + def get_local_timezone(): """Returns the Olson identifier of the local timezone. In a happy world, tzlocal.get_localzone would do this, but there's a bug on OS X diff --git a/requirements.txt b/requirements.txt index 999b9b05..64c1ed0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pycrypto >= 2.6 argparse==1.2.1 tzlocal == 1.0 slugify==0.0.1 +keyring==3.0.5 diff --git a/setup.py b/setup.py index 7f7e5d6c..668a44d9 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,8 @@ setup( "pytz>=2013b", "tzlocal==1.0", "slugify>=0.0.1", - "colorama>=0.2.5" + "colorama>=0.2.5", + "keyring>=3.0.5" ] + [p for p, cond in conditional_dependencies.items() if cond], extras_require = { "encrypted": "pycrypto>=2.6"