ReleaseEngineering/PuppetAgain/Certificate Chaining

From MozillaWiki
Jump to: navigation, search

The Mozilla implementation of PuppetAgain has as one of its goals that any client can communicate freely with any master. Ordinarily, each puppetmaster has its own certificate authority, and a client which has been issued a certificate by one master will not be recognized by another master.

The solution is certificate chaining.

In this configuration, Puppet fits into a larger SSL certificate system. At Mozilla, this is specifically used to allow hosts to issue and validate all certificates without being directly connected. This supports resiliency, isolation of disclosure risk, and potentially (though not at Mozilla) integration into an enterprise-wide certification hierarchy.


Certificate Hierarchy

We have a hierarchy of certificate authorities that looks like this:

   Root CA cert (Subject: CN=PuppetAgain Base CA/emailAddress=release@mozilla.com, OU=Release Engineering, O=Mozilla, Inc.)
   |
   +--Master CA cert (Subject: CN=CA on $fqdn)
   |   |
   |   +--Master cert (Subject: CN=$fqdn, OU=PuppetMasters)
   |   |
   |   +--Agent cert (Subject: CN=$fqdn)
   |   |
   |   +--Agent cert (Subject: CN=$fqdn)
   |   :
   |   .
   |
   +-- Master CA cert (Subject: CN=CA on $fqdn)
   :   |
   .   +--Master cert (Subject: CN=$fqdn, OU=PuppetMasters)
       |
       +--Agent cert (Subject: CN=$fqdn)
       |
       +--..
       :
       .

Notes:

  • Here and throughout these docs, the terms "root" and "base" CA are used interchangeably. "Root" is preferred.
  • Master and Agent certs are nearly identical, except that one has an EKU allowing use as SSL clients, and the other only allows SSL server. The "OU=PuppetMasters" is important so that each puppetmaster can have a master and agent cert with the same fqdn, but the certificate subjects are different.

Master Initialization

When a master is initialized, it is provided with a puppetmaster CA certificate and corresponding key. This certificate is signed by the base CA. The puppet manifests for masters immediately create a Master cert, suitable for authenticating an SSL connection, signed by this Master CA cert.

Certificate Issuance

When a client is issued a new certificate, after the requisite authentication has been performed (see Puppetization Process), a new certificate and key are generated and signed by the CA on the puppetmaster doing the issuing. Any puppetmaster can issue a certificate. This certificate and key, along with the root CA certificate, are returned to the client.

The process looks something like this: (although see the puppet manifests themselves to see how this works for real)

   # make a key 
   openssl genrsa -des3 -out "/root/${host}.key.pass" -passout pass:x
   # strip its password
   openssl rsa -in "/root/${host}.key.pass" -out "/root/${host}.key" -passin pass:x
   # make a signing request
   cat <<EOF > "/root/${host}-openssl.conf"
   [req]
   prompt = no
   distinguished_name = clientcert_dn
   [clientcert_dn]
   commonName = ${host}
   EOF 
   openssl req -config "/root/${host}-openssl.conf" -new -key "/root/${host}.key" -out "/root/${host}.csr"
   # sign it (this openssl.conf points to the local Master CA cert)
   openssl ca -config "${master_ssldir}/ca/openssl.conf" -in "/root/${host}.csr" -notext -out "/root/${host}.crt" \
       -batch -passin "file:${master_ssldir}/ca/private/ca.pass"

In particular, the `commonName` is the fqdn of the host. Agents are configured with the Root CA cert, the Agent cert, and the private key for the Agent cert.

Master CA certificates are generated by hand, using a process something like this:

   (put a nice long password in ${fqdn}-ca.password)
   $ openssl genrsa -des3 -out ${fqdn}-ca.key -passout file:${fqdn}-ca.password 2048
   $ vi openssl.conf
   [req]
   prompt = no
   distinguished_name = puppetmaster_ca_dn
   [puppetmaster_ca_dn]
   commonName = CA on ${fqdn}
   emailAddress = release@mozilla.com
   organizationalUnitName = Release Engineering
   organizationName = Mozilla, Inc.
   $ openssl req -config openssl.conf -new -key ${fqdn}-ca.key -out ${fqdn}-ca.csr -passin file:${fqdn}-ca.password
   $ openssl req -text -in ${fqdn}-ca.csr

