Skip to content

Commit

Permalink
Merge 8cb15f4 into 5ff44ed
Browse files Browse the repository at this point in the history
  • Loading branch information
prabi authored May 9, 2019
2 parents 5ff44ed + 8cb15f4 commit f6ed114
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 46 deletions.
12 changes: 6 additions & 6 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ This new command should connect to a server using an encrypted rsa key.
- [X] Add tests

``pypass edit``
--------------
---------------

- [X] ``pypass edit test.com`` will open a text editor and let you edit the password

Expand All @@ -143,11 +143,11 @@ This new command should connect to a server using an encrypted rsa key.

- [X] ``pypass grep searchstring`` will search for the given string inside all of the encrypted passwords


``pypass generate``
-------------------
- [ ] ``pypass generate [pass-name] [pass-length]`` Genrates a new password using of length pass-length and inserts it into pass-name.
- [ ] ``--no-symbols, -n``
- [ ] ``--clip, -c``
- [ ] ``--in-place, -i``
- [X] ``pypass generate [pass-name] [pass-length]`` Genrates a new password using of length pass-length and inserts it into pass-name.
- [X] ``pass-length`` argument is optional, defaults to 25, can be set with envvar ``PASSWORD_STORE_GENERATED_LENGTH``
- [X] ``--no-symbols, -n``
- [X] ``--clip, -c``
- [X] ``--in-place, -i`` modify only the first line, fails if ``pass-name`` doesn't exist
- [ ] ``--force, -f``
42 changes: 36 additions & 6 deletions pypass/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,49 @@ def insert(config, path, multiline):


@main.command()
@click.option('--no-symbols', '-n', is_flag=True, default=False)
@click.option('--no-symbols', '-n', is_flag=True)
@click.option('--clip', '-c', is_flag=True)
@click.option('--in-place', '-i', is_flag=True)
@click.argument('pass_name', type=click.STRING)
@click.argument('pass_length', type=int)
def generate(pass_name, pass_length, no_symbols):
@click.argument(
'pass_length',
type=int,
required=False,
envvar='PASSWORD_STORE_GENERATED_LENGTH',
default=25
)
@click.pass_obj
def generate(config, pass_name, pass_length, no_symbols, clip, in_place):
symbols = not no_symbols

password = PasswordStore.generate_password(
password = config['password_store'].generate_password(
pass_name,
digits=True,
symbols=symbols,
length=pass_length
length=pass_length,
first_line_only=in_place
)

print(password)
if config['password_store'].uses_git:
config['password_store'].git_add_and_commit(
pass_name + '.gpg',
message='%s generated password for %s.' % (
'Replace' if in_place else 'Add',
pass_name
)
)

if clip:
xclip = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE
)
xclip.stdin.write(password.encode())
xclip.stdin.close()
click.echo('Copied %s to clipboard.' % pass_name)
else:
click.echo(
'The generated password for %s is:\n%s' % (pass_name, password))


@main.command()
Expand Down
38 changes: 32 additions & 6 deletions pypass/passwordstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@
import os
import subprocess
import string
import random
import re

from .entry_type import EntryType

# Secure source of randomness for password generation
try:
from secrets import choice
except ImportError:
import random
_system_random = random.SystemRandom()
choice = _system_random.choice

# Find the right gpg binary
if subprocess.call(
['which', 'gpg2'],
Expand Down Expand Up @@ -137,6 +144,8 @@ def get_decrypted_password(self, path, entry=None):
return hostname.groups()[0]
else:
return decrypted_password
else:
raise Exception('Couldn\'t decrypt %s' % path)

def insert_password(self, path, password):
"""Encrypts the password at the given path
Expand Down Expand Up @@ -171,14 +180,28 @@ def insert_password(self, path, password):
gpg.stdin.close()
gpg.wait()

@staticmethod
def generate_password(digits=True, symbols=True, length=15):
"""Returns a random password
def generate_password(
self,
path,
digits=True,
symbols=True,
length=25,
first_line_only=False
):
"""Returns and stores a random password
:param path: Where to insert the password. Ex: 'passwordstore.org'
:param digits: Should the password have digits? Defaults to True
:param symbols: Should the password have symbols? Defaults to True
:param length: Length of the password. Defaults to 15
:param length: Length of the password. Defaults to 25
:param first_line_only: Modify only the first line of an existing entry
:returns: Generated password.
"""
if first_line_only:
old_content = self.get_decrypted_password(path)
content_wo_pass = ''.join(old_content.partition('\n')[1:])
else:
content_wo_pass = ''

chars = string.ascii_letters

Expand All @@ -188,7 +211,10 @@ def generate_password(digits=True, symbols=True, length=15):
if digits:
chars += string.digits

password = ''.join(random.choice(chars) for i in range(length))
password = ''.join(choice(chars) for i in range(length))

self.insert_password(path, password + content_wo_pass)

return password

@staticmethod
Expand Down
80 changes: 58 additions & 22 deletions pypass/tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ def run_cli(self, args, input=None, expect_failure=False):
'Invoking "pypass {}" failed'.format(' '.join(args)))
return result

