Import source for “1.8.2” from upstream tarball.
[debian_xkcdpass.git] / xkcdpass / xkcd_password.py
blob32efa1ce43249d96a41d62a1db38e8d4ba9d9da8
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.exists(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.exists(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("^%s{%i,%i}$" % (valid_chars, min_length, max_length))
115 # At this point wordfile is set
116 wordfile = os.path.expanduser(wordfile) # just to be sure
117 wlf = open(wordfile)
119 for line in wlf:
120 thisword = line.strip()
121 if regexp.match(thisword) is not None:
122 words.append(thisword)
124 wlf.close()
126 return list(set(words)) # deduplicate, just in case
129 def wordlist_to_worddict(wordlist):
131 Takes a wordlist and returns a dictionary keyed by the first letter of
132 the words. Used for acrostic pass phrase generation
135 worddict = {}
137 # Maybe should be a defaultdict, but this reduces dependencies
138 for word in wordlist:
139 try:
140 worddict[word[0]].append(word)
141 except KeyError:
142 worddict[word[0]] = [word, ]
144 return worddict
147 def verbose_reports(length, numwords, wordfile):
149 Report entropy metrics based on word list and requested password size"
152 bits = math.log(length, 2)
154 print("The supplied word list is located at %s."
155 % os.path.abspath(wordfile))
157 if int(bits) == bits:
158 print("Your word list contains %i words, or 2^%i words."
159 % (length, bits))
160 else:
161 print("Your word list contains %i words, or 2^%0.2f words."
162 % (length, bits))
164 print("A %i word password from this list will have roughly "
165 "%i (%0.2f * %i) bits of entropy," %
166 (numwords, int(bits * numwords), bits, numwords)),
167 print("assuming truly random word selection.")
170 def find_acrostic(acrostic, worddict):
172 Constrain choice of words to those beginning with the letters of the
173 given word (acrostic).
174 Second argument is a dictionary (output of wordlist_to_worddict)
177 words = []
179 for letter in acrostic:
180 try:
181 words.append(rng().choice(worddict[letter]))
182 except KeyError:
183 sys.stderr.write("No words found starting with " + letter + "\n")
184 sys.exit(1)
185 return words
188 def choose_words(wordlist, numwords):
189 s = []
190 for i in xrange(numwords):
191 s.append(rng().choice(wordlist))
192 return s
195 def generate_xkcdpassword(wordlist,
196 numwords=6,
197 interactive=False,
198 acrostic=False,
199 delimiter=" "):
201 Generate an XKCD-style password from the words in wordlist.
204 passwd = False
206 # generate the worddict if we are looking for acrostics
207 if acrostic:
208 worddict = wordlist_to_worddict(wordlist)
210 # useful if driving the logic from other code
211 if not interactive:
212 if not acrostic:
213 passwd = delimiter.join(choose_words(wordlist, numwords))
214 else:
215 passwd = delimiter.join(find_acrostic(acrostic, worddict))
217 return passwd
219 # else, interactive session
220 if not acrostic:
221 custom_n_words = raw_input("Enter number of words (default 6): ")
223 if custom_n_words:
224 numwords = int(custom_n_words)
225 else:
226 numwords = len(acrostic)
228 accepted = "n"
230 while accepted.lower() not in ["y", "yes"]:
231 if not acrostic:
232 passwd = delimiter.join(choose_words(wordlist, numwords))
233 else:
234 passwd = delimiter.join(find_acrostic(acrostic, worddict))
235 print("Generated: ", passwd)
236 accepted = raw_input("Accept? [yN] ")
238 return passwd
241 def emit_passwords(wordlist, options):
242 """ Generate the specified number of passwords and output them. """
243 count = options.count
244 while count > 0:
245 print(generate_xkcdpassword(
246 wordlist,
247 interactive=options.interactive,
248 numwords=options.numwords,
249 acrostic=options.acrostic,
250 delimiter=options.delimiter))
251 count -= 1
254 class XkcdPassArgumentParser(argparse.ArgumentParser):
255 """ Command-line argument parser for this program. """
257 def __init__(self, *args, **kwargs):
258 super(XkcdPassArgumentParser, self).__init__(*args, **kwargs)
260 self._add_arguments()
262 def _add_arguments(self):
263 """ Add the arguments needed for this program. """
264 self.add_argument(
265 "-w", "--wordfile",
266 dest="wordfile", default=None, metavar="WORDFILE",
267 help=(
268 "Specify that the file WORDFILE contains the list"
269 " of valid words from which to generate passphrases."))
270 self.add_argument(
271 "--min",
272 dest="min_length", type=int, default=5, metavar="MIN_LENGTH",
273 help="Generate passphrases containing at least MIN_LENGTH words.")
274 self.add_argument(
275 "--max",
276 dest="max_length", type=int, default=9, metavar="MAX_LENGTH",
277 help="Generate passphrases containing at most MAX_LENGTH words.")
278 self.add_argument(
279 "-n", "--numwords",
280 dest="numwords", type=int, default=6, metavar="NUM_WORDS",
281 help="Generate passphrases containing exactly NUM_WORDS words.")
282 self.add_argument(
283 "-i", "--interactive",
284 action="store_true", dest="interactive", default=False,
285 help=(
286 "Generate and output a passphrase, query the user to"
287 " accept it, and loop until one is accepted."))
288 self.add_argument(
289 "-v", "--valid-chars",
290 dest="valid_chars", default=".", metavar="VALID_CHARS",
291 help=(
292 "Limit passphrases to only include words matching the regex"
293 " pattern VALID_CHARS (e.g. '[a-z]')."))
294 self.add_argument(
295 "-V", "--verbose",
296 action="store_true", dest="verbose", default=False,
297 help="Report various metrics for given options.")
298 self.add_argument(
299 "-a", "--acrostic",
300 dest="acrostic", default=False,
301 help="Generate passphrases with an acrostic matching ACROSTIC.")
302 self.add_argument(
303 "-c", "--count",
304 dest="count", type=int, default=1, metavar="COUNT",
305 help="Generate COUNT passphrases.")
306 self.add_argument(
307 "-d", "--delimiter",
308 dest="delimiter", default=" ", metavar="DELIM",
309 help="Separate words within a passphrase with DELIM.")
310 self.add_argument(
311 "--allow-weak-rng",
312 action="store_true", dest="allow_weak_rng", default=False,
313 help=(
314 "Allow fallback to weak RNG if the "
315 "system does not support cryptographically secure RNG. "
316 "Only use this if you know what you are doing."))
319 def main(argv=None):
320 """ Mainline code for this program. """
322 if argv is None:
323 argv = sys.argv
325 exit_status = 0
327 try:
328 program_name = os.path.basename(argv[0])
329 parser = XkcdPassArgumentParser(prog=program_name)
331 options = parser.parse_args(argv[1:])
332 validate_options(parser, options)
334 my_wordlist = generate_wordlist(
335 wordfile=options.wordfile,
336 min_length=options.min_length,
337 max_length=options.max_length,
338 valid_chars=options.valid_chars)
340 if options.verbose:
341 verbose_reports(
342 len(my_wordlist),
343 options.numwords,
344 options.wordfile)
346 emit_passwords(my_wordlist, options)
348 except SystemExit as exc:
349 exit_status = exc.code
351 return exit_status
354 if __name__ == '__main__':
355 exit_status = main(sys.argv)
356 sys.exit(exit_status)