Puppet is a key component in server deployment and configuration automation. When nodes successfully get provisioned by a Puppet master they retrieve all sorts of data, some which might be both confidentials and criticals. It is then mandatory to ensure that only authorized instances or nodes are allowed to retrieve such data. In today elastic systems and cloud era, it is very important that enrollment and management of servers be both as streamlined and secured as possible, but both of these requirements often conflict, as security is often a blocker to automation. PuppetLabs has been working hard on finding elegant ways to solve this and recently delivered policy based auto-signing of certificates allowing one to set up safeguards against non legitimate synchronization request. This blog post examines the problems that we previously faced and how this new feature addresses them.
Note: The term auto-signing in this article refers to the action that the Puppet CA can sign automatically the certificate as they arrive. Not to be confused with self-signed SSL certificate.
Current state and problematic
Until not so long ago, determining if a node was authorized to retrieve a catalog from a puppet master could only be resolved in a simplistic way. It actually boiled down to 3 possible solutions.
- Allow every node
By setting `autosign = true` or making the content of `autosign.conf` to ‘*’, a puppet administrator authorizes every node querying the puppet server to retrieve a catalog.
The flaw is that if a malicious user can get on the proper network and know the certname the puppet master respond to (default: puppet), one will be able to retrieve a catalog. - Enable a certname whitelist
Until the policy-based auto-signing feature, the most advanced way to automatically sign certificate in a non all-or-nothing fashion was through a certname whitelist (default: `autosign.conf` in `$confdir`).
An `autosign.conf` file looks like :--- web*.example.com lb*.example.com *.mycompany.com
If a malicious user can find/guess those restrictions, then we are back to number one, and all the nodes a malicious user will want to synchronize will be able to retrieve a catalog from the puppet master, simply by passing an authorized certname in an ad-hoc way (`puppet agent –certname web999.example.com`)
- Allow node manually
Last resort is to disable auto-signing, by setting `autosign = false` and authorize each and every node manually. Unless you are using pets in a Pets vs. Cattle way of doing things, allowing manually every host that spawns on an elastic system is not reasonable/scalable/feasible, also it goes against the DevOps way of doing things (ie. frictionless). - Does this node has a serial number that is on my inventory database?
- Was this node spawned on my cloud and has a valid UUID ?
- Must be executable by the same user as the puppet master
- Return 0 to sign the certificate, 1 to refuse it
- Receive the CSR content on stdin
- Receive the certname as unique parameter
The current implementation of auto-signing makes it impossible to authorize nodes based on some common use cases :
Solution
Since version 3.4, Puppet allows one to specify attributes that will be embedded in the Certificate Signing Request (CSR).
Since version 3.5, Puppet allows one to access extensions in the `$trusted` hash.
How to add attributes to the CSR
In order to add specific attributes to the agent CSR, one needs to list those attributes in the `csr_attributes.yaml` file (default: `$confdir/csr_attributes.yaml`).
By the PuppetLabs documentation :
The csr_attributes file must be a YAML hash containing one or both of the following keys:
* custom_attributes
* extension_requests
The value of each key must also be a hash, where:
Each key is a valid object identifier (OID) — Puppet-specific OIDs may optionally be referenced by short name instead of by numeric ID.
Each value is an object that can be cast to a string (that is, numbers are allowed but arrays are not).
Currently there is no OID custom mapping, except those provided by PuppteLabs itself, but a work is in progress to be able to name the custom extensions (https://tickets.puppetlabs.com/browse/PUP-2995). So one needs to specify raw OIDs.
On the agent
Before sending the CSR, create the following `csr_attributes.yaml` file :
--- extension_requests: 1.3.6.1.4.1.34380.1.2.1.1: mySerialNumber 1.3.6.1.4.1.34380.1.2.1.2: myUUIDnumber
then run `puppet agent -t`
On the server
Go to `$ssldir/ca/requests/`, there one should have a `$certname.pem` file, with `$certname` being the hostname of the agent that just ran `puppet agent -t`
Now run the following command : `openssl req -noout -text -in $certname.pem`
The following block should be present in the output :
Requested Extensions: 1.3.6.1.4.1.34380.1.2.1.1: mySerialNumber 1.3.6.1.4.1.34380.1.2.1.2: myUUIDnumber
One can now attach arbitrary attributes to the Certificate Signing Request (CSR) to be consumed later by the autsigning script or the trusted facts (more on that later)
How to autosign a node based on the attributes provided by the CSR
The path to the autosigning script is defined in the `[master]` section with the `autosign` parameter.
The script needs to meet few requirements :
Given that, when the master receives the CSRs, it will run the given script and sign them (or not) if they meet the business logic.
#!/usr/bin/ruby require 'openssl' def parse_extensions(extension_request) extension_request_hash = {} extension_request.each do |extension| extension_request_hash[extension.value[0].value] = extension.value[1].value end return extension_request_hash end $csr = ARGF.read $valid_values = ['foo', 'bar', 'openstack', 'enovance', 'mySerialNumber'] $extensions_hash = {} request = OpenSSL::X509::Request.new $csr request.attributes.each do |attribute| attr = OpenSSL::X509::Attribute.new attribute $extensions_hash = parse_extensions(attr.value.value.first.value) end exit 1 if ! $valid_values.include? $extensions_hash['1.3.6.1.4.1.34380.1.2.1.1']
In the previous example, any Certificate Signing Request that provides a ‘1.3.6.1.4.1.34380.1.2.1.1’ attributes that is in the `$valid_values` array will be signed, refused otherwise. This example is not realistic, it is mainly to give a basic example.
Logic could be much more complex and business specific, one can check that a given node belongs to a given database, that a node has a valid token, etc…. . Since the policy-based auto-signing script can be written in any language, not only one can check if a node is legitimate, but also one can put safeguards on the auto-signing process, making sure – for example – that a given node is allowed to be signed once and only once. Preventing any forgery, that might result from brute force attack or any malicious user understanding the logic at a later point.
Trusted Facts
Not only the auto-signing script can take advantage of those custom attributes, but also facter.
Any custom attribute added in the CSR will become a trusted fact. One should see trusted facts
as immutable piece of information tied to the node certificate. If one wants to change those fact,
s/he will have to renew the certificate.
Trusted facts are available from your manifests in the following way :
$trusted[extensions][1.3.6.1.4.1.34380.1.2.1.1]
where the second dimension of the array meets the expected OID.
Conclusion
Until today the logic that Puppet provided to autosign requests was too basic, not secured. Offering the flexibility of signing CSR based on custom attributes with custom scripts brings the auto-signing feature to a whole new level that solves both management and securities issues.
The script doesn’t work, it gets exited with zero status, but doesn’t help in signing the certs, can you check ?
This is nice example __BUT__ has some a major flaw. The CSR content IS NOT passed in as stdin. The CERTNAME is passed in as stdin. This means you have to read the CSR file.
like this…
“`
csr = File.open(/etc/puppetlabs/puppet/ssl/ca/request/#{ARGV[0]}.pem).read
“`
Also… your parsing of the extensions is pretty messy and overly complicated.
I went with this:
“`
extensions = {}
request = OpenSSL::X509::Request.new csr
csr_attr = request.attributes.first
extensions = {}
csr_attr.value.value.first.value.each do |prop|
extensions[prop.value.first.value] = OpenSSL::ASN1.decode(prop.value.last.value).value
end
“`
Hmm… formatting got kind of butchered there. Sorry.