Import source for “1.9.0” from upstream tarball.
[debian_xkcdpass.git] / xkcdpass / xkcd_password.py
blobe9d4fd7f4e92e85e2ed5df848db5c3aeb7e69a78
1 #!/usr/bin/env python
2 # encoding: utf-8
4 import random
5 import os
6 import os.path
7 import argparse
8 import re
9 import math
10 import sys
12 __LICENSE__ = """
13 Copyright (c) 2011 - 2016, Steven Tobin and Contributors.
14 All rights reserved.
16 Redistribution and use in source and binary forms, with or without
17 modification, are permitted provided that the following conditions are met:
18 * Redistributions of source code must retain the above copyright
19 notice, this list of conditions and the following disclaimer.
20 * Redistributions in binary form must reproduce the above copyright
21 notice, this list of conditions and the following disclaimer in the
22 documentation and/or other materials provided with the distribution.
23 * Neither the name of the <organization> nor the
24 names of its contributors may be used to endorse or promote products
25 derived from this software without specific prior written permission.
27 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
28 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
29 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
30 DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
31 DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
32 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
33 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
34 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
35 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
36 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
37 """
39 # random.SystemRandom() should be cryptographically secure
40 try:
41 rng = random.SystemRandom
42 except AttributeError as ex:
43 sys.stderr.write("WARNING: System does not support cryptographically "
44 "secure random number generator or you are using Python "
45 "version < 2.4.\n")
46 if "XKCDPASS_ALLOW_WEAKRNG" in os.environ or \
47 "--allow-weak-rng" in sys.argv:
48 sys.stderr.write("Continuing with less-secure generator.\n")
49 rng = random.Random
50 else:
51 raise ex
54 # Python 3 compatibility
55 if sys.version_info[0] >= 3:
56 raw_input = input
57 xrange = range
60 def validate_options(parser, options):
61 """
62 Given a parsed collection of options, performs various validation checks.
63 """
65 if options.max_length < options.min_length:
66 sys.stderr.write("The maximum length of a word can not be "
67 "less than the minimum length.\n"
68 "Check the specified settings.\n")
69 sys.exit(1)
71 if options.wordfile is not None:
72 if not os.path.isfile(os.path.abspath(options.wordfile)):
73 sys.stderr.write("Could not open the specified word file.\n")
74 sys.exit(1)
75 else:
76 options.wordfile = locate_wordfile()
78 if not options.wordfile:
79 sys.stderr.write("Could not find a word file, or word file does "
80 "not exist.\n")
81 sys.exit(1)
84 def locate_wordfile():
85 static_default = os.path.join(
86 os.path.dirname(os.path.abspath(__file__)),
87 'static',
88 'default.txt')
89 common_word_files = ["/usr/share/cracklib/cracklib-small",
90 static_default,
91 "/usr/dict/words",
92 "/usr/share/dict/words"]
94 for wfile in common_word_files:
95 if os.path.isfile(wfile):
96 return wfile
99 def generate_wordlist(wordfile=None,
100 min_length=5,
101 max_length=9,
102 valid_chars='.'):
104 Generate a word list from either a kwarg wordfile, or a system default
105 valid_chars is a regular expression match condition (default - all chars)
108 if wordfile is None:
109 wordfile = locate_wordfile()
111 words = []
113 regexp = re.compile("^{0}{{{1},{2}}}$".format(valid_chars,
114 min_length,
115 max_length))
117 # At this point wordfile is set
118 wordfile = os.path.expanduser(wordfile) # just to be sure
120 # read words from file into wordlist
121 with open(wordfile) as wlf:
122 for line in wlf:
123 thisword = line.strip()
124 if regexp.match(thisword) is not None:
125 words.append(thisword)
127 return list(set(words)) # deduplicate, just in case
130 def wordlist_to_worddict(wordlist):
132 Takes a wordlist and returns a dictionary keyed by the first letter of
133 the words. Used for acrostic pass phrase generation
136 worddict = {}
138 # Maybe should be a defaultdict, but this reduces dependencies
139 for word in wordlist:
140 try:
141 worddict[word[0]].append(word)
142 except KeyError:
143 worddict[word[0]] = [word, ]
145 return worddict
148 def verbose_reports(length, numwords, wordfile):
150 Report entropy metrics based on word list and requested password size"
153 bits = math.log(length, 2)
155 print("The supplied word list is located at"
156 " {0}.".format(os.path.abspath(wordfile)))
158 if int(bits) == bits:
159 print("Your word list contains {0} words, or 2^{1} words."
160 "".format(length, bits))
161 else:
162 print("Your word list contains {0} words, or 2^{1:.2f} words."
163 "".format(length, bits))
165 print("A {0} word password from this list will have roughly "
166 "{1} ({2:.2f} * {3}) bits of entropy,"
167 "".format(numwords, int(bits * numwords), bits, numwords)),
168 print("assuming truly random word selection.")
171 def find_acrostic(acrostic, worddict):
173 Constrain choice of words to those beginning with the letters of the
174 given word (acrostic).
175 Second argument is a dictionary (output of wordlist_to_worddict)
178 words = []
180 for letter in acrostic:
181 try:
182 words.append(rng().choice(worddict[letter]))
183 except KeyError:
184 sys.stderr.write("No words found starting with " + letter + "\n")
185 sys.exit(1)
186 return words
189 def choose_words(wordlist, numwords):
191 Choose numwords randomly from wordlist
194 return [rng().choice(wordlist) for i in xrange(numwords)]
197 def try_input(prompt, validate):
199 Suppress stack trace on user cancel and validate input with supplied
200 validate callable.
203 try:
204 answer = raw_input(prompt)
205 except (KeyboardInterrupt, EOFError):
206 # user cancelled
207 print("")
208 sys.exit(0)
210 # validate input
211 return validate(answer)
214 def generate_xkcdpassword(wordlist,
215 numwords=6,
216 interactive=False,
217 acrostic=False,
218 delimiter=" "):
220 Generate an XKCD-style password from the words in wordlist.
223 passwd = None
225 # generate the worddict if we are looking for acrostics
226 if acrostic:
227 worddict = wordlist_to_worddict(wordlist)
229 # useful if driving the logic from other code
230 if not interactive:
231 if not acrostic:
232 passwd = delimiter.join(choose_words(wordlist, numwords))
233 else:
234 passwd = delimiter.join(find_acrostic(acrostic, worddict))
236 return passwd
238 # else, interactive session
239 # define input validators
240 def n_words_validator(answer):
242 Validate custom number of words input
245 if isinstance(answer, str) and len(answer) == 0:
246 return numwords
247 try:
248 number = int(answer)
249 if number < 1:
250 raise ValueError
251 return number
252 except ValueError:
253 sys.stderr.write("Please enter a positive integer\n")
254 sys.exit(1)
256 def accepted_validator(answer):
257 return answer.lower().strip() in ["y", "yes"]
259 if not acrostic:
260 n_words_prompt = ("Enter number of words (default {0}):"
261 " ".format(numwords))
263 numwords = try_input(n_words_prompt, n_words_validator)
264 else:
265 numwords = len(acrostic)
267 # generate passwords until the user accepts
268 accepted = False
270 while not accepted:
271 if not acrostic:
272 passwd = delimiter.join(choose_words(wordlist, numwords))
273 else:
274 passwd = delimiter.join(find_acrostic(acrostic, worddict))
275 print("Generated: " + passwd)
276 accepted = try_input("Accept? [yN] ", accepted_validator)
278 return passwd
281 def emit_passwords(wordlist, options):
282 """ Generate the specified number of passwords and output them. """
283 count = options.count
284 while count > 0:
285 print(generate_xkcdpassword(
286 wordlist,
287 interactive=options.interactive,
288 numwords=options.numwords,
289 acrostic=options.acrostic,
290 delimiter=options.delimiter))
291 count -= 1
294 class XkcdPassArgumentParser(argparse.ArgumentParser):
295 """ Command-line argument parser for this program. """
297 def __init__(self, *args, **kwargs):
298 super(XkcdPassArgumentParser, self).__init__(*args, **kwargs)
300 self._add_arguments()
302 def _add_arguments(self):
303 """ Add the arguments needed for this program. """
304 self.add_argument(
305 "-w", "--wordfile",
306 dest="wordfile", default=None, metavar="WORDFILE",
307 help=(
308 "Specify that the file WORDFILE contains the list"
309 " of valid words from which to generate passphrases."))
310 self.add_argument(
311 "--min",
312 dest="min_length", type=int, default=5, metavar="MIN_LENGTH",
313 help="Generate passphrases containing at least MIN_LENGTH words.")
314 self.add_argument(
315 "--max",
316 dest="max_length", type=int, default=9, metavar="MAX_LENGTH",
317 help="Generate passphrases containing at most MAX_LENGTH words.")
318 self.add_argument(
319 "-n", "--numwords",
320 dest="numwords", type=int, default=6, metavar="NUM_WORDS",
321 help="Generate passphrases containing exactly NUM_WORDS words.")
322 self.add_argument(
323 "-i", "--interactive",
324 action="store_true", dest="interactive", default=False,
325 help=(
326 "Generate and output a passphrase, query the user to"
327 " accept it, and loop until one is accepted."))
328 self.add_argument(
329 "-v", "--valid-chars",
330 dest="valid_chars", default=".", metavar="VALID_CHARS",
331 help=(
332 "Limit passphrases to only include words matching the regex"
333 " pattern VALID_CHARS (e.g. '[a-z]')."))
334 self.add_argument(
335 "-V", "--verbose",
336 action="store_true", dest="verbose", default=False,
337 help="Report various metrics for given options.")
338 self.add_argument(
339 "-a", "--acrostic",
340 dest="acrostic", default=False,
341 help="Generate passphrases with an acrostic matching ACROSTIC.")
342 self.add_argument(
343 "-c", "--count",
344 dest="count", type=int, default=1, metavar="COUNT",
345 help="Generate COUNT passphrases.")
346 self.add_argument(
347 "-d", "--delimiter",
348 dest="delimiter", default=" ", metavar="DELIM",
349 help="Separate words within a passphrase with DELIM.")
350 self.add_argument(
351 "--allow-weak-rng",
352 action="store_true", dest="allow_weak_rng", default=False,
353 help=(
354 "Allow fallback to weak RNG if the "
355 "system does not support cryptographically secure RNG. "
356 "Only use this if you know what you are doing."))
359 def main(argv=None):
360 """ Mainline code for this program. """
362 if argv is None:
363 argv = sys.argv
365 exit_status = 0
367 try:
368 program_name = os.path.basename(argv[0])
369 parser = XkcdPassArgumentParser(prog=program_name)
371 options = parser.parse_args(argv[1:])
372 validate_options(parser, options)
374 my_wordlist = generate_wordlist(
375 wordfile=options.wordfile,
376 min_length=options.min_length,
377 max_length=options.max_length,
378 valid_chars=options.valid_chars)
380 if options.verbose:
381 verbose_reports(
382 len(my_wordlist),
383 options.numwords,
384 options.wordfile)
386 emit_passwords(my_wordlist, options)
388 except SystemExit as exc:
389 exit_status = exc.code
391 return exit_status
394 if __name__ == '__main__':
395 exit_status = main(sys.argv)
396 sys.exit(exit_status)