2 # -*- mode: python; coding: utf-8; -*-
5 import argparse, errno, gettext, itertools, locale, os, re, readline, \
6 subprocess, sys, tempfile, textwrap, threading
9 if sys.version_info[0] == 2:
11 from tkMessageBox import *
13 from ScrolledText import *
18 def input_string (prompt=""):
19 return raw_input (prompt)
22 def gettext_install (*args, **kwargs):
23 gettext.install (*args, unicode = True, **kwargs)
24 elif sys.version_info[0] > 2:
26 from tkinter.messagebox import *
27 from tkinter.tix import *
28 from tkinter.scrolledtext import *
29 from tkinter.ttk import *
33 def input_string (prompt=""):
37 gettext_install = gettext.install
39 raise Exception ("Unsupported Python version {}".format (sys.version_info))
43 return textwrap.dedent (text).strip ()
47 return textwrap.dedent (text).lstrip ()
51 return textwrap.fill (dedented (text), width = 72)
55 return textwrap.fill (ldedented (text), width = 72)
58 def read_input_string (prompt = "", default = ""):
60 readline.set_startup_hook (lambda: readline.insert_text (default))
63 return input_string(prompt)
65 readline.set_startup_hook()
68 def parse_arguments ():
69 parser = argparse.ArgumentParser ()
74 version = "crypto-install version GIT-TAG (GIT-COMMIT/GIT-BRANCH)",
75 help = _ ("Display version."))
79 action = "store_false",
80 help = _ ("Disable GUI, use text interface."))
81 gnupg_group = parser.add_argument_group (
83 _ ("Options related to the GnuPG setup."))
84 gnupg_group.add_argument (
87 action = "store_false",
88 help = _ ("Disable GnuPG setup."))
89 gnupg_group.add_argument (
92 default = os.getenv("GNUPGHOME") or "~/.gnupg",
94 help = _ ("Default directory for GnuPG files."))
95 openssh_group = parser.add_argument_group (
97 _ ("Options related to the OpenSSH setup."))
98 openssh_group.add_argument (
101 action = "store_false",
102 help = _ ("Disable OpenSSH setup."))
103 openssh_group.add_argument (
105 dest = "openssh_home",
107 metavar = _ ("PATH"),
108 help = _ ("Default directory for OpenSSH files."))
109 return parser.parse_args ()
112 def ensure_directories (path, mode = 0o777):
114 os.makedirs (path, mode)
115 except OSError as exception:
116 if exception.errno != errno.EEXIST:
121 return os.getenv ("FULLNAME")
124 def default_email ():
125 return os.getenv ("EMAIL")
128 def default_comment ():
132 def default_hostname ():
133 return subprocess.check_output ("hostname").strip ()
136 def default_username ():
137 return os.getenv ("USER")
140 def valid_email (value):
141 return re.match (".+@.+", value)
144 def valid_name (value):
145 return value.strip () != ""
148 def valid_user (value):
149 return value.strip () != ""
152 def valid_host (value):
153 return value.strip () != ""
156 def valid_comment (value):
160 def gnupg_exists (arguments):
161 gnupg_home = os.path.expanduser (arguments.gnupg_home)
162 gnupg_secring = os.path.join (gnupg_home, "secring.gpg")
164 return os.path.exists (gnupg_secring)
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")
172 return os.path.exists (openssh_config) and os.path.exists (openssh_key)
176 return string.replace ("+", "++").replace (" ", "+")
179 def input_passphrase (arguments):
180 batch_passphrase = ldedented ("""
184 """).format (subprocess.check_output ("tty").strip (),
189 batch_env = dict (os.environ)
191 batch_passphrase += ldedented ("""
194 """).format (os.getenv ("XAUTHORITY"),
195 os.getenv ("DISPLAY"))
198 del batch_env["DISPLAY"]
200 passphrase_process = subprocess.Popen (["gpg-agent", "--server"],
201 stdin = subprocess.PIPE,
202 stdout = subprocess.PIPE,
203 stderr = subprocess.PIPE,
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.")
211 passphrase_process.stdin.write (batch_passphrase.encode ("UTF-8"))
213 for i in range (expected_oks):
214 line = passphrase_process.stdout.readline ().decode ("UTF-8")
216 raise Exception ("Couldn't read expected OK.")
218 error, prompt, description = "", _ ("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")
227 passphrase_process.stdin.write (batch_passphrase.encode ("UTF-8"))
229 line = passphrase_process.stdout.readline ().decode ("UTF-8")
232 error = _ ("Empty passphrase")
235 if line.startswith ("D "):
236 passphrase = line[2:-1]
238 if len (passphrase) < 8:
239 error = _ ("Passphrase too short")
240 description = _ ("Passphrase has to have at least 8 characters.")
245 if line.startswith ("ERR 83886179"):
246 raise Exception ("Operation cancelled.")
248 raise Exception ("Unexpected response.")
250 passphrase_process.stdin.close ()
251 passphrase_process.stdout.close ()
252 passphrase_process.stderr.close ()
254 passphrase_process.wait ()
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 ())
264 line = process.stdout.readline ()
267 sys.stdout.write (line.decode ("UTF-8"))
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")
274 if gnupg_exists (arguments):
275 print (_ ("GnuPG secret keyring already exists at '{}'.")
276 .format (gnupg_secring))
279 if not arguments.gui:
280 print (filled (_ ("""
281 No default GnuPG key available. Please enter your information to
282 create a new key.""")))
284 name = read_input_string (_ ("What is your name (e.g. 'John Doe')? "),
287 email = read_input_string (dedented (_ ("""
288 What is your email address (e.g. 'test@example.com')? """)),
291 comment = read_input_string (dedented (_ ("""
292 What is your comment phrase, if any (e.g. 'key for 2014')? """)),
295 if not os.path.exists (gnupg_home):
296 print (_ ("Creating GnuPG directory at '{}'.").format (gnupg_home))
297 ensure_directories (gnupg_home, 0o700)
299 with tempfile.NamedTemporaryFile () as tmp:
300 batch_key = ldedented ("""
309 """).format (name, email)
312 batch_key += "Name-Comment: {}\n".format (comment)
314 tmp.write (batch_key.encode ("UTF-8"))
317 batch_env = dict (os.environ)
318 if not arguments.gui:
319 del batch_env["DISPLAY"]
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,
329 redirect_to_stdout (gnupg_process)
331 if gnupg_process.returncode != 0:
332 raise Exception ("Couldn't create GnuPG key.")
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")
339 if not os.path.exists (openssh_config):
340 print (_ ("Creating OpenSSH directory at '{}'.").format (openssh_home))
341 ensure_directories (openssh_home, 0o700)
343 print (_ ("Creating OpenSSH configuration at '{}'.")
344 .format (openssh_config))
345 with open (openssh_config, "w") as config:
346 config.write (ldedented ("""
351 openssh_key = os.path.join (openssh_home, "id_rsa")
353 if os.path.exists (openssh_key):
354 print (_ ("OpenSSH key already exists at '{}'.").format (openssh_key))
357 openssh_key_dsa = os.path.join (openssh_home, "id_dsa")
359 if os.path.exists (openssh_key_dsa):
360 print (_ ("OpenSSH key already exists at '{}'.").format (openssh_key_dsa))
363 print (filled (_ ("No OpenSSH key available. Generating new key.")))
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)
370 passphrase = input_passphrase (arguments)
372 batch_env = dict (os.environ)
373 if not arguments.gui:
374 del batch_env["DISPLAY"]
376 # TODO: is it somehow possible to pass the password on stdin?
377 openssh_process = subprocess.Popen (["ssh-keygen",
381 stdin = subprocess.PIPE,
382 stdout = subprocess.PIPE,
383 stderr = subprocess.STDOUT,
386 redirect_to_stdout (openssh_process)
388 if openssh_process.returncode != 0:
389 raise Exception ("Couldn't create OpenSSH key.")
393 return NORMAL if value else DISABLED
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):
403 self.queue = Queue ()
405 def write (self, string):
406 self.widget.insert (END, string)
408 def enqueue (self, value):
409 self.queue.put (value)
410 self.root.event_generate ("<<Idle>>", when = "tail")
413 class CryptoInstallProgress (Toplevel):
414 def __init__ (self, parent):
415 Toplevel.__init__ (self, parent)
419 self.create_widgets ()
421 def create_widgets (self):
422 self.balloon = Balloon (self, initwait = 250)
424 self.text = ScrolledText (self)
425 self.text.pack (fill = BOTH, expand = True)
427 self.redirect = RedirectText (self.parent, self.text)
429 self._quit = Button (self,
431 command = self.maybe_quit)
432 self.balloon.bind_widget (self._quit,
433 msg = _ ("Quit the program immediately"))
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"))
442 def maybe_quit (self):
443 (self.quit if self.parent.state () != "normal" else self.destroy) ()
446 class CryptoInstall (Tk):
447 def __init__ (self, arguments):
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")
455 self.arguments = arguments
457 self.resizable (width = False, height = False)
458 self.title (_ ("Crypto Install Wizard"))
462 self.create_widgets ()
464 def create_widgets (self):
467 self.balloon = Balloon (self, initwait = 250)
469 self.info_frame = Frame (self)
470 self.info_frame.pack (fill = X)
472 msg = dedented (_ ("""
473 Username on the local machine (e.g. 'user')
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 ()
479 self.user_var = StringVar (self, default_username ())
480 self.user_var.trace ("w", self.update_widgets)
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)
486 self.fields["user"] = [self.user_var, valid_user,
487 self.user, self.user_label]
489 msg = dedented (_ ("""
490 Host name of the local machine (e.g. 'mycomputer')
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 ()
496 self.host_var = StringVar (self, default_hostname ())
497 self.host_var.trace ("w", self.update_widgets)
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)
503 self.fields["host"] = [self.host_var, valid_host,
504 self.host, self.host_label]
506 msg = dedented (_ ("""
507 Full name as it should appear in the key description (e.g. 'John Doe')
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 ()
513 self.name_var = StringVar (self, default_name ())
514 self.name_var.trace ("w", self.update_widgets)
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)
520 self.fields["name"] = [self.name_var, valid_name,
521 self.name, self.name_label]
523 msg = dedented (_ ("""
524 Email address associated with the name (e.g. '<test@example.com>')
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 ()
530 self.email_var = StringVar (self, default_email ())
531 self.email_var.trace ("w", self.update_widgets)
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)
537 self.fields["email"] = [self.email_var, valid_email,
538 self.email, self.email_label]
540 msg = dedented (_ ("""
541 Comment phrase for the GnuPG key, if any (e.g. 'key for 2014')
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 ()
547 self.comment_var = StringVar (self, default_comment ())
548 self.comment_var.trace ("w", self.update_widgets)
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)
554 self.fields["comment"] = [self.comment_var, valid_comment,
555 self.comment, self.comment_label]
557 self.options_frame = Frame (self)
558 self.options_frame.pack (fill = X)
560 self.gnupg_label = Label (self.options_frame,
561 text = _ ("Generate GnuPG key"))
562 self.gnupg_label.grid ()
564 self.gnupg_var = IntVar (self, 1 if self.arguments.gnupg else 0)
565 self.gnupg_var.trace ("w", self.update_widgets)
567 self.gnupg = Checkbutton (self.options_frame,
568 variable = self.gnupg_var)
569 self.gnupg.grid (row = 0, column = 1)
571 self.openssh_label = Label (self.options_frame,
572 text = _ ("Generate OpenSSH key"))
573 self.openssh_label.grid ()
575 self.openssh_var = IntVar (self, 1 if self.arguments.openssh else 0)
576 self.openssh_var.trace ("w", self.update_widgets)
578 self.openssh = Checkbutton (self.options_frame,
579 variable = self.openssh_var)
580 self.openssh.grid (row = 1, column = 1)
582 self.button_frame = Frame (self)
583 self.button_frame.pack (fill = X)
585 self._generate = Button (self.button_frame, text = _ ("Generate Keys"),
586 command = self.generate)
587 self.balloon.bind_widget (
589 msg = _ ("Generate the keys as configured above"))
590 self._generate.pack (side = LEFT, fill = Y)
592 self._quit = Button (self.button_frame, text = _ ("Quit"),
594 self.balloon.bind_widget (self._quit,
595 msg = _ ("Quit the program immediately"))
596 self._quit.pack (side = LEFT)
598 self.update_widgets ()
600 def valid_state (self):
601 if not self.openssh_var.get () and not self.gnupg_var.get ():
604 if gnupg_exists (self.arguments) and openssh_exists (self.arguments):
607 if not valid_name (self.user_var.get ()):
610 if not valid_host (self.host_var.get ()):
613 if not valid_email (self.email_var.get ()):
616 if not valid_name (self.name_var.get ()):
619 if not valid_comment (self.comment_var.get ()):
624 def update_field (self, name):
625 field = self.fields[name]
627 valid = field[1] (field[0].get ())
629 field[2]["style"] = "" if valid else "Invalid.TEntry"
630 field[3]["style"] = "" if valid else "Invalid.TLabel"
632 def update_widgets (self, *args):
633 self._generate["state"] = _state (self.valid_state ())
635 for field in ["user", "host", "name", "email", "comment"]:
636 self.update_field (field)
638 self.gnupg["state"] = _state (not gnupg_exists (self.arguments))
639 self.openssh["state"] = _state (not openssh_exists (self.arguments))
641 gnupg_key = self.name_var.get ().strip ()
642 comment = self.comment_var.get ().strip ()
644 gnupg_key + " ({}) ".format (comment)
645 gnupg_key += "<{}>".format (self.email_var.get ().strip ())
647 user = self.user_var.get ().strip ()
648 host = self.host_var.get ().strip ()
650 openssh_key = "{}@{}".format (user, host)
652 msg = dedented (_ ("""
653 Generate a GnuPG key for '{}' and configure a default setup for it
654 """)).format (gnupg_key)
656 self.balloon.bind_widget (self.gnupg, msg = msg)
657 self.balloon.bind_widget (self.gnupg_label, msg = msg)
659 msg = dedented (_ ("""
660 Generate an OpenSSH key for '{}' and configure a default setup for it
661 """)).format (openssh_key)
663 self.balloon.bind_widget (self.openssh, msg = msg)
664 self.balloon.bind_widget (self.openssh_label, msg = msg)
666 def generate_thread (self, gnupg, openssh, name, email, comment, user,
671 sys.stdout = self.progress.redirect
674 gnupg_setup (self.arguments, name, email, comment)
675 # TODO: put update into queue
676 self.update_widgets ()
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:
686 sys.stdout.write (exception)
687 sys.stdout.write ("\n")
691 # TODO: put update into queue
692 self.progress.update_widgets ()
698 message = self.progress.queue.get (block = False)
699 self.progress.redirect.write (message)
706 if not self.progress or self.progress.winfo_exists () == 0:
707 self.progress = CryptoInstallProgress (self)
708 self.progress.text.delete ("0.0", "end")
710 self.bind ("<<Idle>>", self._on_idle)
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 ()))
724 locale.setlocale (locale.LC_ALL, "")
726 gettext_install ("crypto-install", localedir = os.getenv ("TEXTDOMAINDIR"))
728 arguments = parse_arguments ()
731 # TODO: use gtk instead? would be more consistent with the pinentry style
732 # (assuming it's using gtk)
733 CryptoInstall (arguments).mainloop ()
736 gnupg_setup (arguments)
738 if arguments.openssh:
739 openssh_setup (arguments)
742 if __name__ == "__main__":