The resulting CSR is then signed by the Root CA cert on an isolated system.

Validation

The relevant Apache configuration looks like this:

   SSLCertificateFile /var/lib/puppet/ssl-master/certs/puppet.pem
   SSLCertificateKeyFile /var/lib/puppet/ssl-master/private_keys/puppet.pem
   # include the intermediate cert (this host's CA)
   SSLCertificateChainFile /var/lib/puppet/ssl-master/ca/ca_crt.pem
   # certs and CRLs for all CAs (the base, plus the CA for each puppet
   # master) are stored in the same dir.  This dir is synchronized with csync2.
   SSLCACertificatePath /var/lib/puppet/ssl-master/certdir
   SSLCARevocationPath /var/lib/puppet/ssl-master/certdir
   # verification is optional; the puppet rails app looks at SSL_CLIENT_VERIFY
   # and auth.conf to decide what requires verification
   SSLVerifyClient optional
   # puppetagain cert chains have two CA's, so verify twice the depth
   SSLVerifyDepth  2
   SSLOptions +StdEnvVars
   # The following client headers allow the same configuration to work with Pound.
   RequestHeader set X-SSL-Subject %{SSL_CLIENT_S_DN}e
   RequestHeader set X-Client-DN %{SSL_CLIENT_S_DN}e
   RequestHeader set X-Client-Verify %{SSL_CLIENT_VERIFY}e

The certdir contains all puppetmaster CA certs and their CRL's, as well as the base CA cert and its CRL. We synchronize this directory among all puppetmasters using csync2. The certificates and CRLs are hashed using the following script:

 for i in *.crl; do
   h=`openssl crl -hash -noout -in $i`
   fn=$h.r0
   [ ! -f $fn ] && ln -s $i $fn
 done
 for i in *.crt; do
   h=`openssl x509 -hash -noout -in $i`
   fn=$h.0
   [ ! -f $fn ] && ln -s $i $fn
 done

Validation of Agents

Apache takes care of validating agents. The agents provide their Agent cert, and Apache searches the certdir for parent certificates until it reaches a valid self-signed cert. Concretely, in this case it finds the Master CA cert that signed the Agent cert in certdir, and then finds the Root CA cert there.

Validation of Masters

Agents are configured with the Root CA cert. Apache's SSLCertificateChainFile directive supplies the intermediate Master CA cert, and the SSLCertificateFile provides the Master cert. These three certificates constitute the entire chain required for validation of the master by the agent.

CRLs

Certificate revocation lists (CRLs) are crucial to puppet's operation, as certificates are often re-issued to clients, e.g., when they are re-imaged.

All of the CA's described above have corresponding CRLs, and these are made available to Apache with the SSLCARevocationPath directive, as seen above.

Validation

Apache consults its SSLCARevocationPath to check the corresponding CRLs for all CA certificates. This directory is kept up to date by each master, and synchronized between masters using a cronjob.

Unfortunately, until 14550 is fixed, the puppet agent cannot validate a certificate chain's CRLs, so CRL checking is disabled on the agent:

   certificate_revocation = false

This means that agents may continue to trust a compromised Master cert or Master CA cert.

Revocation

When it comes time to revoke a certificate, things can be a bit complicated. Revocation of a compromised cert requires access to the CA certificate and key, but that key only exists on the master that created the compromised cert it. So a method is required to indicate that a particular certificate should be revoked. This is accomplished with a number of directories in the git repository shared between the masters. The relevant pieces look like this:

   agent-certs/relabs02.build.mtv1.mozilla.com/
   agent-certs/relabs03.build.mtv1.mozilla.com/
   agent-certs/relabs03.build.mtv1.mozilla.com/agent1.build.mtv1.mozilla.com.crt
   agent-certs/relabs03.build.mtv1.mozilla.com/agent2.build.mtv1.mozilla.com.crt
   revocation-requests/relabs02.build.mtv1.mozilla.com/
   revocation-requests/relabs02.build.mtv1.mozilla.com/agent8.build.mtv1.mozilla.com-for-relabs03.build.mtv1.mozilla.com.crt
   revocation-requests/relabs03.build.mtv1.mozilla.com/

