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