mirror of
https://github.com/jrnl-org/jrnl.git
synced 2025-05-10 16:48:31 +02:00
Saves password to keyring
Closes #96 and deprecates password field in config
This commit is contained in:
parent
b3a51396e7
commit
34b730a5c9
8 changed files with 73 additions and 45 deletions
|
@ -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`
|
||||||
|
|
|
@ -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",
|
||||||
|
@ -54,7 +48,8 @@ class Journal(object):
|
||||||
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
|
consts.DOWParseStyle = -1 # "Monday" will be either today or the last Monday
|
||||||
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()
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -73,18 +73,19 @@ 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"):
|
||||||
print("colorama not found. To turn on highlighting, install colorama and set highlight to true in your .jrnl_conf.")
|
print("colorama not found. To turn on highlighting, install colorama and set highlight to true in your .jrnl_conf.")
|
||||||
default_config['highlight'] = False
|
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
|
# Write config to ~/.jrnl_conf
|
||||||
with open(config_path, 'w') as f:
|
with open(config_path, 'w') as f:
|
||||||
|
|
22
jrnl/jrnl.py
22
jrnl/jrnl.py
|
@ -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):
|
||||||
|
@ -109,12 +111,11 @@ def update_config(config, new_config, scope):
|
||||||
"""Updates a config dict with new values - either global if scope is None
|
"""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,
|
of config['journals'][scope] is just a string pointing to a journal file,
|
||||||
or within the scope"""
|
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)
|
config['journals'][scope].update(new_config)
|
||||||
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)
|
||||||
|
@ -142,9 +143,9 @@ def cli(manual_args=None):
|
||||||
if journal_name is not 'default':
|
if journal_name is not 'default':
|
||||||
args.text = args.text[1:]
|
args.text = args.text[1:]
|
||||||
journal_conf = config['journals'].get(journal_name)
|
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)
|
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'] = journal_conf
|
||||||
config['journal'] = os.path.expanduser(config['journal'])
|
config['journal'] = os.path.expanduser(config['journal'])
|
||||||
touch_journal(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']))
|
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()
|
||||||
|
|
||||||
|
|
38
jrnl/util.py
38
jrnl/util.py
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
3
setup.py
3
setup.py
|
@ -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"
|
||||||
|
|
Loading…
Add table
Reference in a new issue