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