# -*- coding: utf-8 -*- """ DNSSEC Demo. """ # Stupid kludge for el6 compablity, jinja2-26 (rhbz867105) import __main__ __main__.__requires__ = ['jinja2 >= 2.4'] import pkg_resources # pylint: disable=unused-import # Flask template engine, pylint: disable=invalid-name from flask import Flask, request, render_template, flash, jsonify app = Flask(__name__) app.debug = True app.secret_key = 'very_secret_key_for_flash_function_only' import base64 import binascii import re import subprocess import unbound from unbound import RR_TYPE_DNSKEY, RR_TYPE_TXT, RR_TYPE_NS RR_TYPE_CDS = 59 # Missing in unbound # Regex to match allowed domain name (*.ca) ALLOWED_DOMAIN = r'^.+\.ca$' ROOTANCHOR = '/var/lib/unbound/root.key' KSKHACK = '/tmp/ksk.hack' # Who are we collecting/generating DS records for PARENTAL_RESOLV = '/tmp/cdsres.conf' PARENTAL_DS_TTL = 86400 PARENTAL_ZONE = 'ca' PARENTAL_NS = '199.4.144.2' # Uncomment these to test with "demo.nohats.ca" #PARENTAL_ZONE = 'nohats.ca' #PARENTAL_NS = '193.110.157.102' # el6 / python 2.6 backport kludge if 'check_output' not in dir(subprocess): # duck punch it in! def checkout(*popenargs, **kwargs): """ Python 2.6 backport kludge """ if 'stdout' in kwargs: raise ValueError('stdout argument not allowed, it will be overridden.') process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) output, dummy_err = process.communicate() retcode = process.poll() if retcode: cmd = kwargs.get('args') if cmd is None: cmd = popenargs[0] raise subprocess.CalledProcessError(retcode, cmd) return output subprocess.check_output = checkout def get_unbound(ret): """ Initialize unbound context. """ # Create unbound cache ctx = unbound.ub_ctx() ret['debug'].append('Adding Trust Anchors from root file') ctx.add_ta_file(ROOTANCHOR) # Use the local cache and emulate /etc/hosts ctx.hosts('/etc/hosts') # Use local servers for cache #fp = open(PARENTAL_RESOLV, 'w') #fp.write('nameserver {}\n'.format(PARENTAL_NS)) #fp.close() ctx.resolvconf('/etc/resolv.conf') return ctx def get_nameservers(domain, ctx, ret): """ Grab list of nameservers for domain. """ dummy_status, result = ctx.resolve(domain, RR_TYPE_NS) if result.secure: ret['debug'].append('NS lookup was secure') else: ret['debug'].append('NS lookup was insecure') if not result.havedata: # Failure if result.secure: sec = '[secure denial]' else: sec = '[not DNSSEC validated]' ret['error'] = 'No such domain "{0}" found {1}'.format(domain, sec) return # Success. Ensure it is still insecure if result.secure: ret['debug'].append( 'Delegation for "{0}" already secure - ' 'continuing only for testing '.format(domain)) CHILD_NSrrset = result.data.domain_list CHILD_NSrrset.sort() ret['debug'].append('NS RRset found at child: {0}'.format( ' '.join(CHILD_NSrrset))) return CHILD_NSrrset def get_parent(domain, ret): """ check if "we" are the parent unbound seems to no give us access to AUTHORITY section we need for this so for now hack with dig cmd #PARENT_NSrrset = [] #ctxns = unbound.ub_ctx() #ctxns.resolvconf(PARENTAL_RESOLV) #status, NSresult = ctxns.resolve(domain, RR_TYPE_NS) #if NSresult.havedata: # PARENT_NSrrset = NSresult.data.domain_list # PARENT_NSrrset.sort() #else: # #print NSresult.data.raw # sys.exit('We dont delegate that domain: %s'%result.rcode_str) #print '# NS RRset found at parent: %s'%' '.join(PARENT_NSrrset) """ try: output = subprocess.check_output([ '/usr/bin/dig', 'ns', domain, '@' + PARENTAL_NS]) except subprocess.CalledProcessError: ret['error'] = 'dig at parental NS failed' return GLUErrset = [] for line in output.split('\n'): if 'IN\tNS\t' not in line: continue sline = line.split() if sline[0] == domain + '.': GLUErrset.append(sline[-1]) else: ret['debug'].append('Ignoring indirect NS record: {0}'.format(line)) if not GLUErrset: ret['error'] = ( 'domain "{0}" is not delegated by "{1}" via nameserver "{2}"'.format( domain, PARENTAL_ZONE, PARENTAL_NS)) return GLUErrset.sort() ret['debug'].append('NS RRset found at parent: {0}'.format( ' '.join(GLUErrset))) return GLUErrset def get_dnskeys(domain, ctx): """ Grab dnskey """ dnskeys = [] dummy_status, result = ctx.resolve(domain, RR_TYPE_DNSKEY) if result.havedata: for rdata in result.data.raw: # Skip printing keys flags = int(binascii.b2a_hex(rdata[0:2]), 16) if flags % 2 == 1: RDATAflags = int(binascii.b2a_hex(rdata[0:2]), 16) RDATAproto = int(binascii.b2a_hex(rdata[2]), 16) RDATAalgo = int(binascii.b2a_hex(rdata[3]), 16) RDATApubkey = base64.b64encode(rdata[4:]) dnskeys.append('{0}. 3600 IN DNSKEY {1} {2} {3} {4}'.format( domain, RDATAflags, RDATAproto, RDATAalgo, RDATApubkey)) return dnskeys def verify_local_trust(domain, ctx, ret): """ Verify our local trust anchor. """ dummy_status, result = ctx.resolve(domain, RR_TYPE_DNSKEY) if result.havedata: if result.secure: # If we had a native representation of DNSKEY we could compare # the sets :( ret['debug'].append('DNSKEY RRset validates itself') else: ret['error'] = ( 'Validation using DNSKEYs obtained from "{0}" ' 'failed to validate itself'.format(domain)) return 1 def get_delegate_txt(domain, ctx, ret): """ Grab _delegate TXT. """ TXTkeytags = [] dummy_status, result = ctx.resolve('_delegate.' + domain, RR_TYPE_TXT) if not result.havedata: ret['debug'].append('No TXT record found at _delegate.{0}'.format( domain)) elif result.secure: for rdata in result.data.raw: TXTkeytags.append(str(rdata[1:])) # Eat garbage character? No. TXTkeytags.sort() ret['debug'].append( 'Delegation requested via self-validated TXT for keytags: {0}' .format(', '.join(TXTkeytags))) return TXTkeytags def check_cds(domain, ctx, ret): """ Check CDS, again using the cache with their Trust Anchor """ CDSkeytags = [] CDSRRset = [] dummy_status, result = ctx.resolve(domain, RR_TYPE_CDS) if result.havedata: for cds in result.data.raw: tag = int(binascii.b2a_hex(cds[0:2]), 16) proto = int(binascii.b2a_hex(cds[2]), 16) dtype = int(binascii.b2a_hex(cds[3]), 16) digest = cds[4:].encode('hex').upper() CDSRR = '{0}. {1} IN DS {2} {3} {4} {5}'.format( domain, PARENTAL_DS_TTL, tag, proto, dtype, digest) CDSkeytags.append(str(tag)) CDSRRset.append(CDSRR) ret['debug'].append( 'Delegation requested via self-validated CDS for keytags: {0}' .format(' '.join(CDSkeytags))) return CDSRRset def generate_cds(domain, dnskeys, TXTkeytags, ret): """ Generate our own CDS - should end up doing this natively from raw unbound data. """ ret['debug'].append( 'No CDS record available, generating DS from ' 'DNSKEYs specified in TXT keytag') # We sadly need to write to a file to convert DNSKEY to DS with open(KSKHACK, 'w') as fp: for dnskey in dnskeys: fp.write(dnskey + '\n') try: DSrrset = subprocess.check_output([ '/usr/bin/ldns-key2ds', '-2', '-n', KSKHACK]) except subprocess.CalledProcessError: ret['error'] = 'Failed to convert DNSKEY to DS' return 1 DSrrset = DSrrset.replace('\t', ' ') for ds in DSrrset.splitlines(): parts = ds.split() rr = '{0}. {1} IN DS {2} {3} {4} {5}'.format( domain, PARENTAL_DS_TTL, parts[4], parts[5], parts[6], parts[7].upper()) if parts[4] in TXTkeytags: ret['rr'].append(rr) else: ret['debug'].append( 'Calculated DS keytag "{0}" for "{1}" was not listed in TXT ' 'record'.format(parts[4], domain)) ret['debug'].append('Not added: {0}'.format(rr)) def save_result(ret): """ Export data - would send following validated DS records into backend for inclusion in the zone PARENTAL_ZONE """ ret['debug'].append('') ret['debug'].append('### EXPORT MODULE ###') for ds in ret['rr']: ret['debug'].append(ds) def gends(domain): """ Do the magic. """ # Empty initial return value ret = { 'debug': [], 'domain': domain, 'error': '', 'rr': [], } # We should use "domain.ca." instead of "domain.ca", but unbound # (encodings.idna) seems to crash under flask if we do it. So don't... #domain = domain + '.' # Create unbound cache ctx = get_unbound(ret) # Grab list of nameservers CHILD_NSrrset = get_nameservers(domain, ctx, ret) if not CHILD_NSrrset: return ret # Check if "we" are the parent GLUErrset = get_parent(domain, ret) if not GLUErrset: return ret # Verify that nameservers match if set(CHILD_NSrrset) != set(GLUErrset): ret['error'] = ( 'Parent and Child do not agree on NS records for "{0}" - aborted' .format(domain)) return ret ret['debug'].append( 'Parent and Child agree on NS RRset, looking up DNSKEY KSK') # Grab dnskey dnskeys = get_dnskeys(domain, ctx) # Add dnskeys to local trust anchor ctxtrusted = unbound.ub_ctx() ctxtrusted.add_ta_file(ROOTANCHOR) for dnskey in dnskeys: ctxtrusted.add_ta(dnskey) ret['debug'].append( 'Added DNSKEY Trust Anchor for {0} into validating resolver' .format(domain)) if verify_local_trust(domain, ctxtrusted, ret): return ret # Did they give us permission via CDS or TXT? - let's use the cache # with their Trust Anchor TXTkeytags = get_delegate_txt(domain, ctxtrusted, ret) # Check CDS, again using the cache with their Trust Anchor CDSRRset = check_cds(domain, ctxtrusted, ret) # CDSRRset now contains the DS RRset the child wants to use and it # validates with itself if CDSRRset: ret['debug'].append( 'No DS generation was required, using validated CDS record as ' 'template') ret['rr'] = CDSRRset elif generate_cds(domain, dnskeys, TXTkeytags, ret): return ret # Failed to generate our own CDS # Export final result data save_result(ret) return ret def validate_domain(domain, outformat): """ User entered valid domain. Show results. """ result = gends(domain) # Uncomment this to hide debug #del result['debug'] if outformat == 'json': return jsonify(**result) return render_template( 'result.html', domain=domain, result=result) @app.route('/', methods=['GET', 'POST']) @app.route('/gends/', methods=['GET', 'POST']) def main_page(domainarg=None): """ Start page: Ask for domain name. """ if domainarg: domain = domainarg.lower() outformat = 'json' else: domain = request.values.get('domain', '').lower() outformat = 'html' if domain: if not re.match(ALLOWED_DOMAIN, domain): error = 'Sorry, that domain is not allowed.' if outformat == 'json': return jsonify(domain=domain, error=error) flash(error, 'danger') else: return validate_domain(domain, outformat) return render_template( 'start.html', domain=domain) if __name__ == '__main__': app.run()