Saves password to keyring

Closes #96 and deprecates password field in config
This commit is contained in:
Manuel Ebert 2013-10-17 14:55:59 -07:00
parent b3a51396e7
commit 34b730a5c9
8 changed files with 73 additions and 45 deletions

View file

@ -1,6 +1,10 @@
Changelog 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 #### 1.5.7
* [Improved] The `~` in journal config paths will now expand properly to e.g. `/Users/maebert` * [Improved] The `~` in journal config paths will now expand properly to e.g. `/Users/maebert`

View file

@ -12,18 +12,13 @@ except ImportError: import parsedatetime.parsedatetime as pdt
import re import re
from datetime import datetime from datetime import datetime
import time import time
try: import simplejson as json
except ImportError: import json
import sys import sys
import glob
try: try:
from Crypto.Cipher import AES from Crypto.Cipher import AES
from Crypto import Random from Crypto import Random
crypto_installed = True crypto_installed = True
except ImportError: except ImportError:
crypto_installed = False crypto_installed = False
if "win32" in sys.platform: import pyreadline as readline
else: import readline
import hashlib import hashlib
try: try:
import colorama import colorama
@ -33,14 +28,13 @@ except ImportError:
import plistlib import plistlib
import pytz import pytz
import uuid import uuid
from functools import partial
class Journal(object): class Journal(object):
def __init__(self, **kwargs): def __init__(self, name='default', **kwargs):
self.config = { self.config = {
'journal': "journal.txt", 'journal': "journal.txt",
'encrypt': False, 'encrypt': False,
'password': "",
'default_hour': 9, 'default_hour': 9,
'default_minute': 0, 'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M", 'timeformat': "%Y-%m-%d %H:%M",
@ -55,6 +49,7 @@ class Journal(object):
self.dateparse = pdt.Calendar(consts) self.dateparse = pdt.Calendar(consts)
self.key = None # used to decrypt and encrypt the journal 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() journal_txt = self.open()
self.entries = self.parse(journal_txt) self.entries = self.parse(journal_txt)
@ -77,7 +72,7 @@ class Journal(object):
plain = crypto.decrypt(cipher[16:]) plain = crypto.decrypt(cipher[16:])
except ValueError: except ValueError:
util.prompt("ERROR: Your journal file seems to be corrupted. You do have a backup, don't you?") 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") padding = " ".encode("utf-8")
if not plain.endswith(padding): # Journals are always padded if not plain.endswith(padding): # Journals are always padded
return None return None
@ -95,33 +90,23 @@ class Journal(object):
plain += b" " * (AES.block_size - len(plain) % AES.block_size) plain += b" " * (AES.block_size - len(plain) % AES.block_size)
return iv + crypto.encrypt(plain) 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.""" """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() 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']
journal = None
def validate_password(journal, password):
self.make_key(password)
return self._decrypt(journal)
if self.config['encrypt']: if self.config['encrypt']:
with open(filename, "rb") as f: with open(filename, "rb") as f:
journal = f.read() journal_encrypted = f.read()
decrypted = None journal = util.get_password(keychain=self.name, validator=partial(validate_password, journal_encrypted))
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
else: else:
with codecs.open(filename, "r", "utf-8") as f: with codecs.open(filename, "r", "utf-8") as f:
journal = f.read() journal = f.read()

View file

@ -7,7 +7,7 @@ jrnl is a simple journal application for your command line.
""" """
__title__ = 'jrnl' __title__ = 'jrnl'
__version__ = '1.5.7' __version__ = '1.6.0'
__author__ = 'Manuel Ebert' __author__ = 'Manuel Ebert'
__license__ = 'MIT License' __license__ = 'MIT License'
__copyright__ = 'Copyright 2013 Manuel Ebert' __copyright__ = 'Copyright 2013 Manuel Ebert'

View file

@ -73,11 +73,12 @@ def install_jrnl(config_path='~/.jrnl_config'):
password = getpass.getpass("Enter password for journal (leave blank for no encryption): ") password = getpass.getpass("Enter password for journal (leave blank for no encryption): ")
if password: if password:
default_config['encrypt'] = True 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("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: else:
password = None 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: # Use highlighting:
if not module_exists("colorama"): if not module_exists("colorama"):

View file

@ -86,10 +86,12 @@ def get_text_from_editor(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. """
journal.config['password'] = "" password = util.getpass("Enter new password: ")
journal.make_key(prompt="Enter new password:") journal.make_key(password)
journal.config['encrypt'] = True journal.config['encrypt'] = True
journal.write(filename) 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 journal.config['journal']))
def decrypt(journal, filename=None): def decrypt(journal, filename=None):
@ -114,7 +116,6 @@ def update_config(config, new_config, scope):
else: else:
config.update(new_config) config.update(new_config)
def cli(manual_args=None): def cli(manual_args=None):
if not os.path.exists(CONFIG_PATH): if not os.path.exists(CONFIG_PATH):
config = install.install_jrnl(CONFIG_PATH) config = install.install_jrnl(CONFIG_PATH)
@ -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'])) util.prompt("[Error: {0} is a directory, but doesn't seem to be a DayOne journal either.".format(config['journal']))
sys.exit(1) sys.exit(1)
else: else:
journal = Journal.Journal(**config) journal = Journal.Journal(journal_name, **config)
if mode_compose and not args.text: if mode_compose and not args.text:
if config['editor']: if config['editor']:
@ -205,22 +206,21 @@ def cli(manual_args=None):
encrypt(journal, filename=args.encrypt) encrypt(journal, filename=args.encrypt)
# Not encrypting to a separate file: update config! # Not encrypting to a separate file: update config!
if not args.encrypt: 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) install.save_config(original_config, config_path=CONFIG_PATH)
elif args.decrypt is not False: elif args.decrypt is not False:
decrypt(journal, filename=args.decrypt) decrypt(journal, filename=args.decrypt)
# Not decrypting to a separate file: update config! # Not decrypting to a separate file: update config!
if not args.decrypt: 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) install.save_config(original_config, config_path=CONFIG_PATH)
elif args.delete_last: elif args.delete_last:
last_entry = journal.entries.pop() last_entry = journal.entries.pop()
util.prompt("[Deleted Entry:]") util.prompt("[Deleted Entry:]")
print(last_entry) print(last_entry.pprint())
journal.write() journal.write()
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

View file

@ -4,6 +4,7 @@ import sys
import os import os
from tzlocal import get_localzone from tzlocal import get_localzone
import getpass as gp import getpass as gp
import keyring
import pytz import pytz
PY3 = sys.version_info[0] == 3 PY3 = sys.version_info[0] == 3
@ -14,12 +15,42 @@ STDOUT = sys.stdout
TEST = False TEST = False
__cached_tz = None __cached_tz = None
def getpass(prompt): def getpass(prompt="Password: "):
if not TEST: if not TEST:
return gp.getpass(prompt) return gp.getpass(prompt)
else: else:
return py23_input(prompt) 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): def u(s):
"""Mock unicode function for python 2 and 3 compatibility.""" """Mock unicode function for python 2 and 3 compatibility."""
@ -35,6 +66,11 @@ def py23_input(msg):
STDERR.write(u(msg)) STDERR.write(u(msg))
return STDIN.readline().strip() 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(): def get_local_timezone():
"""Returns the Olson identifier of the 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 In a happy world, tzlocal.get_localzone would do this, but there's a bug on OS X

View file

@ -5,3 +5,4 @@ pycrypto >= 2.6
argparse==1.2.1 argparse==1.2.1
tzlocal == 1.0 tzlocal == 1.0
slugify==0.0.1 slugify==0.0.1
keyring==3.0.5

View file

@ -73,7 +73,8 @@ setup(
"pytz>=2013b", "pytz>=2013b",
"tzlocal==1.0", "tzlocal==1.0",
"slugify>=0.0.1", "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], ] + [p for p, cond in conditional_dependencies.items() if cond],
extras_require = { extras_require = {
"encrypted": "pycrypto>=2.6" "encrypted": "pycrypto>=2.6"