Sunday, April 27, 2014

Dane-ish

As we were moving getdns towards release we started talking about what would be an interesting thing to do with the library and language bindings to show what could be possible.  With the DANE work in the IETF moving along smartly, Allison suggested doing something sort of dane-ish, by pulling down TLSA records, extracting the X.509 certificate and then the public key from the certificate, and using that key to encrypt some text.  So, that's what we did.

[Please note that we assume some basic familiarity with DNS.]

Sample Python code is here.  We were pretty surprised by how little code was required to do something this fundamentally complicated, but the getdns api and the M2Crypto module take care of the really labor-intensive pieces of work and allow the programmer to remain focused on the application.

First things first: you will need the Python getdns and M2Crypto modules.  (We also import sys for utility reasons).

import getdns
import M2Crypto as m2
from M2Crypto import RSA
import sys

In our example, we've got a known good TLSA certificate coded in, but you can use another:

    tls_name = '77fa5113ab6a532ce2e6901f3bd3351c0db5845e0b1b5fb09907808d._smimecert.getdnsapi.org'

    if len(sys.argv) == 2:
        tls_name = sys.argv[1]

Then, we perform the DNS query.  The first thing we do is get a getdns context.  We set up our extensions, which is just a Python dictionary, to tell the getdns library that we would like DNSSEC validation of the query response.  Note also that rather than using getdns.address,as we did in our previous post, we're using getdns.general.  That allows us to request an arbitrary query (or resource record) type, in this case TLSA.

    c = getdns.context_create()
    extensions = { 'dnssec_return_status' : getdns.GETDNS_EXTENSION_TRUE }
    results = getdns.general(c, tls_name, getdns.GETDNS_RRTYPE_TLSA, extensions=extensions)

The results are returned in the form of a Python dictionary.  A future post will pull that dictionary apart for you, but in the meantime there are a few key elements in the dictionary that you'll need to examine.
The first thing you'll want to do is to make sure that you received a response and that the DNS resolver library considered the query successful.  That information is returned in the 'status' element of the results dictionary.  getdns.GETDNS_RESPSTATATUS_GOOD indicates success.  Other possible values include getdns.GETDNS_RESPSTATUS_NO_NAME (queries returned negative responses),
getdns.GETDNS_RESPSTATUS_ALL_TIMEOUT (there was no answer within the time limit),
and getdns.GETDNS_RESPSTATUS_NO_SECURE_ANSWERS (the programmer requested that only DNSEC-secured records be returned, and at least one response was received but no response was determined to be secured by DNSSEC).

    if results['status'] != getdns.GETDNS_RESPSTATUS_GOOD:
        print 'query status is {0}'.format(results['status'])
        sys.exit(1)

DANE is predicated on the availability of DNSSEC-protected records, to provide assurance that the certificate (or other credential) being returned in the DNS data is legitimate.  So, we look for the first "secure" response in the response dictionary, and we've written a small function to do that for us.  The first thing we do is to make sure that we did indeed receive at least one record.  Because the response dictionary is so deeply nested we make some assignments to make the code both easier to type and easier to read.  Once we've determined that we did receive a response and that the record is valid under DNSSEC, we look for our  certificate by pulling out a record of type getdns.GETDNS_RRTYPE_TLSA (the RR type in our initial query).  When we find it, we return it.

def get_first_secure_response(results):
    replies_tree = results['replies_tree']
    if (not replies_tree) or (not len(replies_tree)) or (not replies_tree[0]['answer']) or (not len(replies_tree[0]['answer'])):
        print 'empty answer list'
        return None
    else:
        reply = replies_tree[0]
        if reply['dnssec_status'] != getdns.GETDNS_DNSSEC_SECURE:
            print 'insecure reply'
            return None                      
        answer = replies_tree[0]['answer']
        record = [ x for x in answer if x['type'] is getdns.GETDNS_RRTYPE_TLSA ]
        if len(record) == 0:
            print 'no answers of type TLSA'
            return None
        return record[0]
   

The next step is to pull out the certificate.  The certificate is returned as a binary blob - not human-readable and not immediately usable by the M2Crypto module.  However, we can load it into a M2Crypto x509 object quite easily, using m2.X509.load_cert_der_string.  From there we pull out the public key (get_pubkey().get_rsa()) and use it to encrypt the string "A chunk of text".  The result of the encryption is another binary blob, so we make it human-readable for printing by base64-encoding it before writing it to the console.

        record = get_first_secure_response(results)
        cert = record['rdata']['certificate_association_data']
        try:
            x509 = m2.X509.load_cert_der_string(cert)
            rsakey = x509.get_pubkey().get_rsa()
            encrypted = rsakey.public_encrypt("A chunk of text", RSA.pkcs1_oaep_padding)
            print encrypted.encode('base64')
        except:
            print 'Error: ', sys.exc_info()[0]
            sys.exit(1)

(If you're not familiar with RSA and/or would like an explanation of OAEP padding, this blog post provides a good overview.)

So that's basically it.  The actual DNS part of this exercise, retrieving a TLSA record, is made pretty trivial through the use of the getdns Python module, and the M2Crypto module also manages to hide an astonishing amount of OpenSSL complexity from the programmer.

Our next post on getdns will take a closer look at what's returned from a query.

No comments:

Post a Comment