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