├── tests ├── __init__.py └── test_agent_client.py ├── requirements.txt ├── MANIFEST.in ├── .travis.yml ├── ssh_ca_example.conf ├── .gitignore ├── setup.py ├── CHANGELOG.md ├── LICENSE ├── ssh_ca ├── agent_client.py ├── utils.py ├── s3.py └── __init__.py ├── scripts ├── get_cert ├── sign_host_key └── sign_key └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include .gitignore 4 | include ssh_ca_example.conf 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | 6 | install: 7 | - pip install --use-mirrors pep8 pyflakes 8 | 9 | before_script: 10 | - if [[ $TRAVIS_PYTHON_VERSION == 3* ]]; then 2to3 -n -w --no-diffs ssh_ca; fi 11 | - pep8 --version 12 | - pep8 . 13 | - pyflakes . 14 | 15 | script: 16 | - python setup.py test 17 | -------------------------------------------------------------------------------- /ssh_ca_example.conf: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | # Put the remote users you intend to use this key on here 3 | # Can also be specified per environment 4 | principals = ec2-user,ubuntu 5 | 6 | [ssh-ca-s3] 7 | region = us-west-1 8 | bucket = your-ssh-config-bucket 9 | 10 | [stage] 11 | private_key = ~/.ssh/ssh_ca_stage 12 | 13 | [prod] 14 | private_key = ~/.ssh/ssh_ca_prod 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Vim 38 | *.sw* 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import glob 2 | from setuptools import setup 3 | 4 | setup( 5 | name='ssh-ca', 6 | version='0.3.2', 7 | description="SSH CA utilities", 8 | author="Bob Van Zant", 9 | author_email="bob@veznat.com", 10 | maintainer="Mark Peek", 11 | maintainer_email="mark@peek.org", 12 | url="https://github.com/cloudtools/ssh-ca", 13 | license="New BSD license", 14 | packages=['ssh_ca'], 15 | scripts=glob.glob('scripts/*'), 16 | use_2to3=True, 17 | test_suite="tests", 18 | ) 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.2 (2015-03-20) 2 | - Work around re-adding cert bug on osx 3 | - allow http/https in public key path 4 | - issuing future certificates 5 | - can override default 2 hour window for cert downloads from S3 6 | - definition of principals per environment in config 7 | 8 | ## 0.3.1 (2014-04-17) 9 | - remove the private key first if we already have a cert loaded prior to 10 | adding it to the agent 11 | 12 | ## 0.3.0 (2014-04-07) 13 | - Support specifying the principals in use 14 | - Add functionality for signing host keys 15 | - Add testing via travis-ci 16 | 17 | ## 0.2.0 (2014-02-25) 18 | - Add environment-specific public keys 19 | - Run ssh-add as part of get cert so openssh will detect the new cert 20 | - For auditing, require that a reason be specified when signing 21 | 22 | ## 0.1.0 (2014-02-04) 23 | - First PyPI release 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2014, Bob Van Zant 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 16 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 17 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 18 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 20 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 21 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 22 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 23 | POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /tests/test_agent_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import struct 4 | import unittest 5 | from ssh_ca import agent_client 6 | 7 | 8 | class TestAgentSocketValidation(unittest.TestCase): 9 | 10 | def test_nonexistent_explicit_path(self): 11 | self.assertRaises( 12 | agent_client.SshClientFailure, 13 | agent_client.Client, 14 | '/radio/flyer/inchworm/agent.sock' 15 | ) 16 | 17 | def test_nonexistent_env_path(self): 18 | # don't shoot me for setting an environment variable in a test. I hate 19 | # myself for it. 20 | old_env = os.getenv('SSH_AUTH_SOCK') 21 | try: 22 | os.environ['SSH_AUTH_SOCK'] = '/eflite/alpha/450/sockeroony.sock' 23 | self.assertRaises( 24 | agent_client.SshClientFailure, 25 | agent_client.Client, 26 | ) 27 | finally: 28 | if old_env is not None: 29 | os.environ['SSH_AUTH_SOCK'] = old_env 30 | 31 | 32 | class TestAgentBuffer(unittest.TestCase): 33 | def test_nominal(self): 34 | # This is a pretty stupid test. But it does touch all of the code in 35 | # the class and it verifies that everything we shoved in there actually 36 | # ended up in the serialized string somewhere. Though it may be in the 37 | # wrong place or not actually correct. Better than nothing? 38 | buf = agent_client.SshAgentBuffer() 39 | buf.append_byte(93) 40 | buf.append_uint32(12394) 41 | buf.append_bytestring(base64.b64decode('AAAA')) 42 | results = buf.serialize() 43 | 44 | self.assertIn(bytes([93]), results) 45 | self.assertIn(struct.pack('>I', 12394), results) 46 | self.assertIn(b'\x00\x00\x00', results) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /ssh_ca/agent_client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import socket 4 | import struct 5 | 6 | SSH_AGENT_FAILURE = 5 7 | SSH_AGENT_SUCCESS = 6 8 | SSH_AGENTC_REMOVE_RSA_IDENTITY = 18 9 | 10 | 11 | class SshClientFailure(Exception): 12 | pass 13 | 14 | 15 | class SshAgentBuffer(object): 16 | def __init__(self): 17 | self.parts = [] 18 | 19 | def append_byte(self, byte): 20 | self.parts.append(bytes([byte])) 21 | 22 | def append_uint32(self, number): 23 | self.parts.append(struct.pack('>I', number)) 24 | 25 | def append_bytestring(self, string): 26 | self.append_uint32(len(string)) 27 | self.parts.append(string) 28 | 29 | def serialize(self): 30 | resultant_buffer = b''.join(self.parts) 31 | return struct.pack('>I', len(resultant_buffer)) + resultant_buffer 32 | 33 | 34 | class Client(object): 35 | def __init__(self, ssh_agent_sock_path=None): 36 | self.connection = None 37 | 38 | self.ssh_agent_sock_path = ssh_agent_sock_path 39 | if self.ssh_agent_sock_path is None: 40 | self.ssh_agent_sock_path = os.getenv('SSH_AUTH_SOCK') 41 | self.validate_socket_path(self.ssh_agent_sock_path) 42 | 43 | def validate_socket_path(self, socket_path): 44 | if not self.ssh_agent_sock_path: 45 | raise SshClientFailure( 46 | 'SSH agent path not set in $SSH_AUTH_SOCK. Is it running?') 47 | 48 | if not os.path.exists(self.ssh_agent_sock_path): 49 | raise SshClientFailure( 50 | "$SSH_AUTH_SOCK set to '%s' but doesn't exist?" % ( 51 | self.ssh_agent_sock_path,)) 52 | 53 | def connect(self): 54 | self.connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 55 | self.connection.connect(self.ssh_agent_sock_path) 56 | 57 | def _send_msg(self, msg): 58 | self.connection.sendall(msg) 59 | 60 | def _recv_exact(self, count): 61 | result_buffer = '' 62 | while len(result_buffer) < count: 63 | result_buffer += self.connection.recv(count - len(result_buffer)) 64 | return result_buffer 65 | 66 | def _recv_msg(self): 67 | response_size = self._recv_exact(4) 68 | response_size = struct.unpack('>I', response_size)[0] 69 | 70 | response = self._recv_exact(response_size) 71 | return response 72 | 73 | def _recv_response_code(self): 74 | response = self._recv_msg() 75 | if len(response) != 1: 76 | raise ValueError('Unexpected response length from server') 77 | return struct.unpack('B', response)[0] 78 | 79 | def remove_key(self, pubkey): 80 | remove_msg = SshAgentBuffer() 81 | remove_msg.append_byte(SSH_AGENTC_REMOVE_RSA_IDENTITY) 82 | remove_msg.append_bytestring(bytes(base64.b64decode(pubkey))) 83 | self._send_msg(remove_msg.serialize()) 84 | if self._recv_response_code() != SSH_AGENT_SUCCESS: 85 | raise SshClientFailure('Unable to remove key.') 86 | -------------------------------------------------------------------------------- /ssh_ca/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import unittest 4 | 5 | 6 | def convert_relative_time(time_string): 7 | """Takes a single +XXXX[smhdw] string and converts to seconds""" 8 | last_char = time_string[-1] 9 | user_value = time_string[0:-1] 10 | if last_char == 's': 11 | seconds = int(user_value) 12 | elif last_char == 'm': 13 | seconds = (int(user_value) * 60) 14 | elif last_char == 'h': 15 | seconds = (int(user_value) * 60 * 60) 16 | elif last_char == 'd': 17 | seconds = (int(user_value) * 60 * 60 * 24) 18 | elif last_char == 'w': 19 | seconds = (int(user_value) * 60 * 60 * 24 * 7) 20 | else: 21 | sys.stderr.write("Invalid time format: %s. " 22 | "Missing s/m/h/d/w qualifier\n" % (time_string,)) 23 | sys.exit(-1) 24 | return seconds 25 | 26 | 27 | def parse_time(time_string, reference_time=int(time.time())): 28 | """Parses a time in YYYYMMDDHHMMSS or +XXXX[smhdw][...] 29 | 30 | Returns epoch time. Just like ssk-keygen, we allow complex 31 | expressions like +5d7h37m for 5 days, 7 hours, 37 minutes. 32 | 33 | reference_time should be the epoch time used for calculating 34 | the time when using +XXXX[smhdw][...], defaults to now. 35 | """ 36 | 37 | seconds = 0 38 | if time_string[0] == '+' or time_string[0] == '-': 39 | # parse relative expressions 40 | sign = None 41 | number = '' 42 | factor = 's' 43 | for c in time_string: 44 | if not sign: 45 | sign = c 46 | elif c.isdigit(): 47 | number = number + c 48 | else: 49 | factor = c 50 | seconds += convert_relative_time( 51 | "%s%s%s" % (sign, number, factor)) 52 | number = '' 53 | factor = 's' 54 | 55 | # per ssh-keygen, if specifing seconds, then the 's' is not required 56 | if len(number) > 0: 57 | seconds += convert_relative_time("%s%ss" % (sign, number)) 58 | 59 | epoch = seconds + reference_time 60 | 61 | else: 62 | # parse YYYYMMDDHHMMSS 63 | struct_time = time.strptime(time_string, "%Y%m%d%H%M%S") 64 | epoch = int(time.mktime(struct_time)) 65 | 66 | return epoch 67 | 68 | 69 | def epoch2timefmt(epoch): 70 | """Converts epoch time to YYYYMMDDHHMMSS for ssh-keygen 71 | 72 | ssh-keygen accepts YYYYMMDDHHMMSS in the current TZ but doesn't 73 | understand DST so it will add an hour for you. :-/ 74 | """ 75 | struct_time = time.localtime(epoch) 76 | if struct_time.tm_isdst == 1: 77 | struct_time = time.localtime(epoch - 3600) 78 | return time.strftime("%Y%m%d%H%M%S", struct_time) 79 | 80 | 81 | class ParseTimeTests(unittest.TestCase): 82 | def setUp(self): 83 | self.now = int(time.time()) 84 | 85 | def test_one_week(self): 86 | one_week = parse_time("+1w", self.now) 87 | one_week_check = self.now + (60 * 60 * 24 * 7) 88 | self.assertEqual(one_week_check, one_week) 89 | 90 | def test_one_day(self): 91 | one_day = parse_time("+1d3h15s", self.now) 92 | one_day_check = self.now + (60 * 60 * 27) + 15 93 | self.assertEqual(one_day_check, one_day) 94 | 95 | def test_two_thirty(self): 96 | two_thirty = parse_time("+2h30m", self.now) 97 | two_thirty_check = self.now + (60 * 60 * 2.5) 98 | self.assertEqual(two_thirty_check, two_thirty) 99 | 100 | def test_epoch2timefmt(self): 101 | struct_time = time.localtime(self.now) 102 | offset = 0 103 | if struct_time.tm_isdst == 1: 104 | offset = 3600 105 | 106 | longtime = epoch2timefmt(self.now + offset) 107 | rightnow = parse_time(longtime) 108 | self.assertEqual(rightnow, self.now) 109 | 110 | if __name__ == '__main__': 111 | unittest.main() 112 | -------------------------------------------------------------------------------- /ssh_ca/s3.py: -------------------------------------------------------------------------------- 1 | import boto.s3 2 | import datetime 3 | import json 4 | 5 | from boto.exception import S3ResponseError 6 | 7 | import ssh_ca 8 | 9 | 10 | class S3Authority(ssh_ca.Authority): 11 | def __init__(self, config, ssh_ca_section, ca_key): 12 | super(S3Authority, self).__init__(ca_key) 13 | 14 | try: 15 | # Get a valid S3 bucket 16 | bucket = ssh_ca.get_config_value( 17 | config, ssh_ca_section, 'bucket', required=True) 18 | 19 | # Get a valid AWS region 20 | region = ssh_ca.get_config_value( 21 | config, ssh_ca_section, 'region', required=True) 22 | 23 | self.s3_conn = boto.s3.connect_to_region(region) 24 | self.ssh_bucket = self.s3_conn.get_bucket(bucket) 25 | except S3ResponseError, e: 26 | if e.code == "AccessDenied": 27 | raise ssh_ca.SSHCAInvalidConfiguration("Access denied to S3") 28 | raise 29 | 30 | def increment_serial_number(self): 31 | k = self.ssh_bucket.get_key('serial') 32 | if k is None: 33 | k = self.ssh_bucket.new_key('serial') 34 | last_serial = 0 35 | else: 36 | last_serial = int(k.get_contents_as_string()) 37 | new_serial = last_serial + 1 38 | k.set_contents_from_string( 39 | str(int(new_serial)), 40 | headers={'Content-Type': 'text/json'} 41 | ) 42 | return new_serial 43 | 44 | def get_public_key(self, username, environment): 45 | key_names_to_try = [ 46 | 'keys/{username}?environment={environment}', 47 | 'keys/{username}' 48 | ] 49 | for key_name_template in key_names_to_try: 50 | key_name = key_name_template.format( 51 | username=username, 52 | environment=environment, 53 | ) 54 | key = self.ssh_bucket.get_key(key_name) 55 | if key is not None: 56 | return key.get_contents_as_string() 57 | else: 58 | return None 59 | 60 | def upload_public_key(self, username, key_contents): 61 | k = self.ssh_bucket.new_key('keys/%s' % (username,)) 62 | k.set_contents_from_string(key_contents, replace=True) 63 | 64 | def upload_public_key_cert(self, username, env, cert_contents, 65 | expires=7200): 66 | k = self.ssh_bucket.new_key('certs/%s-%s-cert.pub' % (username, env)) 67 | k.set_contents_from_string( 68 | cert_contents, 69 | headers={'Content-Type': 'text/plain'}, 70 | replace=True, 71 | ) 72 | return k.generate_url(expires) 73 | 74 | def make_host_audit_log(self, serial, valid_for, ca_key_filename, 75 | reason, hostnames): 76 | audit_info = { 77 | 'valid_for': valid_for, 78 | 'access_key': self.s3_conn.access_key, 79 | 'ca_key_filename': ca_key_filename, 80 | 'reason': reason, 81 | 'hostnames': hostnames, 82 | } 83 | return self.drop_audit_blob(serial, audit_info) 84 | 85 | def make_audit_log(self, serial, starts_in, expires_in, username, 86 | ca_key_filename, reason, principals): 87 | audit_info = { 88 | 'username': username, 89 | 'valid_for': starts_in + ':' + expires_in, 90 | 'access_key': self.s3_conn.access_key, 91 | 'ca_key_filename': ca_key_filename, 92 | 'reason': reason, 93 | 'principals': principals, 94 | } 95 | return self.drop_audit_blob(serial, audit_info) 96 | 97 | def drop_audit_blob(self, serial, blob): 98 | k = self.ssh_bucket.new_key('audit_log/%d.json' % (serial,)) 99 | 100 | timestamp = datetime.datetime.strftime( 101 | datetime.datetime.utcnow(), '%Y-%m-%d-%H:%M:%S.%f') 102 | blob['timestamp'] = timestamp 103 | 104 | k.set_contents_from_string(json.dumps(blob)) 105 | -------------------------------------------------------------------------------- /ssh_ca/__init__.py: -------------------------------------------------------------------------------- 1 | import ConfigParser 2 | import os 3 | import subprocess 4 | import time 5 | 6 | 7 | __version__ = "0.3.2" 8 | 9 | 10 | class SSHCAException(Exception): 11 | pass 12 | 13 | 14 | class SSHCAInvalidConfiguration(SSHCAException): 15 | pass 16 | 17 | 18 | def get_config_value(config, section, name, required=False): 19 | if config: 20 | try: 21 | return config.get(section, name) 22 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): 23 | if required: 24 | raise SSHCAInvalidConfiguration( 25 | "option '%s' is required in section '%s'" % 26 | (name, section)) 27 | pass 28 | return None 29 | 30 | 31 | class Authority(object): 32 | def __init__(self, ca_key): 33 | self.ca_key = ca_key 34 | 35 | def get_public_key(self, username, environment): 36 | pass 37 | 38 | def increment_serial_number(self): 39 | pass 40 | 41 | def make_audit_log(self, serial, valid_from, valid_for, username, 42 | ca_key_filename, reason, principals): 43 | pass 44 | 45 | def make_host_audit_log(self, serial, valid_for, ca_key_filename, 46 | reason, hostnames): 47 | pass 48 | 49 | def upload_public_key(self, username, public_path): 50 | pass 51 | 52 | def get_host_rsa_key(self, hostname): 53 | """Gets 's public rsa key and returns it.""" 54 | host_pub_key = subprocess.check_output([ 55 | 'ssh', hostname, 56 | 'cat', '/etc/ssh/ssh_host_rsa_key.pub' 57 | ]) 58 | if not host_pub_key.startswith('ssh-rsa'): 59 | raise ValueError('Unable to get host public key: %s' % ( 60 | host_pub_key,)) 61 | 62 | return host_pub_key 63 | 64 | def upload_host_rsa_cert(self, hostname, cert): 65 | """Puts into ssh_host_rsa_key-cert.pub on """ 66 | subprocess.check_output([ 67 | 'ssh', '-t', hostname, 68 | 'echo "%s" | sudo tee /etc/ssh/ssh_host_rsa_key-cert.pub' % (cert,) 69 | ]) 70 | host_cert_line = "HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub" 71 | subprocess.check_output([ 72 | 'ssh', '-t', hostname, 73 | 'echo "%s" | sudo tee -a /etc/ssh/sshd_config' % (host_cert_line,) 74 | ]) 75 | time.sleep(1) 76 | subprocess.check_output([ 77 | 'ssh', '-t', hostname, 78 | 'sudo service sshd restart' 79 | ]) 80 | 81 | def sign_public_host_key(self, public_key_filename, expires_in, 82 | hostnames, reason, key_id): 83 | serial = self.increment_serial_number() 84 | 85 | subprocess.check_output([ 86 | 'ssh-keygen', 87 | '-h', 88 | '-z', str(serial), 89 | '-s', self.ca_key, 90 | '-I', key_id, 91 | '-V', expires_in, 92 | '-n', ','.join(hostnames), 93 | public_key_filename] 94 | ) 95 | self.make_host_audit_log( 96 | serial, expires_in, self.ca_key, reason, hostnames) 97 | 98 | return self.get_cert_contents(public_key_filename) 99 | 100 | def sign_public_user_key(self, public_key_filename, username, starts_in, 101 | expires_in, reason, principals): 102 | serial = self.increment_serial_number() 103 | 104 | subprocess.check_output([ 105 | 'ssh-keygen', 106 | '-z', str(serial), 107 | '-s', self.ca_key, 108 | '-I', username, 109 | '-V', starts_in + ':' + expires_in, 110 | '-n', ','.join(principals), 111 | public_key_filename] 112 | ) 113 | 114 | self.make_audit_log( 115 | serial, starts_in, expires_in, username, self.ca_key, reason, 116 | principals) 117 | 118 | return self.get_cert_contents(public_key_filename) 119 | 120 | def get_cert_contents(self, public_key_filename): 121 | if public_key_filename.endswith('.pub'): 122 | public_key_filename = public_key_filename[:-4] 123 | cert_filename = public_key_filename + '-cert.pub' 124 | with open(cert_filename, 'r') as f: 125 | cert_contents = f.read() 126 | os.remove(cert_filename) 127 | return cert_contents 128 | -------------------------------------------------------------------------------- /scripts/get_cert: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Download a given SSH certificate and put it in the right place. 3 | 4 | Downloads the certificate specified on argv and then searches through the 5 | user's .ssh directory looking for a matching private key. Puts the certificate 6 | next to that one. 7 | """ 8 | import datetime 9 | import os 10 | import shutil 11 | import subprocess 12 | import sys 13 | import tempfile 14 | import urllib 15 | 16 | import ssh_ca.agent_client 17 | 18 | 19 | class CertMetadata(object): 20 | def __init__(self): 21 | self.public_key_fingerprint = None 22 | self.valid_for_seconds = None 23 | 24 | 25 | def download_cert_to_tempfile(url): 26 | resp = urllib.urlopen(url) 27 | if resp.code > 299 or resp.code < 200: 28 | print "Bad response code: HTTP/%d" % (resp.code,) 29 | print resp.read() 30 | sys.exit(1) 31 | 32 | temp_file = tempfile.NamedTemporaryFile(delete=False) 33 | with temp_file.file: 34 | temp_file.write(resp.read()) 35 | return temp_file.name 36 | 37 | 38 | def get_cert_metadata(cert_path): 39 | proc = subprocess.Popen(['/usr/bin/ssh-keygen', '-L', '-f', cert_path], 40 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 41 | 42 | if proc.stderr.read().find('is not a public key') != -1: 43 | print "Invalid signed ssh certificate file: %s" % (cert_path,) 44 | sys.exit(1) 45 | 46 | metadata = CertMetadata() 47 | for line in proc.stdout.readlines(): 48 | if 'Public key:' in line: 49 | fingerprint = line[line.find('RSA-CERT') + 9:] 50 | fingerprint = fingerprint.strip() 51 | metadata.public_key_fingerprint = fingerprint 52 | if 'Valid:' in line: 53 | expire_time = line[line.find(' to ') + 4:].strip() 54 | expire_dt = datetime.datetime.strptime(expire_time, 55 | '%Y-%m-%dT%H:%M:%S') 56 | now_dt = datetime.datetime.now() 57 | delta = expire_dt - now_dt 58 | valid_for_seconds = delta.seconds 59 | metadata.valid_for_seconds = valid_for_seconds 60 | 61 | return metadata 62 | 63 | 64 | def find_private_key_for_public_key(pub_fingerprint): 65 | ssh_dir = os.getenv('HOME') + '/.ssh' 66 | for filename in os.listdir(ssh_dir): 67 | key_filename = ssh_dir + '/' + filename 68 | if key_filename.endswith('pub'): 69 | continue 70 | proc = subprocess.Popen( 71 | ['/usr/bin/ssh-keygen', '-l', '-f', key_filename], 72 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 73 | for line in proc.stdout.readlines(): 74 | if pub_fingerprint in line: 75 | return key_filename 76 | 77 | 78 | def move_cert_into_place(cert_path, private_key_filename): 79 | new_cert_filename = private_key_filename + '-cert.pub' 80 | shutil.move(cert_path, new_cert_filename) 81 | 82 | 83 | def re_add_identity(private_key_filename, valid_for_seconds): 84 | public_filename = private_key_filename + '.pub' 85 | if os.path.exists(public_filename): 86 | agent_client = ssh_ca.agent_client.Client() 87 | try: 88 | agent_client.connect() 89 | except ssh_ca.agent_client.SshClientFailure: 90 | print "Unable to find SSH agent, things probably aren't working." 91 | 92 | with open(public_filename, 'r') as f: 93 | pub_key = f.read().strip().split()[1] 94 | try: 95 | agent_client.remove_key(pub_key) 96 | except ssh_ca.agent_client.SshClientFailure: 97 | print 'Unable to delete existing key, this is probably benign' 98 | 99 | subprocess.check_output([ 100 | '/usr/bin/ssh-add', '-t', '%d' % (valid_for_seconds,), 101 | private_key_filename]) 102 | 103 | 104 | if __name__ == '__main__': 105 | if len(sys.argv) != 2: 106 | print 'Usage: %s ' % (sys.argv[0],) 107 | sys.exit(1) 108 | 109 | cert_filename = download_cert_to_tempfile(sys.argv[1]) 110 | cert_metadata = get_cert_metadata(cert_filename) 111 | key_fingerprint = cert_metadata.public_key_fingerprint 112 | private_key_filename = find_private_key_for_public_key(key_fingerprint) 113 | if not private_key_filename: 114 | print 'Unable to find private key matching certificate.' 115 | sys.exit(1) 116 | 117 | move_cert_into_place(cert_filename, private_key_filename) 118 | re_add_identity(private_key_filename, cert_metadata.valid_for_seconds) 119 | -------------------------------------------------------------------------------- /scripts/sign_host_key: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Sign a machine's host public key. 4 | 5 | This script is used to sign a host's SSH public key using a certificate 6 | authority's private key. The signed public key can be used to verify that a 7 | machine is one of your own and very likely the machine you actually want to 8 | talk to. 9 | 10 | For example, imagine a server that is launched by a single user and later 11 | accessed by dozens of engineers. The first engineer may be able (and may 12 | actually) verify the host key fingerprint. However, users 2 through n probably 13 | just type yes when prompted by SSH to verify the fingerprint. 14 | 15 | Instead the user setting up the server can sign the host key of the host and 16 | then all users in an organization can trust the certificate authority that 17 | signed the host key. When a new user SSHs to a new host so long as the 18 | certificate is valid the user will not be prompted to type yes. 19 | 20 | The final output of this script is an S3 URL containing the host's signed 21 | certificate. The admin needs to take this URL and download the file it points 22 | at. The downloaded file should be named exactly like their host SSH key but 23 | with the suffix "-cert.pub". 24 | 25 | For example, if the host key is /etc/ssh/ssh_host_rsa_key.pub do something 26 | like: 27 | 28 | curl > /etc/ssh/ssh_host_rsa_key-cert.pub 29 | 30 | sshd isn't configured out of the box to look for certs so you need to edit your 31 | /etc/ssh/sshd_config and add a line like this one: 32 | 33 | HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub 34 | 35 | Now you can send a HUP to the sshd parent process. I typically do a ps ax | 36 | grep sshd and then kill -HUP the lowest numbered pid (or whichever one appears 37 | most likely to be the parent). 38 | """ 39 | import argparse 40 | import ConfigParser 41 | import os 42 | import sys 43 | import tempfile 44 | 45 | from contextlib import closing 46 | 47 | import ssh_ca 48 | import ssh_ca.s3 49 | 50 | 51 | if __name__ == '__main__': 52 | default_authority = os.getenv('SSH_CA_AUTHORITY', 's3') 53 | default_config = os.path.expanduser( 54 | os.getenv('SSH_CA_CONFIG', '~/.ssh_ca/config')) 55 | 56 | parser = argparse.ArgumentParser(__doc__) 57 | parser.add_argument( 58 | '-a', '--authority', dest='authority', default=default_authority, 59 | help='Pick one: s3') 60 | parser.add_argument( 61 | '-c', '--config-file', default=default_config, 62 | help='The configuration file to use. Can also be ' 63 | 'specified in the SSH_CA_CONFIG environment ' 64 | 'variable. Default: %(default)s') 65 | parser.add_argument( 66 | '-e', '--environment', required=True, help='Environment name') 67 | parser.add_argument( 68 | '--hostname', action='append', required=True, dest='hostnames', 69 | help='A principal (hostname) that clients can verify. ' 70 | 'This should match what the user types for "ssh ') 71 | parser.add_argument( 72 | '-t', '--expires-in', default='+104w', 73 | help='Expires in. A relative time like +1w. Or YYYYMMDDHHMMSS. ' 74 | 'Default: %(default)s', 75 | ) 76 | args = parser.parse_args() 77 | 78 | environment = args.environment 79 | 80 | ssh_ca_section = 'ssh-ca-' + args.authority 81 | 82 | config = None 83 | if args.config_file: 84 | config = ConfigParser.ConfigParser() 85 | config.read(args.config_file) 86 | 87 | # Get a valid CA key file 88 | ca_key = ssh_ca.get_config_value(config, environment, 'private_key') 89 | if ca_key: 90 | ca_key = os.path.expanduser(ca_key) 91 | else: 92 | ca_key = os.path.expanduser('~/.ssh/ssh_ca_%s' % (environment,)) 93 | if not os.path.isfile(ca_key): 94 | print 'CA key file %s does not exist.' % (ca_key,) 95 | sys.exit(1) 96 | 97 | try: 98 | ca = ssh_ca.s3.S3Authority(config, ssh_ca_section, ca_key) 99 | except ssh_ca.SSHCAInvalidConfiguration, e: 100 | print 'Issue with creating CA: %s' % e.message 101 | sys.exit(1) 102 | 103 | reason = 'New host cert for %r' % (args.hostnames,) 104 | 105 | public_key_contents = ca.get_host_rsa_key(args.hostnames[0]) 106 | (fd, public_path) = tempfile.mkstemp() 107 | with closing(os.fdopen(fd, 'w')) as f: 108 | f.write(public_key_contents) 109 | 110 | cert_contents = ca.sign_public_host_key( 111 | public_path, args.expires_in, args.hostnames, 112 | reason, args.hostnames[0] 113 | ) 114 | 115 | ca.upload_host_rsa_cert(args.hostnames[0], cert_contents) 116 | -------------------------------------------------------------------------------- /scripts/sign_key: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Sign a user's SSH public key. 4 | 5 | This script is used to sign a user's SSH public key using a certificate 6 | authority's private key. The signed public key can be presented along with the 7 | user's private key to get access to servers that trust the CA. 8 | 9 | The final output of this script is an S3 URL containing the user's signed 10 | certificate. The user needs to take this URL and download the file it points 11 | at. This URL should be used with the get_cert script. 12 | 13 | """ 14 | 15 | import argparse 16 | import ConfigParser 17 | import os 18 | import sys 19 | import tempfile 20 | import time 21 | import urlparse 22 | import urllib 23 | 24 | from contextlib import closing 25 | 26 | import ssh_ca 27 | import ssh_ca.s3 28 | from ssh_ca.utils import parse_time, epoch2timefmt 29 | 30 | from boto.exception import NoAuthHandlerFound 31 | 32 | 33 | def get_public_key(path_or_url): 34 | parsed = urlparse.urlparse(path_or_url) 35 | fd = None 36 | try: 37 | if parsed.scheme in ('http', 'https'): 38 | fd = urllib.urlopen(path_or_url) 39 | else: 40 | fd = open(path_or_url) 41 | key_contents = fd.readline() 42 | finally: 43 | if fd: 44 | fd.close() 45 | return key_contents 46 | 47 | 48 | if __name__ == '__main__': 49 | default_authority = os.getenv('SSH_CA_AUTHORITY', 's3') 50 | default_config = os.path.expanduser( 51 | os.getenv('SSH_CA_CONFIG', '~/.ssh_ca/config')) 52 | 53 | parser = argparse.ArgumentParser(description=__doc__) 54 | parser.add_argument( 55 | '-a', '--authority', dest='authority', default=default_authority, 56 | help='Pick one: s3') 57 | parser.add_argument( 58 | '-c', '--config-file', default=default_config, 59 | help='The configuration file to use. Can also be ' 60 | 'specified in the SSH_CA_CONFIG environment ' 61 | 'variable. Default: %(default)s') 62 | parser.add_argument( 63 | '-e', '--environment', required=True, help='Environment name') 64 | parser.add_argument( 65 | '--principal', action='append', 66 | help='A principal (username) that the user is allowed to use') 67 | parser.add_argument( 68 | '-p', dest='public_path', 69 | help='Path to public key. May be a local file or one located at a ' 70 | 'http/https url. If set we try to upload this. Otherwise we try ' 71 | 'to download one.') 72 | parser.add_argument( 73 | '-u', required=True, dest='username', help='username / email address') 74 | parser.add_argument( 75 | '--upload', dest='upload_only', action='store_true', 76 | help='Only upload the public key') 77 | parser.add_argument( 78 | '-r', '--reason', 79 | help='Specify the reason for the user needing this cert.') 80 | parser.add_argument( 81 | '-t', '--expires-in', default='+2h', 82 | help='Expires in. A relative time like +1w. Or YYYYMMDDHHMMSS. ' 83 | 'Default: %(default)s') 84 | parser.add_argument( 85 | '-s', '--starts-in', default='+0m', 86 | help='Certificate becomes active in. ' 87 | 'A relative time like +1h. Or YYYYMMDDHHMMSS. ' 88 | 'Default: %(default)s') 89 | args = parser.parse_args() 90 | 91 | public_path = args.public_path 92 | environment = args.environment 93 | username = args.username 94 | 95 | now = int(time.time()) 96 | starts_in = parse_time(args.starts_in, now) 97 | expires_in = parse_time(args.expires_in, now) 98 | if starts_in > expires_in: 99 | sys.stderr.write("You must specify an --expires-in later then " 100 | "the --starts-in\n") 101 | sys.exit(-1) 102 | 103 | if expires_in < int(time.time()): 104 | sys.stderr.write("You must specify an --expires-in in the future\n") 105 | sys.exit(-1) 106 | 107 | # always tell S3 to expire the key when the cert expires 108 | url_expires = expires_in - now 109 | 110 | # Keys which expire really soon don't make much sense 111 | if url_expires < (5 * 60): 112 | sys.stderr.write("*" * 50 + "\n") 113 | sys.stderr.write("WARNING: Very short cert expire time of %dsec!\n" % 114 | (url_expires,)) 115 | sys.stderr.write("*" * 50 + "\n") 116 | 117 | starts_in = epoch2timefmt(starts_in) 118 | expires_in = epoch2timefmt(expires_in) 119 | 120 | ssh_ca_section = 'ssh-ca-' + args.authority 121 | 122 | config = None 123 | if args.config_file: 124 | config = ConfigParser.ConfigParser() 125 | config.read(args.config_file) 126 | 127 | # Get a valid CA key file 128 | ca_key = ssh_ca.get_config_value(config, environment, 'private_key') 129 | if ca_key: 130 | ca_key = os.path.expanduser(ca_key) 131 | else: 132 | ca_key = os.path.expanduser('~/.ssh/ssh_ca_%s' % (environment,)) 133 | if not os.path.isfile(ca_key): 134 | print 'CA key file %s does not exist.' % (ca_key,) 135 | sys.exit(1) 136 | 137 | try: 138 | # Create our CA 139 | ca = ssh_ca.s3.S3Authority(config, ssh_ca_section, ca_key) 140 | except ssh_ca.SSHCAInvalidConfiguration, e: 141 | print 'Issue with creating CA: %s' % e.message 142 | sys.exit(1) 143 | except NoAuthHandlerFound: 144 | print ("Error: No AWS credentials found- check your environment for " 145 | "the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY variables.") 146 | sys.exit(1) 147 | 148 | if args.upload_only: 149 | if not public_path: 150 | print 'Upload needs a public key specified.' 151 | sys.exit(1) 152 | key_contents = get_public_key(public_path) 153 | ca.upload_public_key(username, key_contents) 154 | print 'Public key %s for username %s uploaded.' % (public_path, 155 | username) 156 | sys.exit(0) 157 | 158 | # Figure out if we use a local new public key or an existing one 159 | if public_path: 160 | ca.upload_public_key(username, public_path) 161 | delete_public_key = False 162 | else: 163 | public_key_contents = ca.get_public_key(username, environment) 164 | if public_key_contents is None: 165 | print 'Key for user %s not found.' % (username) 166 | sys.exit(1) 167 | (fd, public_path) = tempfile.mkstemp() 168 | with closing(os.fdopen(fd, 'w')) as f: 169 | f.write(public_key_contents) 170 | delete_public_key = True 171 | 172 | if args.reason: 173 | reason = args.reason 174 | else: 175 | prompt = 'Specify the reason for the user needing this cert:\n' 176 | reason = raw_input(prompt).strip() 177 | if len(reason) > 256: 178 | print 'Reason is way too long. Type less.' 179 | sys.exit(1) 180 | 181 | if args.principal: 182 | principals = args.principal 183 | else: 184 | p_conf = ssh_ca.get_config_value(config, environment, 'principals') 185 | if p_conf: 186 | principals = [p.strip() for p in p_conf.split(',')] 187 | else: 188 | principals = ['ec2-user', 'ubuntu'] 189 | 190 | # Sign the key 191 | cert_contents = ca.sign_public_user_key( 192 | public_path, username, starts_in, expires_in, 193 | reason, principals) 194 | 195 | print 196 | print 'Public key signed, certificate available for download here:' 197 | print ca.upload_public_key_cert(username, environment, cert_contents, 198 | url_expires) 199 | 200 | if delete_public_key: 201 | os.remove(public_path) 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | New Project Announcement 2 | ======================== 3 | 4 | We figured out how to make this service a bit more self-service and arguably more secure. That project is here: 5 | 6 | https://github.com/cloudtools/ssh-cert-authority 7 | 8 | The authors of ssh-ca suggest that for the latest and greatest in updates and support you use the ssh-cert-authority tool. It's worth switching. 9 | 10 | Certificate based SSH 11 | ===================== 12 | 13 | "*One key to rule them all, One key to find them, 14 | One key to bring them all and in the cloud bind them*" 15 | 16 | Certificate based SSH allows us to launch a server at time X and grant 17 | SSH access to that server later at time X + Y without touching the 18 | authorized keys file. Further it allows us to generate certificates that 19 | expire at some predefined time meaning that users can be granted access 20 | to a system for a short period of time. 21 | 22 | The primary use case is: 23 | 24 | Jane the Engineer needs shell access to a machine running in 25 | production in order to help debug a problem. In general Jane does not 26 | need access to these machines and it is expected that she only needs 27 | access for a few hours at which point her access should automatically 28 | be revoked. 29 | 30 | A second use case is around host keys: 31 | 32 | A server is launched into the cloud by an adminstrator and made 33 | available to other users over SSH. The first time a user connects to 34 | that machine she is prompted to inspect the host key fingerprint and 35 | type either "yes" or "no". Most users blindly type yes. By signing a 36 | host key and generating a certificate users can blindly accept any 37 | server that presents a valid certificate as trustworthy and never be 38 | prompted to blindly type "yes" again. 39 | 40 | Quick start 41 | =========== 42 | 43 | User keys 44 | --------- 45 | 46 | Generate a certificate authority (yep, this is exactly like making an ordinary private key): 47 | 48 | `ssh-keygen -f ~/.ssh/ssh_ca_production -b 4096` 49 | 50 | Put the CA's public key on the remote host of your choosing into 51 | `authorized_keys`, but prefix it with cert-authority: 52 | 53 | `echo "cert-authority $(cat ssh_ca_production.pub)" >> ~/.ssh/authorized_keys` 54 | 55 | Generate a certificate using the utility in this github repo: 56 | 57 | `sign_key -e production -u user@example.com -p ~/.ssh/id_rsa.pub -t +1d` 58 | 59 | Install the certificate using the other utility in this github repo: 60 | 61 | `get_cert ''` 62 | 63 | SSH like normal. 64 | 65 | Host keys 66 | --------- 67 | 68 | First create a CA using ssh-keygen as described in the previous section. 69 | Then use the `sign_host_key` script included in this distribution. 70 | 71 | This script will SSH out to the remote server and: 72 | 73 | - Copy back the host's public key 74 | - Sign it (it will ask you for the passphrase to your CA 75 | - Copy it back to the host 76 | - Restart sshd 77 | 78 | Once the cert is in place you need to have your client computers told to 79 | trust the CA. Take the public portion of your CA and add it into your 80 | `authorized_keys` file according to this format: 81 | 82 | `@cert-authority *.domain ssh-rsa ...` 83 | 84 | The `*.domain` is intended to be the domain you're signing keys for. If 85 | you were working with a CA that only signed host keys for veznat.com you 86 | could enter `*.veznat.com`. If you sign keys for all sorts of domains 87 | you can enter a `*` here without any qualification, however, you should 88 | understand what this means in the context of a compromised CA before 89 | doing so. 90 | 91 | Usage 92 | ===== 93 | 94 | If you're running this command you must already have access to the 95 | root-ca certificate. Despite being really well encrypted this file is 96 | kept secret and you'll need to pass the "I require access to this file" 97 | test in order to get a copy. 98 | 99 | Once you've got the CA file you can use the script here. Usage is found 100 | with the --help option (not documented here to avoid duplicating the 101 | code). 102 | 103 | When running this script a number of things happen: 104 | 105 | - An entry is made in an audit log in S3 to document that the key was 106 | made, for who, by who and how long the key is valid. 107 | - A serial number is incremented and stored in S3. This makes revoking 108 | certificates later a lot easier. 109 | - The generated certificate is stored in S3 and a temporary (2 hour) URL 110 | is generated for the user to download the certificate 111 | 112 | If a user's public key is given as an argument to the script it is also 113 | uploaded to S3 effectively caching it for the next time the script is 114 | used for that user. Without a public key filename being passed in the 115 | script attempts to load the key from S3. 116 | 117 | How it works 118 | ============ 119 | 120 | The CA owner creates a new certificate authority keypair. This is just a 121 | generic 4096 bit RSA keypair that could be used for regular old SSH 122 | authentication. However, we will protect the generated private key with our 123 | lives (and a really great 2-factor passphrase). 124 | 125 | ``` 126 | cd ~/.ssh 127 | ssh-keygen -f ssh_ca_production -b 4096 128 | ``` 129 | 130 | We take the public key portion of that key pair and add it to the 131 | authorized_keys file of machines we want to login to. However, unlike 132 | normal, the line in authorized_keys is prefixed with `cert-authority`. 133 | 134 | ``` 135 | echo "cert-authority $(cat ssh_ca_production.pub)" >> ~/.ssh/authorized_keys 136 | ``` 137 | 138 | At this point the server is ready to accept authentication using any 139 | private key that can also present a certifcate that was signed using the 140 | root-ca's private key. 141 | 142 | We now get the users public key and sign it with the CA key. The below command 143 | specifies the S3 bucket (-b), S3 region (-r), environment (-e), user name (-u), 144 | users public key file (-p) and how long before the key expires (-t). 145 | 146 | ``` 147 | sign_key -b my-s3-bucket -r us-west-1 -e production -u user@example.com -p user-example.pub -t +1d 148 | ``` 149 | 150 | The output of this is an S3 URL that you give to the user. The user will now 151 | run `get_key` to download the generated certificate from S3 and install it 152 | into their ~/.ssh directory. Note the quotes around the download link. 153 | 154 | ``` 155 | get_key 'https://my-s3-bucket.s3-us-west-1.amazonaws.com/certs/user%40example.com-cert.pub?Signature=neidfJ5bZ5YbmAi2ouJVZzZzZz%3D&Expires=1391025703&AWSAccessKeyId=AKIAJ7HFYKZIVF3ZZZZ' 156 | ``` 157 | 158 | The user can now log into the remote system using these new keys. 159 | 160 | `get_key` is nothing particularly fancy. It simply downloads the 161 | certificate and attempts to find the corresponding private key for the 162 | user and places the cert next to it. OpenSSH requires that the cert be 163 | named similarly to the private key. For example, if your private key is 164 | named `id_rsa` the cert must be in a file named `id_rsa-cert.pub`. It 165 | really does simply append `-cert.pub` to the filename. 166 | 167 | Troubleshooting 168 | =============== 169 | 170 | Typical problems include not having the certificate added to the running 171 | ssh-agent. You can list certificates and keys with the ssh-add command: 172 | `ssh-add -l`. You should see the certificate listed: 173 | 174 | ``` 175 | 2048 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe .ssh/id_rsa (RSA) 176 | 2048 66:b5:be:e5:7e:09:3f:98:97:36:9b:64:ec:ea:3a:fe .ssh/id_rsa (RSA-CERT) 177 | ``` 178 | If you don't see it listed simply run `ssh-add ` again. 179 | 180 | Incompatibilities and Annoyances 181 | ================================ 182 | 183 | OpenSSH - One cert per key 184 | -------------------------- 185 | You can only have one SSH cert loaded per private key at any one time 186 | (The SSH agent works by loading your private key file `keyfile` and then 187 | looks for a certificate in a file named `keyfile`-cert.pub. 188 | 189 | If, like us, you have multiple environments that you want to access in a 190 | short time window the best workaround is to have multiple private keys. 191 | This project has builtin support for per-environment keys. To take 192 | advantage of this upload your private key, using the sign_key command, 193 | but specifying your username in the format 194 | `user?environment=THE-ENVIRONMENT`. For example, I might use 195 | `bob@veznat.com?environment=stage` for staging and 196 | `bob@veznat.com?environment=prod` for production. The public keys that 197 | are being uploaded her must correspond to separate private keys 198 | otherwise when get_cert runs it will not be able to reliably figure out 199 | where to put the downloaded cert. 200 | 201 | If you use this multiple environment naming trick in your username you 202 | do not need to specify anything special when running sign_key in the 203 | future. Sign key searches for your public key first by doing an 204 | environment specific lookup and second looking for a generic one. 205 | 206 | Vagrant 207 | ------- 208 | When a user has one of these cert keys in their keychain 209 | [vagrant](http://www.vagrantup.com/) will hang in bringing up a new box. 210 | This is due to an incompatibility in the Ruby net-ssh package included in 211 | vagrant. This is being tracked in this 212 | [net-ssh issue](https://github.com/net-ssh/net-ssh/pull/142). 213 | 214 | OS X 215 | ---- 216 | OS X's magic ssh-add (the one where it prompts you in the GUI of OS X 217 | for your passphrase) does not properly add the certificate. In order to 218 | utilize certificates you'll want to `ssh-add .ssh/my_private_key` at a 219 | terminal in order for the certificate to properly be added to your 220 | ssh-agent. 221 | 222 | 223 | --------------------------------------------------------------------------------