From ec7ac1a6447c67e0e649c693a0e830ee2dc237e8 Mon Sep 17 00:00:00 2001 From: Jonathan DeMasi Date: Wed, 23 Jun 2021 09:56:25 -0600 Subject: add yubikey helper --- yubaws/README.md | 16 ++++++ yubaws/yubaws.py | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 yubaws/README.md create mode 100755 yubaws/yubaws.py diff --git a/yubaws/README.md b/yubaws/README.md new file mode 100644 index 0000000..f3ae1e2 --- /dev/null +++ b/yubaws/README.md @@ -0,0 +1,16 @@ +# YubiKey AWS Helper +This utility will help you configure your YubiKey as a virutal MFA device to use in AWS and refresh your local session token to interact with the API using MFA where required. Requires `ykman` to be in your PATH prior to running, as well as boto3 to be available. **Note: when setting up the YubiKey in the AWS console, you do NOT use it as a u2f device! We're leveraging the otp functionality baked into the YubiKey 4/5 series keys to instead generate OTP codes. At this time, u2f is NOT supported for generating session tokens programmatically.** + +## Usage +I recommend creating bash or zsh aliases for these functions - use whatever makes sense to you + +`yubaws.py configure` will help you setup a new MFA device on the YubiKey and in AWS + +`yubaws.py session` will get you a new session token associated with the MFA device for use with boto or awscli. Automatically updates your `~/.aws/credentials` + +`yubaws.py otp` will print an MFA code for logging into the AWS console, so you don't have to memorize the command to use `ykman` + +## Known Issues +* The calls to subprocess are due to an issue with using the native Yubico Python API and macOS at the time of writing +* If you already have a file called `.mfa_device` in your `.aws` config dir, it's going to get overwritten with any new devices created using `yubaws.py configure` +* Unexpected things may happen if you have more than 1 yubikey inserted while using this utility (read: it's untested, but it might be fine?) diff --git a/yubaws/yubaws.py b/yubaws/yubaws.py new file mode 100755 index 0000000..7370ce5 --- /dev/null +++ b/yubaws/yubaws.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import time +import os +from os.path import expanduser, exists +import boto3 +import configparser + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["configure", "session", "otp"]) + args = parser.parse_args() + return args + + +""" +Finds the name of a preconfigured MFA device +""" + + +def get_mfa_device(): + home = expanduser("~") + mfa_device_file = home+"/.aws/.mfa_device" + if not exists(mfa_device_file): + print("You must configure an mfa device prior to using this function") + exit(3) + f = open(mfa_device_file, "r") + mfa_device = f.read().strip("\n") + f.close() + return mfa_device + + +def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + +""" +Checks if a binary is in the PATH +and returns the full path if it is +""" + + +def which(program): + fpath, fname = os.path.split(program) + if fpath: + if is_exe(program): + return program + else: + for path in os.environ["PATH"].split(os.pathsep): + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + + return None + + +def read_aws_config(file): + config = configparser.ConfigParser() + config.read(file) + return config + + +def get_session_token(): + home = expanduser("~") + credentials_file = home+"/.aws/credentials" + orig_credentials_file = home+"/.aws/credentials.orig" + if not exists(orig_credentials_file): + print("Backing up your original credentials file") + config = read_aws_config(credentials_file) + with open(orig_credentials_file, 'w') as configfile: + config.write(configfile) + proc = subprocess.Popen( + ['ykman', 'oath', 'accounts', 'code', get_mfa_device()], stdout=subprocess.PIPE) + token = str(proc.communicate()[0].decode('utf-8').split()[-1]) + config = read_aws_config(credentials_file) + orig_config = read_aws_config(orig_credentials_file) + client = boto3.client('sts', aws_access_key_id=orig_config['default']['aws_access_key_id'], + aws_secret_access_key=orig_config['default']['aws_secret_access_key']) + response = client.get_session_token( + DurationSeconds=28800, SerialNumber=get_mfa_device(), TokenCode=token) + config['default']['aws_access_key_id'] = response['Credentials']['AccessKeyId'] + config['default']['aws_secret_access_key'] = response['Credentials']['SecretAccessKey'] + config['default']['aws_session_token'] = response['Credentials']['SessionToken'] + with open(credentials_file, 'w') as configfile: + config.write(configfile) + return + + +""" +Used to configure a new yubikey as a virtual MFA +device for AWS. Produces an error if more than one yubikey is present +or if the yubikey doesn't support oath! +""" + + +def configure_new_device(): + f = filter(None, subprocess.run( + ['ykman', 'list'], capture_output=True).stdout.decode('utf-8').split("\n")) + keys = list(f) + if len(keys) != 1: + print("You must have exactly one yubikey inserted to configure a new virtual mfa device") + exit(2) + account_number = input("Enter your AWS account number: ") + iam_username = input("Enter your IAM username: ") + secret_key = input("Enter the secret key provided by AWS: ") + oath_account_string = "arn:aws:iam::{account_number}:mfa/{iam_username}".format( + account_number=account_number, iam_username=iam_username) + subprocess.run(['ykman', 'oath', 'accounts', 'add', + '-t', oath_account_string, secret_key]) + print() + print("We're going to generate a few OTPs now to pass back to AWS, please press your YubiKey when prompted") + x = 0 + while x < 3: + print() + subprocess.run(['ykman', 'oath', 'accounts', + 'code', oath_account_string]) + time.sleep(10) + x = x + 1 + print() + print("You should have at least two subsequent time based codes, go enter them in the AWS management console") + print("To get an OTP on a one-off basis run 'ykman oath accounts code {oath_account_string}'".format( + oath_account_string=oath_account_string)) + home = expanduser("~") + f = open(home+"/.aws/.mfa_device", "w") + f.write(oath_account_string) + f.close() + return + + +def get_otp(): + proc = subprocess.run( + ['ykman', 'oath', 'accounts', 'code', get_mfa_device()]) + return + + +def main(): + args = parse_args() + ykman = which('ykman') + if ykman is None: + print("You must have 'ykman' in your path prior to using this script") + exit(1) + if args.action == "configure": + configure_new_device() + elif args.action == "otp": + get_otp() + elif args.action == "session": + get_session_token() + return + + +if __name__ == '__main__': + main() -- cgit v1.2.3