Merge pull request #99 from maebert/96-keychain

Saves passwords to keyring
This commit is contained in:
Manuel Ebert 2013-10-20 13:45:15 -07:00
commit 11a6d84819
12 changed files with 134 additions and 75 deletions

View file

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

View file

@ -185,7 +185,6 @@ The configuration file is a simple JSON file with the following options.
- `journals`: paths to your journal files
- `editor`: if set, executes this command to launch an external editor for writing your entries, e.g. `vim` or `subl -w` (note the `-w` flag to make sure _jrnl_ waits for Sublime Text to close the file before writing into the journal).
- `encrypt`: if `true`, encrypts your journal using AES.
- `password`: you may store the password you used to encrypt your journal in plaintext here. This is useful if your journal file lives in an unsecure space (ie. your Dropbox), but the config file itself is more or less safe.
- `tagsymbols`: Symbols to be interpreted as tags. (__See note below__)
- `default_hour` and `default_minute`: if you supply a date, such as `last thursday`, but no specific time, the entry will be created at this time
- `timeformat`: how to format the timestamps in your journal, see the [python docs](http://docs.python.org/library/time.html#time.strftime) for reference

View file

@ -9,6 +9,7 @@
"password": "",
"journals": {
"default": "features/journals/simple.journal",
"simple": "features/journals/simple.journal",
"work": "features/journals/work.journal",
"ideas": "features/journals/nothing.journal"
},

View file

@ -1,4 +1,4 @@
Feature: Multiple journals
Feature: Encrypted journals
Scenario: Loading an encrypted journal
Given we use the config "encrypted.json"
@ -6,24 +6,32 @@
Then we should see the message "Password"
and the output should contain "2013-06-10 15:40 Life is good"
Scenario: Decrypting a journal
Given we use the config "encrypted.json"
When we run "jrnl --decrypt" and enter "bad doggie no biscuit"
Then the config for journal "default" should have "encrypt" set to "bool:False"
Then we should see the message "Journal decrypted"
and the journal should have 2 entries
Scenario: Encrypting a journal
Given we use the config "basic.json"
When we run "jrnl --encrypt" and enter "swordfish"
Then we should see the message "Journal encrypted"
and the config for journal "default" should have "encrypt" set to "bool:True"
When we run "jrnl -n 1" and enter "swordfish"
Then we should see the message "Password"
and the output should contain "2013-06-10 15:40 Life is good"
Scenario: Loading an encrypted journal with password in config
Given we use the config "encrypted_with_pw.json"
When we run "jrnl -n 1"
Then the output should contain "2013-06-10 15:40 Life is good"
Scenario: Decrypting a journal
Given we use the config "encrypted.json"
When we run "jrnl --decrypt" and enter "bad doggie no biscuit"
Then we should see the message "Journal decrypted"
and the journal should have 2 entries
and the config should have "encrypt" set to "bool:False"
Scenario: Encrypting a journal
Given we use the config "basic.json"
When we run "jrnl --encrypt" and enter "swordfish"
Then we should see the message "Journal encrypted"
and the config should have "encrypt" set to "bool:True"
When we run "jrnl -n 1" and enter "swordfish"
Then we should see the message "Password"
Scenario: Storing a password in Keychain
Given we use the config "multiple.json"
When we run "jrnl simple --encrypt" and enter "sabertooth"
When we set the keychain password of "simple" to "sabertooth"
Then the config for journal "simple" should have "encrypt" set to "bool:True"
When we run "jrnl simple -n 1"
Then we should not see the message "Password"
and the output should contain "2013-06-10 15:40 Life is good"

View file

@ -5,6 +5,8 @@ import os
import sys
import json
import pytz
import keyring
keyring.set_keyring(keyring.backends.file.PlaintextKeyring())
try:
from io import StringIO
except ImportError:
@ -34,11 +36,11 @@ def read_journal(journal_name="default"):
def open_journal(journal_name="default"):
with open(jrnl.CONFIG_PATH) as config_file:
config = json.load(config_file)
journals = config['journals']
if type(journals) is dict: # We can override the default config on a by-journal basis
config['journal'] = journals.get(journal_name)
else: # But also just give them a string to point to the journal file
config['journal'] = journal
journal_conf = config['journals'][journal_name]
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
config['journal'] = journal_conf
return Journal.Journal(**config)
@given('we use the config "{config_file}"')
@ -68,6 +70,10 @@ def run(context, command):
except SystemExit as e:
context.exit_status = e.code
@when('we set the keychain password of "{journal}" to "{password}"')
def set_keychain(context, journal, password):
keyring.set_password('jrnl', journal, password)
@then('we should get an error')
def has_error(context):
assert context.exit_status != 0, context.exit_status
@ -134,6 +140,11 @@ def check_message(context, text):
out = context.messages.getvalue()
assert text in out, [text, out]
@then('we should not see the message "{text}"')
def check_not_message(context, text):
out = context.messages.getvalue()
assert text not in out, [text, out]
@then('the journal should contain "{text}"')
@then('journal "{journal_name}" should contain "{text}"')
def check_journal_content(context, text, journal_name="default"):
@ -148,7 +159,8 @@ def journal_doesnt_exist(context, journal_name="default"):
assert not os.path.exists(journal_path)
@then('the config should have "{key}" set to "{value}"')
def config_var(context, key, value):
@then('the config for journal "{journal}" should have "{key}" set to "{value}"')
def config_var(context, key, value, journal=None):
t, value = value.split(":")
value = {
"bool": lambda v: v.lower() == "true",
@ -157,6 +169,8 @@ def config_var(context, key, value):
}[t](value)
with open(jrnl.CONFIG_PATH) as config_file:
config = json.load(config_file)
if journal:
config = config["journals"][journal]
assert key in config
assert config[key] == value
@ -171,4 +185,3 @@ def check_journal_content(context, number, journal_name="default"):
@then('fail')
def debug_fail(context):
assert False

View file

@ -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,30 @@ 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
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()
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 not journal:
journal = util.get_password(keychain=self.name, validator=validate_password)
else:
with codecs.open(filename, "r", "utf-8") as f:
journal = f.read()

View file

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

View file

@ -1,7 +1,8 @@
#!/usr/bin/env python
# encoding: utf-8
import readline, glob
import readline
import glob
import getpass
try: import simplejson as json
except ImportError: import json
@ -25,7 +26,6 @@ default_config = {
},
'editor': "",
'encrypt': False,
'password': "",
'default_hour': 9,
'default_minute': 0,
'timeformat': "%Y-%m-%d %H:%M",
@ -35,7 +35,7 @@ default_config = {
}
def update_config(config, config_path=os.path.expanduser("~/.jrnl_conf")):
def upgrade_config(config, config_path=os.path.expanduser("~/.jrnl_conf")):
"""Checks if there are keys missing in a given config dict, and if so, updates the config file accordingly.
This essentially automatically ports jrnl installations if new config parameters are introduced in later
versions."""
@ -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:

View file

@ -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):
@ -105,16 +107,18 @@ def touch_journal(filename):
util.prompt("[Journal created at {0}]".format(filename))
open(filename, 'a').close()
def update_config(config, new_config, scope):
def update_config(config, new_config, scope, force_local=False):
"""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 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)
elif scope and force_local: # Convert to dict
config['journals'][scope] = {"journal": config['journals'][scope]}
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)
@ -126,7 +130,7 @@ def cli(manual_args=None):
util.prompt("[There seems to be something wrong with your jrnl config at {0}: {1}]".format(CONFIG_PATH, e.message))
util.prompt("[Entry was NOT added to your journal]")
sys.exit(1)
install.update_config(config, config_path=CONFIG_PATH)
install.upgrade_config(config, config_path=CONFIG_PATH)
original_config = config.copy()
# check if the configuration is supported by available modules
@ -142,9 +146,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 +163,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 +209,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, force_local=True)
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, force_local=True)
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()

View file

@ -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
elif not TEST:
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

View file

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

View file

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