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