def assertLastCommitMessage(self, text):
git_log = subprocess.Popen(
[
'git',
'--git-dir=%s' % os.path.join(self.dir, '.git'),
'--work-tree=%s' % self.dir,
'log', '-1', '--pretty=%B'
],
shell=False,
stdout=subprocess.PIPE
)
git_log.wait()
self.assertEqual(git_log.stdout.read().decode(), text + '\n\n')

def setUp(self):
self.dir = tempfile.mkdtemp()

Expand Down Expand Up @@ -355,25 +369,8 @@ def test_git_init_insert_and_show(self):
input='super_secret\nsuper_secret'
)

self.assertTrue(
os.path.isfile(os.path.join(self.dir, 'test.com.gpg'))
)

git_log = subprocess.Popen(
[
'git',
'--git-dir=%s' % os.path.join(self.dir, '.git'),
'--work-tree=%s' % self.dir,
'log', '-1', '--pretty=%B'
],
shell=False,
stdout=subprocess.PIPE
)
git_log.wait()
self.assertEqual(
git_log.stdout.read().decode(),
'Added test.com to store\n\n'
)
self.assertTrue(os.path.isfile(os.path.join(self.dir, 'test.com.gpg')))
self.assertLastCommitMessage('Added test.com to store')

show_result = self.run_cli(
['show', 'test.com'],
Expand Down Expand Up @@ -443,6 +440,45 @@ def test_init_clone(self):
)

def test_generate_no_symbols(self):
generate = self.run_cli(['generate', '-n', 'test.com', '20'])
password = generate.output.strip()
self.assertIsNotNone(re.match('[a-zA-Z0-9]{20}$', password))
generate = self.run_cli(['generate', '-n', 'test.com'])
password = generate.output.partition('\n')[2].strip()
self.assertIsNotNone(re.match('[a-zA-Z0-9]{25}$', password))

store = PasswordStore(self.dir)
decoded = store.get_decrypted_password('test.com')
self.assertEqual(decoded, password)

def test_generate_in_place(self):
self.run_cli(['git', 'init'])
store = PasswordStore(self.dir)

generate = self.run_cli(
['generate', '-i', 'in-place.com'],
expect_failure=True
)
self.assertNotEqual(generate.exit_code, 0)

store.insert_password('in-place.com', 'first\nsecond')
self.run_cli(['generate', '-i', 'in-place.com', '10'])

self.assertLastCommitMessage(
'Replace generated password for in-place.com.'
)

new_content = store.get_decrypted_password('in-place.com')
new_password, _, remainder = new_content.partition('\n')
self.assertEqual(len(new_password), 10)
self.assertEqual(remainder, 'second')

@pypass.tests.skipIfTravis
def test_generate_clip(self):
generate = self.run_cli(['generate', '-c', 'clip.me'])

self.assertEqual(generate.output, 'Copied clip.me to clipboard.\n')

xclip = subprocess.Popen(
['xclip', '-o', '-selection', 'clipboard'],
stdout=subprocess.PIPE
)
xclip.wait()
self.assertEqual(len(xclip.stdout.read().decode().strip()), 25)
35 changes: 29 additions & 6 deletions pypass/tests/test_passwordstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def test_get_decrypted_password_deeply_nested(self):
os.path.isdir(os.path.join(self.dir, 'A', 'B', 'C', 'D'))
)

def test_get_decrypted_password_doesnt_exist(self):
store = PasswordStore(self.dir)
self.assertRaises(Exception, store.get_decrypted_password, 'nope.com')

def test_init(self):
init_dir = tempfile.mkdtemp()
PasswordStore.init(
Expand Down Expand Up @@ -239,17 +243,36 @@ def test_init_clone(self):
shutil.rmtree(destination_dir)

def test_generate_password(self):
only_letters = PasswordStore.generate_password(
digits=False,
symbols=False
)
store = PasswordStore(self.dir)

store.generate_password('letters.net', digits=False, symbols=False)
only_letters = store.get_decrypted_password('letters.net')
self.assertTrue(only_letters.isalpha())

alphanum = PasswordStore.generate_password(digits=True, symbols=False)
store.generate_password('alphanum.co.uk', digits=True, symbols=False)
alphanum = store.get_decrypted_password('alphanum.co.uk')
self.assertTrue(alphanum.isalnum())
for char in alphanum:
self.assertTrue(char not in string.punctuation)

length_100 = PasswordStore.generate_password(length=100)
store.generate_password('hundred.org', length=100)
length_100 = store.get_decrypted_password('hundred.org')
self.assertEqual(len(length_100), 100)

def test_generate_in_place(self):
store = PasswordStore(self.dir)

self.assertRaises(
Exception,
store.generate_password,
'nope.org',
first_line_only=True
)

store.insert_password('nope.org', 'pw\nremains intact')
store.generate_password('nope.org', length=3, first_line_only=True)

new_content = store.get_decrypted_password('nope.org')
new_password, _, remainder = new_content.partition('\n')
self.assertNotEqual(new_password, 'pw')
self.assertEqual(remainder, 'remains intact')

0 comments on commit f6ed114

Please sign in to comment.