da1a9ea0c9f34b55468a315e76111605f4d697cb
[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         self._quit["text"] = "Quit"
375         self._quit["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)
399         self.user_label["text"] = _ ("Username")
400         self.user_label.grid ()
401
402         self.user_var = StringVar ()
403         self.user_var.set (default_username ())
404         self.user_var.trace ("w", self.update_widgets)
405
406         self.user = Entry (self.info_frame, textvariable = self.user_var,
407                            state = DISABLED)
408         self.balloon.bind_widget (self.user, msg = dedented (_ ("""
409         Username on the local machine (e.g. 'user')
410         """)))
411         self.user.grid (row = 0, column = 1)
412
413         self.host_label = Label (self.info_frame)
414         self.host_label["text"] = _ ("Host Name")
415         self.host_label.grid ()
416
417         self.host_var = StringVar ()
418         self.host_var.set (default_hostname ())
419         self.host_var.trace ("w", self.update_widgets)
420
421         self.host = Entry (self.info_frame, textvariable = self.host_var,
422                            state = DISABLED)
423         self.balloon.bind_widget (self.host, msg = dedented (_ ("""
424         Host name of the local machine (e.g. 'mycomputer')
425         """)))
426         self.host.grid (row = 1, column = 1)
427
428         self.name_label = Label (self.info_frame)
429         self.name_label["text"] = _ ("Full Name")
430         self.name_label.grid ()
431
432         self.name_var = StringVar ()
433         self.name_var.set (default_name ())
434         self.name_var.trace ("w", self.update_widgets)
435
436         self.name = Entry (self.info_frame, textvariable = self.name_var)
437         self.balloon.bind_widget (self.name, msg = dedented (_ ("""
438         Full name as it should appear in the key description (e.g. 'John Doe')
439         """)))
440         self.name.grid (row = 2, column = 1)
441
442         self.email_label = Label (self.info_frame)
443         self.email_label["text"] = _ ("Email address")
444         self.email_label.grid ()
445
446         self.email_var = StringVar ()
447         self.email_var.set (default_email ())
448         self.email_var.trace ("w", self.update_widgets)
449
450         self.email = Entry (self.info_frame, textvariable = self.email_var)
451         self.balloon.bind_widget (self.email, msg = dedented (_ ("""
452         Email address associated with the name (e.g. '<test@example.com>')
453         """)))
454         self.email.grid (row = 3, column = 1)
455
456         self.comment_label = Label (self.info_frame)
457         self.comment_label["text"] = _ ("Comment phrase")
458         self.comment_label.grid ()
459
460         self.comment_var = StringVar ()
461         self.comment_var.set (default_comment ())
462         self.comment_var.trace ("w", self.update_widgets)
463
464         self.comment = Entry (self.info_frame, textvariable = self.comment_var)
465         self.balloon.bind_widget (self.comment, msg = dedented (_ ("""
466         Comment phrase for the GnuPG key, if any (e.g. 'key for 2014')
467         """)))
468         self.comment.grid (row = 4, column = 1)
469
470         self.options_frame = Frame (self)
471         self.options_frame.pack (fill = X)
472
473         self.gnupg_label = Label (self.options_frame)
474         self.gnupg_label["text"] = _ ("Generate GnuPG key")
475         self.gnupg_label.grid ()
476
477         self.gnupg_var = IntVar ()
478         self.gnupg_var.set (1 if self.arguments.gnupg else 0)
479         self.gnupg_var.trace ("w", self.update_widgets)
480
481         self.gnupg = Checkbutton (self.options_frame,
482                                   variable = self.gnupg_var)
483         self.gnupg.grid (row = 0, column = 1)
484
485         self.openssh_label = Label (self.options_frame)
486         self.openssh_label["text"] = _ ("Generate OpenSSH key")
487         self.openssh_label.grid ()
488
489         self.openssh_var = IntVar ()
490         self.openssh_var.set (1 if self.arguments.openssh else 0)
491         self.openssh_var.trace ("w", self.update_widgets)
492
493         self.openssh = Checkbutton (self.options_frame,
494                                     variable = self.openssh_var)
495         self.openssh.grid (row = 1, column = 1)
496
497         self.button_frame = Frame (self)
498         self.button_frame.pack (fill = X)
499
500         self._generate = Button (self.button_frame)
501         self._generate["text"] = _ ("Generate Keys")
502         self._generate["command"] = self.generate
503         self.balloon.bind_widget (
504             self._generate,
505             msg = _ ("Generate the keys as configured above"))
506         self._generate.pack (side = LEFT, fill = Y)
507
508         self._quit = Button (self.button_frame)
509         self._quit["text"] = "Quit"
510         self._quit["command"] = self.quit
511         self.balloon.bind_widget (self._quit,
512                                   msg = _ ("Quit the program immediately"))
513         self._quit.pack (side = LEFT)
514
515         self.update_widgets ()
516
517     def valid_state (self):
518         if not self.openssh_var.get () and not self.gnupg_var.get ():
519             return False
520
521         if gnupg_exists (self.arguments) and openssh_exists (self.arguments):
522             return False
523
524         if not valid_email (self.email_var.get ()):
525             return False
526
527         if not valid_name (self.name_var.get ()):
528             return False
529
530         if not valid_comment (self.comment_var.get ()):
531             return False
532
533         return True
534
535     def update_widgets (self, *args):
536         valid = self.valid_state ()
537
538         self._generate["state"] = NORMAL if valid else DISABLED
539
540         name = self.name_var.get ()
541
542         valid = valid_name (name)
543         self.name["fg"] = "black" if valid else "red"
544         self.name_label["fg"] = "black" if valid else "red"
545
546         email = self.email_var.get ()
547
548         valid = valid_email (email)
549         self.email["fg"] = "black" if valid else "red"
550         self.email_label["fg"] = "black" if valid else "red"
551
552         comment = self.comment_var.get ()
553
554         valid = valid_comment (comment)
555         self.comment["fg"] = "black" if valid else "red"
556         self.comment_label["fg"] = "black" if valid else "red"
557
558         exists = gnupg_exists (self.arguments)
559         self.gnupg["state"] = NORMAL if not exists else DISABLED
560
561         exists = openssh_exists (self.arguments)
562         self.openssh["state"] = NORMAL if not exists else DISABLED
563
564         gnupg_key = name
565         if comment.strip () != "":
566             gnupg_key + " ({}) ".format (comment)
567         gnupg_key += "<{}>".format (email)
568
569         user = self.user_var.get ()
570         host = self.host_var.get ()
571
572         openssh_key = "{}@{}".format (user, host)
573
574         msg = dedented (_ ("""
575         Generate a GnuPG key for '{}' and configure a default setup for it
576         """)).format (gnupg_key)
577
578         self.balloon.bind_widget (self.gnupg, msg = msg)
579         self.balloon.bind_widget (self.gnupg_label, msg = msg)
580
581         msg = dedented (_ ("""
582         Generate an OpenSSH key for '{}' and configure a default setup for it
583         """)).format (openssh_key)
584
585         self.balloon.bind_widget (self.openssh, msg = msg)
586         self.balloon.bind_widget (self.openssh_label, msg = msg)
587
588     def generate_thread (self):
589         stdout = sys.stdout
590
591         try:
592             sys.stdout = self.progress.redirect
593
594             # TODO: capture and show stdout and stderr
595             if self.gnupg_var.get ():
596                 # TODO: make get calls thread-safe
597                 gnupg_setup (self.arguments,
598                              self.name_var.get (),
599                              self.email_var.get (),
600                              self.comment_var.get ())
601                 # TODO: put update into queue
602                 self.update_widgets ()
603
604             if self.openssh_var.get ():
605                 comment = "{}@{}".format (self.user_var.get (),
606                                           self.host_var.get ())
607                 openssh_setup (self.arguments, comment)
608                 # TODO: put update into queue
609                 self.update_widgets ()
610         finally:
611             sys.stdout = stdout
612
613     def _on_idle ():
614         while True:
615             try:
616                 self.progress.redirect.write (self.progress.queue.get (block = False))
617             except Empty:
618                 break
619
620     def generate (self):
621         self.progress = CryptoInstallProgress (self)
622
623         self.bind ("<<Idle>>", self._on_idle)
624
625         thread = threading.Thread (target = self.generate_thread)
626         thread.start ()
627
628
629 def main ():
630     gettext_install ()
631
632     arguments = parse_arguments ()
633
634     if arguments.gui:
635         # TODO: use gtk instead?  would be more consistent with the pinentry style
636         # (assuming it's using gtk)
637         CryptoInstall (arguments).mainloop ()
638     else:
639         if arguments.gnupg:
640             gnupg_setup (arguments)
641
642         if arguments.openssh:
643             openssh_setup (arguments)
644
645
646 if __name__ == "__main__":
647     main ()