Skip to content

Commit f88c121

Browse files
committed
Add functionality for signing host keys
Works similarly, but obviously differently, from signing user keys. The restarting of sshd on the remote server doesn't appear to work reliably. Not sure why yet. Everything else appears to work right.
1 parent e26556d commit f88c121

4 files changed

Lines changed: 237 additions & 7 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,22 @@ The primary use case is:
1818
access for a few hours at which point her access should automatically
1919
be revoked.
2020

21+
A second use case is around host keys:
22+
23+
A server is launched into the cloud by an adminstrator and made
24+
available to other users over SSH. The first time a user connects to
25+
that machine she is prompted to inspect the host key fingerprint and
26+
type either "yes" or "no". Most users blindly type yes. By signing a
27+
host key and generating a certificate users can blindly accept any
28+
server that presents a valid certificate as trustworthy and never be
29+
prompted to blindly type "yes" again.
30+
2131
Quick start
2232
===========
2333

34+
User keys
35+
---------
36+
2437
Generate a certificate authority (yep, this is exactly like making an ordinary private key):
2538

2639
`ssh-keygen -f ~/.ssh/ssh_ca_production -b 4096`
@@ -40,6 +53,32 @@ Install the certificate using the other utility in this github repo:
4053

4154
SSH like normal.
4255

56+
Host keys
57+
---------
58+
59+
First create a CA using ssh-keygen as described in the previous section.
60+
Then use the `sign_host_key` script included in this distribution.
61+
62+
This script will SSH out to the remote server and:
63+
64+
- Copy back the host's public key
65+
- Sign it (it will ask you for the passphrase to your CA
66+
- Copy it back to the host
67+
- Restart sshd
68+
69+
Once the cert is in place you need to have your client computers told to
70+
trust the CA. Take the public portion of your CA and add it into your
71+
`authorized_keys` file according to this format:
72+
73+
`@cert-authority *.domain ssh-rsa ...`
74+
75+
The `*.domain` is intended to be the domain you're signing keys for. If
76+
you were working with a CA that only signed host keys for veznat.com you
77+
could enter `*.veznat.com`. If you sign keys for all sorts of domains
78+
you can enter a `*` here without any qualification, however, you should
79+
understand what this means in the context of a compromised CA before
80+
doing so.
81+
4382
Usage
4483
=====
4584

scripts/sign_host_key

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 like:
26+
27+
curl <THE URL> > /etc/ssh/ssh_host_rsa_key-cert.pub
28+
29+
sshd isn't configured out of the box to look for certs so you need to edit your
30+
/etc/ssh/sshd_config and add a line like this one:
31+
32+
HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub
33+
34+
Now you can send a HUP to the sshd parent process. I typically do a ps ax |
35+
grep sshd and then kill -HUP the lowest numbered pid (or whichever one appears
36+
most likely to be the parent).
37+
"""
38+
import argparse
39+
import ConfigParser
40+
import os
41+
import sys
42+
import tempfile
43+
44+
from contextlib import closing
45+
46+
import ssh_ca
47+
import ssh_ca.s3
48+
49+
50+
if __name__ == '__main__':
51+
default_authority = os.getenv('SSH_CA_AUTHORITY', 's3')
52+
default_config = os.path.expanduser(
53+
os.getenv('SSH_CA_CONFIG', '~/.ssh_ca/config'))
54+
55+
parser = argparse.ArgumentParser(__doc__)
56+
parser.add_argument('-a', '--authority',
57+
dest='authority', default=default_authority,
58+
help='Pick one: s3',
59+
)
60+
parser.add_argument('-c', '--config-file',
61+
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+
)
66+
parser.add_argument('-e', '--environment',
67+
required=True,
68+
help='Environment name',
69+
)
70+
parser.add_argument('--hostname',
71+
action='append', required=True, dest='hostnames',
72+
help='A principal (hostname) that clients can verify. '
73+
'This should match what the user types for "ssh <hostname>',
74+
)
75+
parser.add_argument('-t', '--expires-in',
76+
default='+104w',
77+
help='Expires in. A relative time like +1w. Or YYYYMMDDHHMMSS. '
78+
'Default: %(default)s',
79+
)
80+
args = parser.parse_args()
81+
82+
environment = args.environment
83+
84+
ssh_ca_section = 'ssh-ca-' + args.authority
85+
86+
config = None
87+
if args.config_file:
88+
config = ConfigParser.ConfigParser()
89+
config.read(args.config_file)
90+
91+
# Get a valid CA key file
92+
ca_key = ssh_ca.get_config_value(config, environment, 'private_key')
93+
if ca_key:
94+
ca_key = os.path.expanduser(ca_key)
95+
else:
96+
ca_key = os.path.expanduser('~/.ssh/ssh_ca_%s' % (environment,))
97+
if not os.path.isfile(ca_key):
98+
print 'CA key file %s does not exist.' % (ca_key,)
99+
sys.exit(1)
100+
101+
try:
102+
ca = ssh_ca.s3.S3Authority(config, ssh_ca_section, ca_key)
103+
except ssh_ca.SSHCAInvalidConfiguration, e:
104+
print 'Issue with creating CA: %s' % e.message
105+
sys.exit(1)
106+
107+
reason = 'New host cert for %r' % (args.hostnames,)
108+
109+
public_key_contents = ca.get_host_rsa_key(args.hostnames[0])
110+
(fd, public_path) = tempfile.mkstemp()
111+
with closing(os.fdopen(fd, 'w')) as f:
112+
f.write(public_key_contents)
113+
114+
cert_contents = ca.sign_public_host_key(
115+
public_path, args.expires_in, args.hostnames,
116+
reason, args.hostnames[0]
117+
)
118+
119+
ca.upload_host_rsa_cert(args.hostnames[0], cert_contents)

ssh_ca/__init__.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ConfigParser
22
import os
33
import subprocess
4+
import time
45

56

67
__version__ = "0.2.0"
@@ -41,9 +42,61 @@ def make_audit_log(self,
4142
serial, valid_for, username, ca_key_filename, reason, principals):
4243
pass
4344

45+
def make_host_audit_log(self,
46+
serial, valid_for, ca_key_filename, reason, hostnames):
47+
pass
48+
4449
def upload_public_key(self, username, public_path):
4550
pass
4651

52+
def get_host_rsa_key(self, hostname):
53+
"""Gets <hostname>'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 <cert> into ssh_host_rsa_key-cert.pub on <hostname>"""
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,
82+
public_key_filename, expires_in, 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+
47100
def sign_public_user_key(self,
48101
public_key_filename, username, expires_in, reason, principals):
49102
serial = self.increment_serial_number()
@@ -55,11 +108,15 @@ def sign_public_user_key(self,
55108
'-I', username,
56109
'-V', expires_in,
57110
'-n', ','.join(principals),
58-
public_key_filename])
111+
public_key_filename]
112+
)
59113

