eec72f713201bfcf7e563d569ab480bb8552dfd0
[crypto-install.git] / crypto-install
1 #!/usr/bin/env python
2 # -*- mode: python; coding: utf-8-unix; -*-
3
4
5 import argparse, errno, os, re, readline, subprocess, sys, tempfile, textwrap, threading
6
7
8 if sys.version_info[0] == 2:
9     from Tkinter import *
10     from tkMessageBox import *
11     from Tix import *
12     from ScrolledText import *
13     from Queue import *
14
15     def input_string (prompt=""):
16         return raw_input (prompt)
17 elif sys.version_info[0] > 2:
18     from tkinter import *
19     from tkinter.messagebox import *
20     from tkinter.tix import *
21     from tkinter.scrolledtext import *
22     from queue import *
23
24     def input_string (prompt=""):
25         return input (prompt)
26 else:
27     raise Exception ("Unsupported Python version {}".format (sys.version_info))
28
29
30 def dedented (text):
31     return textwrap.dedent (text).strip ()
32
33
34 def ldedented (text):
35     return textwrap.dedent (text).lstrip ()
36
37
38 def filled (text):
39     return textwrap.fill (dedented (text), width = 72)
40
41
42 def lfilled (text):
43     return textwrap.fill (ldedented (text), width = 72)
44
45
46 def read_input_string (prompt = "", default = ""):
47     if default != "":
48         readline.set_startup_hook (lambda: readline.insert_text (default))
49
50     try:
51         return input_string(prompt)
52     finally:
53         readline.set_startup_hook()
54
55
56 def parse_arguments ():
57     parser = argparse.ArgumentParser ()
58     parser.add_argument (
59         "-v", "--version",
60         dest = "version",
61         action = "version",
62         version = "crypto-install.py version GIT-TAG (GIT-COMMIT/GIT-BRANCH)",
63         help = "Display version.")
64     parser.add_argument (
65         "--no-gui",
66         dest = "gui",
67         action = "store_false",
68         help = "Disable GUI, use text mode.")
69     gnupg_group = parser.add_argument_group (
70         "GnuPG",
71         "Options related to the GnuPG setup.")
72     gnupg_group.add_argument (
73         "--no-gpg",
74         dest = "gnupg",
75         action = "store_false",
76         help = "Disable GnuPG setup.")
77     gnupg_group.add_argument (
78         "--gpg-home",
79         dest = "gnupg_home",
80         default = os.getenv("GNUPGHOME") or "~/.gnupg",
81         metavar = "PATH",
82         help = "Default directory for GnuPG files.")
83     openssh_group = parser.add_argument_group (
84         "OpenSSH",
85         "Options related to the OpenSSH setup.")
86     openssh_group.add_argument (
87         "--no-ssh",
88         dest = "openssh",
89         action = "store_false",
90         help = "Disable OpenSSH setup.")
91     openssh_group.add_argument (
92         "--ssh-home",
93         dest = "openssh_home",
94         default = "~/.ssh",
95         metavar = "PATH",
96         help = "Default directory for OpenSSH files.")
97     return parser.parse_args ()
98
99
100 def ensure_directories (path, mode = 0o777):
101     try:
102         os.makedirs (path, mode)
103     except OSError as exception:
104         if exception.errno != errno.EEXIST:
105             raise
106
107
108 def default_name ():
109     return os.getenv ("FULLNAME")
110
111
112 def default_email ():
113     return os.getenv ("EMAIL")
114
115
116 def default_comment ():
117     return ""
118
119
120 def default_hostname ():
121     return subprocess.check_output ("hostname").strip ()
122
123
124 def default_username ():
125     return os.getenv ("USER")
126
127
128 def valid_email (value):
129     return re.match (".+@.+", value)
130
131
132 def valid_name (value):
133     return value.strip () != ""
134
135
136 def valid_comment (value):
137     return True
138
139
140 def gnupg_exists (arguments):
141     gnupg_home = os.path.expanduser (arguments.gnupg_home)
142     gnupg_secring = os.path.join (gnupg_home, "secring.gpg")
143
144     return os.path.exists (gnupg_secring)
145
146
147 def openssh_exists (arguments):
148     openssh_home = os.path.expanduser (arguments.openssh_home)
149     openssh_config = os.path.join (openssh_home, "config")
150     openssh_key = os.path.join (openssh_home, "id_rsa")
151
152     return os.path.exists (openssh_config) and os.path.exists (openssh_key)
153
154
155 # TODO: verify phrase at least once
156 # TODO: use better labels
157 def input_passphrase (arguments):
158     batch_passphrase = ldedented ("""
159     RESET
160     OPTION ttyname={}
161     OPTION ttytype={}
162     """).format (subprocess.check_output ("tty").strip (),
163                  os.getenv ("TERM"))
164
165     batch_env = dict (os.environ)
166     if arguments.gui:
167         batch_passphrase += ldedented ("""
168         OPTION xauthority={}
169         OPTION display={}
170         """).format (os.getenv ("XAUTHORITY"),
171                      os.getenv ("DISPLAY"))
172     else:
173         del batch_env["DISPLAY"]
174
175     batch_passphrase += \
176         "GET_PASSPHRASE --data --check --qualitybar X X Passphrase X\n"
177
178     passphrase_process = subprocess.Popen (["gpg-agent", "--server"],
179                                            stdin = subprocess.PIPE,
180                                            stdout = subprocess.PIPE,
181                                            stderr = subprocess.PIPE,
182                                            env = batch_env)
183     (stdout, stderr) = passphrase_process.communicate (batch_passphrase.encode ("UTF-8"))
184
185     if passphrase_process.returncode != 0:
186         raise Exception ("Couldn't read passphrase.")
187
188     for line in stdout.splitlines ():
189         if line.decode ("UTF-8").startswith ("D "):
190             return line[2:]
191
192     return ""
193
194
195 def gnupg_setup (arguments, name = None, email = None, comment = None):
196     gnupg_home = os.path.expanduser (arguments.gnupg_home)
197     gnupg_secring = os.path.join (gnupg_home, "secring.gpg")
198
199     if gnupg_exists (arguments):
200         print ("GnuPG secret keyring already exists at '{}'."
201                .format (gnupg_secring))
202         return
203
204     if not arguments.gui:
205         print (filled ("""
206         No default GnuPG key available.  Please enter your information to
207         create a new key."""))
208
209         name = read_input_string ("What is your name (e.g. 'John Doe')? ",
210                                   default_name ())
211
212         email = read_input_string (dedented ("""
213         What is your email address (e.g. 'test@example.com')? """),
214                                    default_email ())
215
216         comment = read_input_string (dedented ("""
217         What is your comment phrase, if any (e.g. 'key for 2014')? """),
218                                      default_comment ())
219
220     if not os.path.exists (gnupg_home):
221         print ("Creating GnuPG directory at '{}'.".format (gnupg_home))
222         ensure_directories (gnupg_home, 0o700)
223
224     with tempfile.NamedTemporaryFile () as tmp:
225         batch_key = ldedented ("""
226         %ask-passphrase
227         Key-Type: DSA
228         Key-Length: 2048
229         Subkey-Type: ELG-E
230         Subkey-Length: 2048
231         Name-Real: {}
232         Name-Email: {}
233         Expire-Date: 0
234         """).format (name, email)
235
236         if comment != "":
237             batch_key += "Name-Comment: {}\n".format (comment)
238
239         tmp.write (batch_key.encode ("UTF-8"))
240         tmp.flush ()
241
242         batch_env = dict (os.environ)
243         if not arguments.gui:
244             del batch_env["DISPLAY"]
245
246         gnupg_process = subprocess.Popen (["gpg2",
247                                            "--homedir", gnupg_home,
248                                            "--batch", "--gen-key", tmp.name],
249                                           stdin = subprocess.PIPE,
250                                           stdout = subprocess.PIPE,
251                                           stderr = subprocess.STDOUT,
252                                           env = batch_env)
253
254         # TODO: argh.  there has to be a better way
255         gnupg_process.stdin.close ()
256         while gnupg_process.poll () is None:
257             sys.stdout.write (gnupg_process.stdout.readline ())
258
259         while True:
260             line = gnupg_process.stdout.readline ()
261             if len (line) == 0:
262                 break
263             sys.stdout.write (line.decode ("UTF-8"))
264
265         if gnupg_process.returncode != 0:
266             raise Exception ("Couldn't create GnuPG key.")
267
268
269 def openssh_setup (arguments, comment = None):
270     openssh_home = os.path.expanduser (arguments.openssh_home)
271     openssh_config = os.path.join (openssh_home, "config")
272
273     if not os.path.exists (openssh_config):
274         print ("Creating OpenSSH directory at '{}'.".format (openssh_home))
275         ensure_directories (openssh_home, 0o700)
276
277         print ("Creating OpenSSH configuration at '{}'."
278                .format (openssh_config))
279         with open (openssh_config, "w") as config:
280             config.write (ldedented ("""
281             ForwardAgent yes
282             ForwardX11 yes
283             """))
284
285     openssh_key = os.path.join (openssh_home, "id_rsa")
286
287     if os.path.exists (openssh_key):
288         print ("OpenSSH key already exists at '{}'.".format (openssh_key))
289         return
290
291     openssh_key_dsa = os.path.join (openssh_home, "id_dsa")
292
293     if os.path.exists (openssh_key_dsa):
294         print ("OpenSSH key already exists at '{}'.".format (openssh_key_dsa))
295         return
296
297     print (filled ("No OpenSSH key available.  Generating new key."))
298
299     if not arguments.gui:
300         comment = "{}@{}".format (default_username (), default_hostname ())
301         comment = read_input_string (ldedented ("""
302         What is your comment phrase (e.g. 'user@mycomputer')? """), comment)
303
304     passphrase = input_passphrase (arguments)
305
306     batch_env = dict (os.environ)
307     if not arguments.gui:
308         del batch_env["DISPLAY"]
309
310     # TODO: is it somehow possible to pass the password on stdin?
311     openssh_process = subprocess.Popen (["ssh-keygen",
312                                          "-P", passphrase,
313                                          "-C", comment,
314                                          "-f", openssh_key],
315                                         stdin = subprocess.PIPE,
316                                         stdout = subprocess.PIPE,
317                                         stderr = subprocess.STDOUT,
318                                         env = batch_env)
319
320     # TODO: argh.  there has to be a better way
321     openssh_process.stdin.close ()
322     while openssh_process.poll () is None:
323         sys.stdout.write (openssh_process.stdout.readline ())
324
325     while True:
326         line = openssh_process.stdout.readline ()
327         if len (line) == 0:
328             break
329         sys.stdout.write (line.decode ("UTF-8"))
330
331     if openssh_process.returncode != 0:
332         raise Exception ("Couldn't create OpenSSH key.")
333
334
335 # http://www.blog.pythonlibrary.org/2014/07/14/tkinter-redirecting-stdout-stderr/
336 # http://www.virtualroadside.com/blog/index.php/2012/11/10/glib-idle_add-for-tkinter-in-python/
337 class RedirectText (object):
338     def __init__ (self, root, widget):
339         self.root = root
340         self.widget = widget
341
342         self.queue = Queue ()
343
344     def write (self, string):
345         self.widget.insert (END, string)
346
347     def enqueue (self, value):
348         self.queue.put (value)
349         self.root.event_generate ("<<Idle>>", when = "tail")
350
351
352 class CryptoInstallProgress (Toplevel):
353     def __init__ (self, parent):
354         Toplevel.__init__ (self, parent)
355
356         self.parent = parent
357
358         self.create_widgets ()
359
360     def create_widgets (self):
361         self.balloon = Balloon (self, initwait = 250)
362
363         self.text = ScrolledText (self)
364         self.text.pack (fill = BOTH, expand = True)
365
366         self.redirect = RedirectText (self.parent, self.text)
367
368         self._quit = Button (self)
369         self._quit["text"] = "Quit"
370         self._quit["command"] = self.quit
371         self.balloon.bind_widget (self._quit,
372                                   msg = "Quit the program immediately")
373         self._quit.pack ()
374
375
376 class CryptoInstall (Tk):
377     def __init__ (self, arguments):
378         Tk.__init__ (self)
379
380         self.arguments = arguments
381
382         self.resizable (width = False, height = False)
383         self.title ("Crypto Install Wizard")
384
385         self.create_widgets ()
386
387     def create_widgets (self):
388         self.balloon = Balloon (self, initwait = 250)
389
390         self.info_frame = Frame (self)
391         self.info_frame.pack (fill = X)
392
393         self.user_label = Label (self.info_frame)
394         self.user_label["text"] = "Username"
395         self.user_label.grid ()
396
397         self.user_var = StringVar ()
398         self.user_var.set (default_username ())
399         self.user_var.trace ("w", self.update_widgets)
400
401         self.user = Entry (self.info_frame, textvariable = self.user_var,
402                            state = DISABLED)
403         self.balloon.bind_widget (self.user, msg = dedented ("""
404         Username on the local machine (e.g. 'user')
405         """))
406         self.user.grid (row = 0, column = 1)
407
408         self.host_label = Label (self.info_frame)
409         self.host_label["text"] = "Host Name"
410         self.host_label.grid ()
411
412         self.host_var = StringVar ()
413         self.host_var.set (default_hostname ())
414         self.host_var.trace ("w", self.update_widgets)
415
416         self.host = Entry (self.info_frame, textvariable = self.host_var,
417                            state = DISABLED)
418         self.balloon.bind_widget (self.host, msg = dedented ("""
419         Host name of the local machine (e.g. 'mycomputer')
420         """))
421         self.host.grid (row = 1, column = 1)
422
423         self.name_label = Label (self.info_frame)
424         self.name_label["text"] = "Full Name"
425         self.name_label.grid ()
426
427         self.name_var = StringVar ()
428         self.name_var.set (default_name ())
429         self.name_var.trace ("w", self.update_widgets)
430
431         self.name = Entry (self.info_frame, textvariable = self.name_var)
432         self.balloon.bind_widget (self.name, msg = dedented ("""
433         Full name as it should appear in the key description (e.g. 'John Doe')
434         """))
435         self.name.grid (row = 2, column = 1)
436
437         self.email_label = Label (self.info_frame)
438         self.email_label["text"] = "Email address"
439         self.email_label.grid ()
440
441         self.email_var = StringVar ()
442         self.email_var.set (default_email ())
443         self.email_var.trace ("w", self.update_widgets)
444
445         self.email = Entry (self.info_frame, textvariable = self.email_var)
446         self.balloon.bind_widget (self.email, msg = dedented ("""
447         Email address associated with the name (e.g. '<test@example.com>')
448         """))
449         self.email.grid (row = 3, column = 1)
450
451         self.comment_label = Label (self.info_frame)
452         self.comment_label["text"] = "Comment phrase"
453         self.comment_label.grid ()
454
455         self.comment_var = StringVar ()
456         self.comment_var.set (default_comment ())
457         self.comment_var.trace ("w", self.update_widgets)
458
459         self.comment = Entry (self.info_frame, textvariable = self.comment_var)
460         self.balloon.bind_widget (self.comment, msg = dedented ("""
461         Comment phrase for the GnuPG key, if any (e.g. 'key for 2014')
462         """))
463         self.comment.grid (row = 4, column = 1)
464
465         self.options_frame = Frame (self)
466         self.options_frame.pack (fill = X)
467
468         self.gnupg_label = Label (self.options_frame)
469         self.gnupg_label["text"] = "Generate GnuPG key"
470         self.gnupg_label.grid ()
471
472         self.gnupg_var = IntVar ()
473         self.gnupg_var.set (1 if self.arguments.gnupg else 0)
474         self.gnupg_var.trace ("w", self.update_widgets)
475
476         self.gnupg = Checkbutton (self.options_frame,
477                                   variable = self.gnupg_var)
478         self.gnupg.grid (row = 0, column = 1)
479
480         self.openssh_label = Label (self.options_frame)
481         self.openssh_label["text"] = "Generate OpenSSH key"
482         self.openssh_label.grid ()
483
484         self.openssh_var = IntVar ()
485         self.openssh_var.set (1 if self.arguments.openssh else 0)
486         self.openssh_var.trace ("w", self.update_widgets)
487
488         self.openssh = Checkbutton (self.options_frame,
489                                     variable = self.openssh_var)
490         self.openssh.grid (row = 1, column = 1)
491
492         self.button_frame = Frame (self)
493         self.button_frame.pack (fill = X)
494
495         self._generate = Button (self.button_frame)
496         self._generate["text"] = "Generate Keys"
497         self._generate["command"] = self.generate
498         self.balloon.bind_widget (
499             self._generate,
500             msg = "Generate the keys as configured above")
501         self._generate.pack (side = LEFT, fill = Y)
502
503         self._quit = Button (self.button_frame)
504         self._quit["text"] = "Quit"
505         self._quit["command"] = self.quit
506         self.balloon.bind_widget (self._quit,
507                                   msg = "Quit the program immediately")
508         self._quit.pack (side = LEFT)
509
510         self.update_widgets ()
511
512     def valid_state (self):
513         if not self.openssh_var.get () and not self.gnupg_var.get ():
514             return False
515
516         if gnupg_exists (self.arguments) and openssh_exists (self.arguments):
517             return False
518
519         if not valid_email (self.email_var.get ()):
520             return False
521
522         if not valid_name (self.name_var.get ()):
523             return False
524
525         if not valid_comment (self.comment_var.get ()):
526             return False
527
528         return True
529
530     def update_widgets (self, *args):
531         valid = self.valid_state ()
532
533         self._generate["state"] = NORMAL if valid else DISABLED
534
535         name = self.name_var.get ()
536
537         valid = valid_name (name)
538         self.name["fg"] = "black" if valid else "red"
539         self.name_label["fg"] = "black" if valid else "red"
540
541         email = self.email_var.get ()
542
543         valid = valid_email (email)
544         self.email["fg"] = "black" if valid else "red"
545         self.email_label["fg"] = "black" if valid else "red"
546
547         comment = self.comment_var.get ()
548
549         valid = valid_comment (comment)
550         self.comment["fg"] = "black" if valid else "red"
551         self.comment_label["fg"] = "black" if valid else "red"
552
553         exists = gnupg_exists (self.arguments)
554         self.gnupg["state"] = NORMAL if not exists else DISABLED
555
556         exists = openssh_exists (self.arguments)
557         self.openssh["state"] = NORMAL if not exists else DISABLED
558
559         gnupg_key = name
560         if comment.strip () != "":
561             gnupg_key + " ({}) ".format (comment)
562         gnupg_key += "<{}>".format (email)
563
564         user = self.user_var.get ()
565         host = self.host_var.get ()
566
567         openssh_key = "{}@{}".format (user, host)
568
569         msg = dedented ("""
570         Generate a GnuPG key for '{}' and configure a default setup for it
571         """).format (gnupg_key)
572
573         self.balloon.bind_widget (self.gnupg, msg = msg)
574         self.balloon.bind_widget (self.gnupg_label, msg = msg)
575
576         msg = dedented ("""
577         Generate an OpenSSH key for '{}' and configure a default setup for it
578         """).format (openssh_key)
579
580         self.balloon.bind_widget (self.openssh, msg = msg)
581         self.balloon.bind_widget (self.openssh_label, msg = msg)
582
583     def generate_thread (self):
584         stdout = sys.stdout
585
586         try:
587             sys.stdout = self.progress.redirect
588
589             # TODO: capture and show stdout and stderr
590             if self.gnupg_var.get ():
591                 # TODO: make get calls thread-safe
592                 gnupg_setup (self.arguments,
593                              self.name_var.get (),
594                              self.email_var.get (),
595                              self.comment_var.get ())
596                 # TODO: put update into queue
597                 self.update_widgets ()
598
599             if self.openssh_var.get ():
600                 comment = "{}@{}".format (self.user_var.get (),
601                                           self.host_var.get ())
602                 openssh_setup (self.arguments, comment)
603                 # TODO: put update into queue
604                 self.update_widgets ()
605         finally:
606             sys.stdout = stdout
607
608     def _on_idle ():
609         while True:
610             try:
611                 self.progress.redirect.write (self.progress.queue.get (block = False))
612             except Empty:
613                 break
614
615     def generate (self):
616         self.progress = CryptoInstallProgress (self)
617
618         self.bind ("<<Idle>>", self._on_idle)
619
620         thread = threading.Thread (target = self.generate_thread)
621         thread.start ()
622
623
624 def main ():
625     arguments = parse_arguments ()
626
627     if arguments.gui:
628         # TODO: use gtk instead?  would be more consistent with the pinentry style
629         # (assuming it's using gtk)
630         CryptoInstall (arguments).mainloop ()
631     else:
632         if arguments.gnupg:
633             gnupg_setup (arguments)
634
635         if arguments.openssh:
636             openssh_setup (arguments)
637
638
639 if __name__ == "__main__":
640     main ()