Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Human passwords #311

Merged
merged 3 commits into from Jan 15, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
97 changes: 91 additions & 6 deletions bundlewrap/secrets.py
Expand Up @@ -17,9 +17,22 @@
from .utils.ui import io


HUMAN_CHARS_START = list("bcdfghjklmnprstvwxz")
HUMAN_CHARS_VOWELS = list("aeiou") + ["ai", "ao", "au", "ea", "ee", "ei",
"eu", "ia", "ie", "oo", "ou"]
HUMAN_CHARS_CONS = HUMAN_CHARS_START + ["bb", "bl", "cc", "ch", "ck", "dd", "dr",
"ds", "dt", "ff", "gg", "gn", "kl", "ll",
"mb", "md", "mm", "mp", "mt", "nc", "nd",
"nn", "np", "nt", "pp", "rr", "rt", "sh",
"ss", "st", "tl", "ts", "tt"]

FILENAME_SECRETS = ".secrets.cfg"


def choice_prng(lst, prng):
return lst[next(prng) % (len(lst) - 1)]


def generate_initial_secrets_cfg():
return (
"# DO NOT COMMIT THIS FILE\n"
Expand Down Expand Up @@ -126,6 +139,61 @@ def _decrypt_file_as_base64(self, source_path=None, key='encrypt'):
join(self.repo.data_dir, source_path),
))).decode('utf-8')

def _generate_human_password(
self, identifier=None, digits=2, key='generate', per_word=3, words=4,
):
"""
Like _generate_password(), but creates a password which can be
typed more easily by human beings.

A "word" consists of an upper case character (usually an actual
consonant), followed by an alternating pattern of "vowels" and
"consonants". Those lists of characters are defined at the top
of this file. Note that something like "tl" is considered "a
consonant" as well. Similarly, "au" and friends are "a vowel".

Words are separated by dashes. By default, you also get some
digits at the end of the password.
"""
if environ.get("BW_VAULT_DUMMY_MODE", "0") != "0":
return "generatedpassword"

prng = self._get_prng(identifier, key)

pwd = ""
is_start = True
word_length = 0
words_done = 0
while words_done < words:
if is_start:
add = choice_prng(HUMAN_CHARS_START, prng).upper()
is_start = False
is_vowel = True
else:
if is_vowel:
add = choice_prng(HUMAN_CHARS_VOWELS, prng)
else:
add = choice_prng(HUMAN_CHARS_CONS, prng)
is_vowel = not is_vowel
pwd += add

word_length += 1
if word_length == per_word:
pwd += "-"
word_length = 0
words_done += 1
is_start = True

if digits > 0:
for i in range(digits):
pwd += str(next(prng) % 10)
else:
# Strip trailing dash which is always added by the routine
# above.
pwd = pwd[:-1]

return pwd

def _generate_password(self, identifier=None, key='generate', length=32, symbols=False):
"""
Derives a password from the given identifier and the shared key
Expand All @@ -138,6 +206,16 @@ def _generate_password(self, identifier=None, key='generate', length=32, symbols
"""
if environ.get("BW_VAULT_DUMMY_MODE", "0") != "0":
return "generatedpassword"

prng = self._get_prng(identifier, key)

alphabet = ascii_letters + digits
if symbols:
alphabet += punctuation

return "".join([choice_prng(alphabet, prng) for i in range(length)])

def _get_prng(self, identifier, key):
try:
key_encoded = self.keys[key]
except KeyError:
Expand All @@ -149,14 +227,9 @@ def _generate_password(self, identifier=None, key='generate', length=32, symbols
password=identifier,
))

alphabet = ascii_letters + digits
if symbols:
alphabet += punctuation

h = hmac.new(urlsafe_b64decode(key_encoded), digestmod=hashlib.sha512)
h.update(identifier.encode('utf-8'))
prng = random(h.digest())
return "".join([alphabet[next(prng) % (len(alphabet) - 1)] for i in range(length)])
return random(h.digest())

def _load_keys(self):
config = SafeConfigParser()
Expand Down Expand Up @@ -250,6 +323,18 @@ def format(self, format_str, *faults):
faults=faults,
)

def human_password_for(
self, identifier, digits=2, key='generate', per_word=3, words=4,
):
return Fault(
self._generate_human_password,
identifier=identifier,
digits=digits,
key=key,
per_word=per_word,
words=words,
)

def password_for(self, identifier, key='generate', length=32, symbols=False):
return Fault(
self._generate_password,
Expand Down
4 changes: 4 additions & 0 deletions docs/content/guide/secrets.md
Expand Up @@ -38,6 +38,10 @@ This makes it easy to change all your passwords at once (e.g. when an employee l

<div class="alert alert-warning">However, it also means you have to guard your <code>.secrets.cfg</code> very closely. If it is compromised, so are <strong>all</strong> your passwords. Use your own judgement.</div>

### "Human" passwords

As an alternative to `password_for()`, which generates random strings, you can use `human_password_for()`.It generates strings like `Wiac-Kaobl-Teuh-Kumd-40`. They are easier to handle for human beings. You might want to use them if you have to type those passwords on a regular basis.

<br>

## Static passwords
Expand Down
36 changes: 36 additions & 0 deletions tests/integration/secrets.py
Expand Up @@ -83,3 +83,39 @@ def test_format_password(tmpdir):
assert stdout == b"format: faCTT76kagtDuZE5wnoiD1CxhGKmbgiX\n"
assert stderr == b""
assert rcode == 0


def test_human_password(tmpdir):
make_repo(tmpdir)

stdout, stderr, rcode = run("bw debug -c 'print(repo.vault.human_password_for(\"hello world\"))'", path=str(tmpdir))
assert stdout == b"Xaint-Heep-Pier-Tikl-76\n"
assert stderr == b""
assert rcode == 0


def test_human_password_digits(tmpdir):
make_repo(tmpdir)

stdout, stderr, rcode = run("bw debug -c 'print(repo.vault.human_password_for(\"hello world\", digits=4))'", path=str(tmpdir))
assert stdout == b"Xaint-Heep-Pier-Tikl-7608\n"
assert stderr == b""
assert rcode == 0


def test_human_password_per_word(tmpdir):
make_repo(tmpdir)

stdout, stderr, rcode = run("bw debug -c 'print(repo.vault.human_password_for(\"hello world\", per_word=1))'", path=str(tmpdir))
assert stdout == b"X-D-F-H-42\n"
assert stderr == b""
assert rcode == 0


def test_human_password_words(tmpdir):
make_repo(tmpdir)

stdout, stderr, rcode = run("bw debug -c 'print(repo.vault.human_password_for(\"hello world\", words=2))'", path=str(tmpdir))
assert stdout == b"Xaint-Heep-13\n"
assert stderr == b""
assert rcode == 0