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