Active agent certs are kept in agent-certs, under a directory named for the master that issued the cert. When any master wants to revoke a certificate, it moves it ('git mv') to a subdirectory of revocation-requests again named after the issuing master. The filename is set up to be unique even if two masters request a revocation of the same certificate.

A crontask runs on each master that checks its revocation requests for any pending revocations, and carries them out. After each such revocation, a new CRL is generated and placed in the certdir, where it is soon distributed again to all masters.

Puppet Configuration

On the master, you'll need to convince it not to try to do fancy CA-like things:

 ca = false

Command Reference

All of this is accomplished with the command we all love to hate, openssl. Here are some of the commands used for various parts of this process. This isn't a complete recipe because, honestly, you should struggle with this stuff a bit so that you understand it - it's easy to accidentally configure encryption to do absolutely nothing of value if you're not paying attention. I've tried to call out the processes that took me the longest to figure out, and the things I stumbled over, in hopes you can make the journey more quickly than I did.

NOTE: be careful to distinguish the puppetmaster's CA certificate from its leaf certificate, particularly in the Apache configurations.

Root CA

The root CA has a simple self-signed certificate. This is the keys to the kingdom, so be careful with it. Put it on a well-protected system, isolated from your puppet environment, and protect the passphrase carefully.

You should also understand the difference between a certificate, a key, and a CRL. There are plenty of good summaries out there on the 'net.

Put the following in openssl.conf:

[ca]
default_ca = puppetagain-base-ca
 
[puppetagain-base-ca]
certificate = ./puppetagain-base-ca.crt
private_key = ./puppetagain-base-ca.key
database = ./inventory.txt
new_certs_dir = ./puppetagain-base-ca-certs
serial = ./serial
 
default_crl_days = 3650
default_days = 1825
default_md = sha1
 
policy = general_policy
x509_extensions = general_exts
 
[general_policy]
commonName = supplied
emailAddress = supplied
organizationName = supplied
organizationalUnitName = supplied
 
[general_exts]
authorityKeyIdentifier=keyid,issuer:always
basicConstraints = critical,CA:true
keyUsage = keyCertSign, cRLSign

then touch inventory.txt and echo 0001 > serial.

Set up a new self-signed CA cert with:

 openssl req -new -newkey rsa -days 3650 -x509 -subj "/CN=PuppetAgain Base CA, OU=Release Engineering, O=Mozilla, Inc." -keyout puppetagain-base-ca.key -out puppetagain-base-ca.crt

adjusting the subject appropriately for your environment. The subject doesn't particularly matter, but using the above will risk it being confused with moco's certificate.

Generate a CRL with

 openssl ca -config openssl.conf -gencrl -out puppetagain-base-ca.crl

You now have a *.key (private key - keep this secret!), *.crt (certificate), and *.crl (CRL) file for your root CA.

Note that the file contents are short blobs encoded in a text format. You can easily copy-and-paste them, if -- as is wise -- your CA host is strictly isolated from your production systems.

Making a New Puppetmaster CA Certificate

You should already know what a key, certificate, CRL, and CSR are.

The idea here is to make a CA certificate (one that can sign other certificates) that is signed by the root CA.

The following commands will make a new key (master.key) and a corresponding CSR. Note that the instructions you get from puppet when you're setting this up will contain explicit paths, so it will be easier to copy/paste there.

   openssl genrsa -out ${master_ca_key} 2048
   openssl req -new -subj "/CN=CA on ${fqdn}" -key ${master_ca_key} -out master-ca.csr
   openssl req -text -in master-ca.csr

Check that the CSR has the expected fields (check the dates, etc.), then copy/paste it into a temporary file (say, master.csr) on the host where your root CA is set up. There, run

   openssl ca -config openssl.conf -in master.csr

This is using the root CA to sign the master CA's certificate. Check that the CSR values match what you specified above, and answer the prompts. You'll end up with a new certificate, which you can copy and paste back onto the puppetmaster (the puppet message will tell you where to put it).

Puppetmaster CA Setup

You don't need to know this if you're using PuppetAgain, because the setup scripts do it for you (and slightly differently), but for those wondering how Certificate Chaining works in general:

$ openssl genrsa -des3 -out ${fqdn}-ca.key -passout file:${fqdn}-ca.password 2048
$ vi openssl.conf
[req]
prompt = no
distinguished_name = puppetmaster_ca_dn
[puppetmaster_ca_dn]
commonName = CA on ${fqdn}
emailAddress = youremail@domain.com
organizationalUnitName = Your OU
organizationName = Your Org