60114
self.make_audit_log(
61115
serial, expires_in, username, self.ca_key, reason, principals)
62116

117+
return self.get_cert_contents(public_key_filename)
118+
119+
def get_cert_contents(self, public_key_filename):
63120
if public_key_filename.endswith('.pub'):
64121
public_key_filename = public_key_filename[:-4]
65122
cert_filename = public_key_filename + '-cert.pub'

ssh_ca/s3.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,34 @@ def upload_public_key_cert(self, username, cert_contents):
6969
)
7070
return k.generate_url(7200)
7171

72+
def make_host_audit_log(self,
73+
serial, valid_for, ca_key_filename, reason, hostnames):
74+
audit_info = {
75+
'valid_for': valid_for,
76+
'access_key': self.s3_conn.access_key,
77+
'ca_key_filename': ca_key_filename,
78+
'reason': reason,
79+
'hostnames': hostnames,
80+
}
81+
return self.drop_audit_blob(serial, audit_info)
82+
7283
def make_audit_log(self,
7384
serial, valid_for, username, ca_key_filename, reason, principals):
74-
timestamp = datetime.datetime.strftime(
75-
datetime.datetime.utcnow(), '%Y-%m-%d-%H:%M:%S.%f')
76-
k = self.ssh_bucket.new_key('audit_log/%d.json' % (serial,))
77-
7885
audit_info = {
7986
'username': username,
8087
'valid_for': valid_for,
81-
'timestamp': timestamp,
8288
'access_key': self.s3_conn.access_key,
8389
'ca_key_filename': ca_key_filename,
8490
'reason': reason,
8591
'principals': principals,
8692
}
87-
k.set_contents_from_string(json.dumps(audit_info))
93+
return self.drop_audit_blob(serial, audit_info)
94+
95+
def drop_audit_blob(self, serial, blob):
96+
k = self.ssh_bucket.new_key('audit_log/%d.json' % (serial,))
97+
98+
timestamp = datetime.datetime.strftime(
99+
datetime.datetime.utcnow(), '%Y-%m-%d-%H:%M:%S.%f')
100+
blob['timestamp'] = timestamp
101+
102+
k.set_contents_from_string(json.dumps(blob))

0 commit comments

Comments
 (0)