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