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