├── CONTRIBUTING.md ├── LICENSE ├── README.md └── gpg_gener8.py /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | First off, thank you for wanting to contribute! 4 | 5 | fork the project from here: http://github.com/etsy/yubigpgkeyer 6 | 7 | 1. Clone your fork 8 | 2. Hack away 9 | 3. If necessary, rebase your commits into logical chunks, without errors 10 | 4. Push the branch up to GitHub 11 | 5. Send a pull request to the etsy/yubigpgkeyer project. 12 | 13 | We'll do our best to get your changes in. Thank you. 14 | 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015 Etsy, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YubiGPGKeyer 2 | 3 | [Generating RSA keys on Yubikeys is a delight](https://www.yubico.com/2012/12/yubikey-neo-openpgp/) many of us have enjoyed, and it's fine for a single key. Once you start doing more than key, say an organisation's worth, it quickly gets less enjoyable. 4 | 5 | Being able to programmatically generate keys with as little human interaction as possible (there remains some, due to fragility and the real world). It even has JSON output if need be, so you can feed use it as part of another script. 6 | 7 | ## Requirements 8 | 9 | * Python 3. 10 | * pinentry-hax from [pinentry-hax](https://gist.github.com/barn/e3ff508c3032da3ff905) in the same directory. Needed for setting the PIN unattended. 11 | * [ykneomgr](https://developers.yubico.com/libykneomgr/) "brew install ykneomgr" 12 | * [ykpers](https://yubico.github.io/yubikey-personalization/) "brew install ykpers" 13 | * [gnupg2](https://www.gnupg.org/) version 2.0.27 only tested. "brew install gnupg2" 14 | * Some [Yubikey Neo Nanos](https://www.yubico.com/products/yubikey-hardware/yubikey-neo/) 15 | 16 | ## Notes 17 | 18 | Firmware version of the Yubikey is *very* important. The versions that have worked with this are 3.3.7. Earlier have had different PIN requirements, later, well, who knows. This isn't the most reliable or rugged process. 19 | 20 | There's a lot of unplugging and plugging back in involved. 21 | 22 | Also running `gpg2 --card-status` can help kick it, if it can't find the card. Also waiting until the light turns off. 23 | 24 | See also [Ben Hughes' blog on the subject](https://mumble.org.uk/blog/2015/03/17/pining-for-gpg-to-try/). 25 | 26 | ## Usage. 27 | 28 | ``` 29 | usage: gpg_gener8.py [-h] --name "Mr. Etsy" --email "mr_etsy@example.org" 30 | [--json] [--overwrite] [--pin 1234] [--adminpin 12345] 31 | [--newpin 4321] [--newadminpin 54321] [--randomnewpin] 32 | [--randomnewadminpin] [--forcecard neo-nano] 33 | gpg_gener8.py: error: the following arguments are required: --name/-n, --email/-e 34 | ``` 35 | 36 | ## Example 37 | 38 | Run the simple, not unwieldly at all: 39 | 40 | ``` 41 | localtoast% python3 gpg_gener8.py --name 'Isabel Tate' --email 'issy@example.org' --pin 123456 --adminpin 123456 --randomnewpin --randomnewadminpin 42 | ``` 43 | 44 | Which, after some prompting, will output: 45 | 46 | ``` 47 | For name "Isabel Tate", email: issy@example.org 48 | Yubikey serial: 3281265 49 | PIN set to: 793574 50 | Admin PIN set to: 23457830 51 | Public key: 52 | ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== cardno:000202925496 53 | ``` 54 | 55 | There's the JSON output too, if you wish to feed to it in to something else. 56 | 57 | # Contributing 58 | 59 | Please do! See [Contributing](CONTRIBUTING.md) 60 | 61 | # Bugs 62 | 63 | Almost certainly, see the [issue tracker](https://github.com/etsy/yubigpgkeyer/issues) on github. 64 | 65 | # Credits 66 | 67 | Thanks to [ecraven](https://github.com/ecraven) for pinentry-emacs. 68 | 69 | Thanks to [@antifuchs](https://twitter.com/antifuchs) for assisting with battling pinentry and GPG. 70 | -------------------------------------------------------------------------------- /gpg_gener8.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env PYTHONPATH='' python3 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # ----------------------------------------------------------- 5 | # Filename : gpg_gener8.py 6 | # Description : Poorly automate some of generating SSH keys in yubikeys 7 | # Created By : Ben Hughes 8 | # Date Created : 2015-03-16 17:24 9 | # 10 | # License : MIT 11 | # 12 | # Yeah, I probably should use https://github.com/Yubico/python-yubico but I 13 | # didn't notice that existed until half way through. 14 | # 15 | # There's probably a python module for gpg and card support too... Whatever. 16 | # 17 | # Requires the yubikey tools ('ykpers' in brew) and gpg stuff ('gnupg2' and 18 | # 'gpg-agent' in brew) to be installed. Doesn't bother checking for them. 19 | # 20 | # Todo: 21 | # * detect when PINs are locked out 'PIN retry counter : 0 3 3' and act. 22 | # * detect when PINs are wrong too. 23 | # 24 | # (c) Copyright 2015, Etsy all rights reserved. 25 | # ----------------------------------------------------------- 26 | __author__ = "Ben Hughes" 27 | __version__ = "0.1337" 28 | 29 | import re 30 | import os 31 | import sys 32 | import json 33 | import time 34 | import shlex 35 | import shutil 36 | import argparse 37 | from subprocess import Popen, PIPE 38 | from random import randint 39 | 40 | # Check what version we're running on 41 | if sys.version_info < (3, 2): 42 | sys.stdout.write("Sorry, requires Python >3.2, because I'm the worst.\n") 43 | sys.exit(1) 44 | 45 | 46 | def random_len(n): 47 | range_start = 10**(n-1) 48 | range_end = (10**n)-1 49 | return randint(range_start, range_end) 50 | 51 | 52 | class YubiKeyMagic(object): 53 | 54 | def __init__(self, DEBUG=False): 55 | self.keytime = '4y' # Default to 4 years for keys. 56 | self.ipc_file = os.path.expanduser('~/.insecure_pretend_ipc') 57 | 58 | # beat out the argpase dict to attrs 59 | self.assign_vars(vars(self.parseargs())) 60 | 61 | self.get_yubikey_serial() 62 | 63 | # Work out if the card is already configured. 64 | self.card_configured() 65 | 66 | # work out if we've tried to set random pins... 67 | self.assign_pins() 68 | 69 | if self.newpin or self.newadminpin: 70 | self.changepin = True 71 | else: 72 | self.changepin = False 73 | 74 | print("New pin is set to %s, new admin pin is %s, changing: %s" % 75 | (self.newpin, self.newadminpin, self.changepin)) 76 | 77 | def assign_pins(self): 78 | if self.randomnewpin: 79 | self.newpin = random_len(6) 80 | if self.randomnewadminpin: 81 | self.newadminpin = random_len(10) 82 | 83 | def assign_vars(self, a): 84 | """ 85 | Hack out the dict from argparse in to a bunch of accessors for 86 | the class 87 | """ 88 | for k in a.keys(): 89 | setattr(self, k, a[k]) 90 | 91 | def get_yubikey_model(self): 92 | """ 93 | "Bit hack" 94 | look at what version of yubikey is inserted. Assumes ykinfo exists 95 | """ 96 | 97 | if self.forcecard: 98 | self.model = self.forcecard 99 | return self.model 100 | 101 | print("Finding which type of yubikey we have.") 102 | model = os.popen('ykinfo -I').read().rstrip() 103 | if model == 'product_id: 111': 104 | self.model = 'neo' 105 | elif model == 'product_id: 116': 106 | self.model = 'neo-nano' 107 | else: 108 | return None 109 | 110 | return self.model 111 | 112 | def get_yubikey_serial(self): 113 | """ 114 | Get the serial number, so we can find it... 115 | FEEL THE RUBBY! 116 | """ 117 | serial = os.popen('ykinfo -s').read().rstrip() 118 | if 'serial: ' in serial: 119 | self.serial = serial.split(': ')[1] 120 | else: 121 | print('Failed to find serial.') 122 | sys.exit(24) 123 | 124 | def fix_yubikey_mode(self): 125 | """From the ykpersonalize man page: 126 | -m mode 127 | 2 OTP/CCID composite device. 128 | 6 OTP/U2F/CCID composite device. 129 | Add 80 to set MODE_FLAG_EJECT, for example: 81 130 | """ 131 | modes = {'neo': '82', 132 | 'neo-nano': '82'} 133 | 134 | model = self.get_yubikey_model() 135 | 136 | print("Checking it's in the right mode.") 137 | if model not in modes.keys(): 138 | print("Yubikey doesn't do CCID. No dice.") 139 | sys.exit(20) 140 | 141 | curmode = None 142 | while curmode is None: 143 | curmode = os.popen('ykneomgr -m').read().rstrip() 144 | if 'No device found' in curmode: 145 | print("sleeping and trying again. Unplug it & replug it.") 146 | time.sleep(2) 147 | curmode = None 148 | elif modes[model] in curmode: 149 | print('Mode already set, all good.') 150 | return True 151 | else: 152 | print("Mode is currently: %s" % curmode) 153 | 154 | if self.can_overwrite(): 155 | print("Setting mode for yubikey for doing CCID") 156 | 157 | ykpers_cmd = 'ykpersonalize -y -v -m{m}'.format(m=modes[model]) 158 | if os.system(ykpers_cmd) != 0: 159 | print("Failed to change yubikey to do CCID") 160 | sys.exit(24) 161 | else: 162 | print("Not modifying written yubikey") 163 | print("Use --overwrite if you wish to destroy this key.") 164 | sys.exit(23) 165 | 166 | def can_overwrite(self): 167 | """ 168 | return true if it's not configured, or we're okay to overwrite. 169 | Otherwise returns false 170 | """ 171 | if not self.configured: 172 | return True 173 | else: 174 | return self.overwrite 175 | 176 | def make_cmd_string(self, commands): 177 | return "\n".join(commands) + "\n" 178 | 179 | def card_configured(self): 180 | """ Look for: 181 | Signature key ....: 7ED6 6360 7222 6AFC 61EE 26AE 11F3 2D39 9CB7 1542 182 | Encryption key....: 28AF C015 6AB9 0707 D9C3 C23F 5CD2 A26B 7D36 66D6 183 | Authentication key: 9655 FFFC C4A0 F4D3 87EB 498B 4DAF B5C4 7DCA AB87 184 | in the card status output 185 | 186 | return true/false based on whether card is configured or not. 187 | """ 188 | 189 | with Popen(shlex.split('gpg2 --card-status'), stdout=PIPE) as p: 190 | cardstatus = p.stdout.read().decode() 191 | 192 | if p.returncode != 0: 193 | print("Failed to get card status.") 194 | sys.exit(10) 195 | 196 | has_keys = re.compile(r"""(?:Signature|Encryption|Authentication) 197 | \s+ key [\s\.]* : 198 | \s+ [0-9A-F]{4}\s .* """, re.VERBOSE) 199 | matches = has_keys.search(cardstatus) 200 | 201 | self.configured = matches is not None 202 | return self.configured 203 | 204 | def gen_that_key(self, name=None, email=None): 205 | """ 206 | Open a file descriptor to some random FD, gpg2 --card-edit it. 207 | """ 208 | if name is None: 209 | name = self.name 210 | if email is None: 211 | email = self.email 212 | 213 | # This is a fragile mess of blind commands to run against gpg2 214 | # --card-edit. Is there a nicer way to format this? Should I use expect 215 | # (hiss) instead? 216 | mess_of_blind_gpg_commands = { 217 | 'neo': {'configured': ['admin', 'generate', 218 | 'y', self.keytime, 219 | 'y', name, 220 | email, '', 221 | 'O', 'quit'], 222 | 'unconfigured': ['admin', 'generate', 223 | self.keytime, 'y', 224 | name, email, 225 | '', 'O', 226 | 'quit'], }, 227 | 'neo-nano': {'configured': ['admin', 'generate', 228 | 'n', 'y', 229 | self.keytime, 'y', 230 | name, email, 231 | '', 'O', 232 | 'quit'], 233 | 'unconfigured': ['admin', 'generate', 234 | 'n', self.keytime, 235 | 'y', name, 236 | email, '', 237 | 'O', 'quit']}, 238 | } 239 | 240 | # model = self.get_yubikey_model() 241 | 242 | if self.configured: 243 | print("Using the configured version of %s" % self.model) 244 | cmds = mess_of_blind_gpg_commands[self.model]['configured'] 245 | else: 246 | cmds = mess_of_blind_gpg_commands[self.model]['unconfigured'] 247 | 248 | in_fd, out_fd = os.pipe() 249 | cmd = 'gpg2 --command-fd {fd} --card-edit'.format(fd=in_fd) 250 | 251 | # if not self.configured and self.model is 'neo-nano': 252 | self.do_a_pin(self.adminpin, self.pin) 253 | # else: 254 | # self.do_a_pin(self.pin, self.adminpin) 255 | 256 | self.mess_with_pinentry() 257 | 258 | try: 259 | with Popen(shlex.split(cmd), pass_fds=[in_fd]) as p: 260 | os.close(in_fd) # unused in the parent 261 | 262 | with open(out_fd, 'w', encoding='utf-8') as command_fd: 263 | command_fd.write(self.make_cmd_string(cmds)) 264 | 265 | if p.returncode != 0: 266 | print("Failed to generate GPG key?") 267 | sys.exit(10) 268 | 269 | finally: 270 | os.unlink(self.ipc_file) 271 | self.unmess_with_pinentry() 272 | 273 | def do_a_pin(self, oldpin, newpin): 274 | """ 275 | for changing pins, or actually for generating keys (where 276 | oldpin and newpin are actually pin and adminpin) 277 | """ 278 | 279 | ipc_file = self.ipc_file 280 | 281 | # Yup, this is happening. A FILE BASED IPC METHOD. 282 | if os.path.exists(ipc_file): 283 | os.unlink(ipc_file) # safety delete. 284 | 285 | old_umask = os.umask(0o077) # safety umask! 286 | try: 287 | with open(ipc_file, 'w') as f: 288 | f.write('round=0\n') 289 | f.write("oldpin=%s\n" % oldpin) 290 | f.write("newpin=%s\n" % newpin) 291 | finally: 292 | os.umask(old_umask) 293 | 294 | return True 295 | 296 | def change_pin(self, oldpin='123456', newpin='123456', admin=False): 297 | """ 298 | http://bit.ly/18Vvn7b was useful! 299 | https://gist.github.com/barn/425fd3c13c3f501f9d81#file-pinentry-emacs 300 | 301 | So change a pin from something to something else. 302 | 303 | Use a file with: 304 | # round=<0|1|2> 305 | # oldpass=1234 306 | # newpass=4321 307 | 308 | which is then read by the pinentry programme to change the PIN. 309 | 310 | No really, that's what is happening. 311 | """ 312 | 313 | regular_passwd_change = ['passwd', 'quit'] 314 | admin_passwd_change = ['admin', 'passwd', '3', 'Q', 'quit'] 315 | 316 | # This does the pinentry side. Makes a pretend pinentry using 317 | # pinentry-hax. Requires calling mess_with_pinentry() to make gpg use 318 | # that pinentry. 319 | self.do_a_pin(oldpin, newpin) 320 | 321 | try: 322 | in_fd, out_fd = os.pipe() 323 | cmd = 'gpg2 --command-fd {fd} --card-edit'.format(fd=in_fd) 324 | 325 | self.mess_with_pinentry() 326 | 327 | with Popen(shlex.split(cmd), pass_fds=[in_fd], stdin=PIPE) as p: 328 | os.close(in_fd) # unused in the parent 329 | 330 | with open(out_fd, 'w', encoding='utf-8') as command_fd: 331 | if admin: 332 | command_text = admin_passwd_change 333 | else: 334 | command_text = regular_passwd_change 335 | command_fd.write(self.make_cmd_string(command_text)) 336 | finally: 337 | os.unlink(self.ipc_file) 338 | self.unmess_with_pinentry() 339 | 340 | if p.returncode is not 0: 341 | print("Failed to change a password...") 342 | os.exit(21) 343 | 344 | def get_public_key(self): 345 | """ 346 | So this appears to never work with the neo I have unless I 347 | take it out and put it back in. I hope it is fixed on the 348 | neo-nanos. 349 | """ 350 | 351 | print("This is awful, but I can't see there being any other way ):") 352 | input("Press return when you've popped the Yubikey out and back in:") 353 | print("") 354 | 355 | gpg_ssh_cmd = 'gpg-agent --daemon --enable-ssh-support ssh-add -L' 356 | with Popen(shlex.split(gpg_ssh_cmd), 357 | stdout=PIPE, 358 | close_fds=True, 359 | env={'SSH_AUTH_SOCK': '', 360 | 'PATH': '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'} 361 | ) as p: 362 | self.pubkey = p.stdout.read().decode() 363 | # self.pubkey = os.popen(gpg_ssh_cmd).read() 364 | # print("Your public key is:\n%s" % pubkey) 365 | 366 | def mess_with_pinentry(self): 367 | """ 368 | steps are: 369 | write/swap out ~/.gnupg/gpg-agent.conf: 370 | https://gist.github.com/barn/425fd3c13c3f501f9d81 371 | `echo RELOADAGENT | gpg-connect-agent` 372 | then run change password workflow. 373 | swap everything back. 374 | lament. 375 | Thanks to @antifuchs for the help! 376 | """ 377 | 378 | # # Find the pinentry script included with this. 379 | # 380 | # pinentry_script = '~/codes/yubi/scripts/pinentry-hax' 381 | # pinentry_script = os.path.expanduser(pinentry_script) 382 | script_dir = os.path.dirname(os.path.realpath(__file__)) 383 | pinentry_script = os.path.join(script_dir, 'pinentry-hax') 384 | if not os.path.isfile(pinentry_script): 385 | print("Cannot find a pinentry script at %s" % pinentry_script) 386 | sys.exit(27) 387 | 388 | gpg_agent_conf = os.path.expanduser('~/.gnupg/gpg-agent.conf') 389 | 390 | # shutil.move(..) 391 | if os.path.isfile(gpg_agent_conf): 392 | shutil.move(gpg_agent_conf, gpg_agent_conf + '.orig') 393 | 394 | with open(gpg_agent_conf, 'w') as f: 395 | f.write("pinentry-program {piney}".format(piney=pinentry_script)) 396 | 397 | # I could popen, or whatever. 398 | os.system('echo RELOADAGENT | gpg-connect-agent') 399 | 400 | def unmess_with_pinentry(self): 401 | """ 402 | boldly unmess the overwriting and renaming of gpg-agent.conf from 403 | the aforementioned mess_with_pinentry function. 404 | """ 405 | gpg_agent_conf = os.path.expanduser('~/.gnupg/gpg-agent.conf') 406 | os.unlink(gpg_agent_conf) 407 | shutil.move(gpg_agent_conf + '.orig', gpg_agent_conf) 408 | 409 | def parseargs(self): 410 | """ 411 | argparse is nicer than getopt/optparse 412 | 413 | This is a bit messy, but that's argument parsing for you. 414 | """ 415 | 416 | parser = argparse.ArgumentParser(description='Hack me a key.') 417 | 418 | parser.add_argument('--name', '-n', metavar='"Mr. Etsy"', 419 | required=True, help='Name to generate key for') 420 | parser.add_argument('--email', '-e', metavar='"mr_etsy@example.org"', 421 | required=True, help='Email address for the key') 422 | 423 | parser.add_argument('--json', '-j', dest='json', 424 | action='store_true', help='output just in JSON') 425 | 426 | parser.add_argument('--overwrite', '-o', dest='overwrite', 427 | action='store_true', default=False, 428 | help='Overwrite an existing key') 429 | 430 | # We can have random or fixed pins, not both. 431 | group_pin = parser.add_mutually_exclusive_group() 432 | group_adminpin = parser.add_mutually_exclusive_group() 433 | 434 | parser.add_argument('--pin', metavar='1234', type=int, 435 | default='123456', help='current PIN') 436 | parser.add_argument('--adminpin', metavar='12345', type=int, 437 | default='12345678', help='current admin PIN') 438 | 439 | group_pin.add_argument('--newpin', metavar='4321', type=int, 440 | help='desired PIN') 441 | group_adminpin.add_argument('--newadminpin', metavar='54321', type=int, 442 | help='desired admin PIN') 443 | 444 | group_pin.add_argument('--randomnewpin', dest='randomnewpin', 445 | action='store_true') 446 | group_adminpin.add_argument('--randomnewadminpin', 447 | dest='randomnewadminpin', 448 | action='store_true') 449 | 450 | parser.add_argument('--forcecard', '-f', metavar='neo-nano', 451 | help='Override key type') 452 | return parser.parse_args() 453 | 454 | def print_results(self): 455 | if self.json: 456 | d = {'name': self.name, 'email': self.email, 457 | 'pin': self.newpin, 'adminpin': self.newadminpin, 458 | 'serial': self.serial, 'pubkey': self.pubkey} 459 | print(json.dumps(d)) 460 | else: 461 | # Just badly format all the output with some prints 462 | print('For name "{name}", email: {email}'.format(name=self.name, 463 | email=self.email)) 464 | print('Yubikey serial: {serial}'.format(serial=self.serial)) 465 | if self.newpin is not None: 466 | print('PIN set to: {pin}'.format(pin=self.newpin)) 467 | if self.newadminpin is not None: 468 | print('Admin PIN set to: {pin}'.format(pin=self.newadminpin)) 469 | print('Public key:\n{pubkey}'.format(pubkey=self.pubkey)) 470 | 471 | def generate(self): 472 | self.fix_yubikey_mode() 473 | 474 | print("Sleeping for 5...") 475 | time.sleep(5) 476 | 477 | if self.changepin: 478 | print("Changing the PIN") 479 | self.change_pin(self.pin, self.newpin) 480 | print("Changing the Admin PIN") 481 | self.change_pin(self.adminpin, self.newadminpin, admin=True) 482 | 483 | # Now we've set them, assign them back. 484 | self.adminpin = self.newadminpin 485 | self.pin = self.newpin 486 | 487 | print("Generating key...") 488 | self.gen_that_key() 489 | 490 | self.get_public_key() 491 | self.print_results() 492 | 493 | 494 | if __name__ == "__main__": 495 | y = YubiKeyMagic() 496 | y.generate() 497 | --------------------------------------------------------------------------------