$ openssl req -config openssl.conf -new -key ${fqdn}-ca.key -out ${fqdn}-ca.csr -passin file:${fqdn}-ca.password
$ openssl req -text -in ${fqdn}-ca.csr

Check the request's contents in the dump, and copy it (${fqdn}-ca.csr) over to the machine hosting the base CA. There, run:

# openssl ca -config openssl.conf -in ${fqdn}-ca.csr -notext -out ${fqdn}-ca.crt -batch

and copy the resulting ${fqdn}-ca.crt back to the puppet master.

You'll need to set up an openssl.conf for signing *using* this new CA certificate:

[ca]
default_ca = server_ca

[server_ca]
certificate = ${fqdn}-ca.crt
private_key = ${fqdn}-ca.key
database = $PWD/inventory.txt
new_certs_dir = $PWD/certs
serial = $PWD/serial

default_crl_days = 7
default_days = 1825
default_md = sha1

policy = general_policy
x509_extensions = general_exts

[general_policy]
commonName = supplied

[general_exts]
authorityKeyIdentifier=keyid,issuer:always
basicConstraints = critical,CA:false
keyUsage = keyEncipherment, digitalSignature
extendedKeyUsage = serverAuth, clientAuth

# extensions used to sign the server cert (alternative name, in particular)
[servercert_exts]
authorityKeyIdentifier=keyid,issuer:always
basicConstraints = critical,CA:false
keyUsage = keyEncipherment, digitalSignature
extendedKeyUsage = serverAuth, clientAuth
# include the fqdn here to work around https://bugs.ruby-lang.org/issues/6493
subjectAltName = DNS:puppet,DNS:puppetmaster.fully.qualified.name

The salient points there are that the first two options in [server_ca] are the CA certificate and key you just generated; and the subjectAltName in the servercert_exts section (which will only be used to sign the leaf cert).

Note that the private key has a password, stored in ${ssldir}/ca/private/ca.pass. This path is what Puppet uses naturally, although we never allow puppet to touch it. This just adds a little more obfuscation, without any significant complexity.

You should generate a CRL right away, even though it will be empty:

openssl ca -config openssl.conf -gencrl -passin file:${ca_pass} -out "${certdir}/${fqdn}.crl"

Making a new Puppetmaster Leaf Certificate

cat <<EOF > openssl.conf
[req]
prompt = no
distinguished_name = servercert_dn

[servercert_dn]
commonName = ${fqdn}
EOF
openssl genrsa -des3 -out ${fqdn}.key-tmp -passout pass:temppass 2048
openssl rsa -in  ${fqdn}.key-tmp -passin pass:temppass -out ${fqdn}.key
rm ${fqdn}.key-tmp
openssl req -config openssl.conf -new -key ${fqdn}.key -out ${fqdn}.csr
openssl req -text -in ${fqdn}.csr
openssl ca -config ${ssldir}/ca/openssl.conf -in ${fqdn}.csr -notext -out ${fqdn}.crt -batch \
    -extensions servercert_exts -passin file:${ssldir}/ca/private/ca.pass

Here, the commonName is important, and it's important that it be the only component of the DN. I've assumed that the puppetmaster CA is in ${ssldir}/ca; you can put yours where you like.

Note that the resulting key has no passphrase, since it's used in an automated process.

Note, too, that this specifically calls out the servercert_exts, which is what triggers the subjectAltName to be present.

Signing a client cert

This looks suspiciously similar to signing the server cert, with the exception of not using the servercert_exts.

# make a key
openssl genrsa -des3 -out "/root/$host.key.pass" -passout pass:x

# strip its password
openssl rsa -in "/root/$host.key.pass" -out "/root/$host.key" -passin pass:x

# make a signing request
cat <<EOF > "/root/$host-openssl.conf"
[req]
prompt = no
distinguished_name = clientcert_dn

[clientcert_dn]
commonName = ${host}
EOF
openssl req -config "/root/$host-openssl.conf" -new -key "/root/${host}.key" -out "/root/${host}.csr"

# sign it
openssl ca -config "${ssldir}/ca/openssl.conf" -in "/root/${host}.csr" -notext -out "/root/${host}.crt" \
    -batch -passin "file:${ssldir}/ca/private/ca.pass"

Again, the commonName is important, and must be the only thing in the DN.

Revoking Certificates

This script runs in a crontask to revoke certificates and regenerate CRLs.

need_crl=false
shopt -s nullglob
cd "$client_certs_dir/$fqdn/revoke"
for crt in *.crt; do
    openssl ca -revoke $crt -config "$ssldir/ca/openssl.conf" \
            -keyfile "$ssldir/ca/ca_key.pem" -passin file:"$ssldir/ca/private/ca.pass" \
            -cert "$ssldir/ca/ca_crt.pem"
    rm "$crt"
    need_crl=true
done

# check the date on the CRL, regenerting if it's over a day old
if [ -n "$(find $crl -mtime +1 2>/dev/null)" ]; then
    need_crl=true
fi

# if we revoked anything or otherwise need to re-gen the CRL, do it
if $need_crl; then
    openssl ca -gencrl -config "$ssldir/ca/openssl.conf" \
            -keyfile "$ssldir/ca/ca_key.pem" -passin file:"$ssldir/ca/private/ca.pass" \
            -cert "$ssldir/ca/ca_crt.pem" -out "$certdir/$fqdn.crl"
fi

Gotchas and Tips

These were the points with lots of stars and uncouth language next to them in my journal. --Djmitche 21:58, 22 May 2012 (PDT)

  • OpenSSL *must* trust the root of the certificate chain (the self-signed cert). It must have access to the intermediate certificates, but it doesn't matter if they're trusted. When I began this process, I signed the base CA with an internal Mozilla CA certificate. This would have worked, except that I was forced to trust *everything* signed by that certificate, which was too broad for my purposes.
  • If you don't set ca = false on the master, it will do all sorts of nasty things, sending bogus certificates and CRLs to the client. I saw "The certificate retrieved from the master does not match the agent's private key".
  • You must set certificate_revocation = false on the agent, or else the agent will try to download a CRL from the master, and then fail because that (single) CRL cannot completely cover the two CA certificates involved in the certification chain.
  • In certificates used for SSL, the DN must contain only commonName=$hostname; in the 'openssl x509' output, you should see something like:
  Subject: CN=relabs08.mozilla.com
  • note that in a certdir, certificates are hashed with an extension of '.0' (or .1 and so on if there are collisions), while CRLs are hashed with an extension ending of '.r0' (or .r1, ..). This is not obvious *anywhere* except in the output of a 'strace' of Apache (seriously).
  • Apache only checks CRLs that it can find; a CA certificate without a corresponding CRL is assumed to have no revoked certificates. This can be helpful if you do not want to worry about expired CRLs, but could also be a security problem if you're not careful.
  • Ruby's OpenSSL::SSL class ignores the distinguished name on a certificate if there are subjectAltNames defined. This matters for server certs, which must thus include both their fqdn and 'puppet' in the subjectAltNames option if they are to be usable at both names. See https://bugs.ruby-lang.org/issues/6493
  • CRLs potentially expire very quickly. If you don't have plans in place to automatically regenerate CRLs, pick a very long expiration time for them (default_crl_days in openssl.conf).

The 'openssl verify' command is *very* useful for verifying that a set of certificates all line up. Sadly, it seems unable to work with a CRL directory, but it can take a bundle of CRLs appended to one another. If I recall correctly, order of those CRLs is important.

 openssl verify -purpose sslclient -crl_check_all -CApath /var/lib/puppet/ssl/certdir/ -CRLfile crl-bundle.pem relabs08.mozilla.com.crt

When debugging, start by using openssl s_client on the client, rather than running puppet. Puppet currently ignores the error codes from certificate validation, so it will give you very little data to debug with. I'm working on a patch for better reporting.

openssl s_client -connect puppet:8140 -verify 3 -CAfile certs/ca.pem 
   -cert certs/$client.pem -key private_keys/$client.pem

Look for two things: first, at the very bottom of the output, you'll see the overall result of the validation. Second, if you get your shell prompt back right away, then Apache is rejecting you.

You can add 'LogLevel Debug' to the Apache VirtualHost section to get *very* verbose logging of its SSL machinations.