From: Remi Gacogne Date: Wed, 30 Dec 2015 08:20:30 +0000 (+0100) Subject: dnsdist: Add a basic regression test for DNSCrypt X-Git-Tag: dnsdist-1.0.0-alpha2~128^2~1 X-Git-Url: https://granicus.if.org/sourcecode?a=commitdiff_plain;h=b8db58a230959585880e15c097851c5139d90f45;p=pdns dnsdist: Add a basic regression test for DNSCrypt I could not find any DNSCrypt client implementation in python without zillions of dependencies, so I wrote a basic one depending only on dnspython and libnacl bindings. --- diff --git a/.travis.yml b/.travis.yml index 45fd464fb..4d227861e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -280,13 +280,14 @@ script: - cd pdns/dnsdistdist - tar xf dnsdist*.tar.bz2 - cd dnsdist-* - - ./configure --enable-unit-tests + - ./configure --enable-unit-tests --enable-libsodium --enable-dnscrypt - make -k -j3 - ./testrunner - cp ./dnsdist ../../../regression-tests.dnsdist/ - cd ../../../regression-tests.dnsdist - DNSDISTBIN=./dnsdist ./runtests - rm -f ./dnsdist + - rm -f ./DNSCryptResolver.cert ./DNSCryptResolver.key - cd .. - rm -rf pdns/dnsdistdist/dnsdist-*/ diff --git a/pdns/README-dnsdist.md b/pdns/README-dnsdist.md index 348446895..c9fe5329e 100644 --- a/pdns/README-dnsdist.md +++ b/pdns/README-dnsdist.md @@ -661,6 +661,7 @@ To generate the provider and resolver certificates and keys, you can simply do: ``` > generateDNSCryptProviderKeys("/path/to/providerPublic.key", "/path/to/providerPrivate.key") +Provider fingerprint is: E1D7:2108:9A59:BF8D:F101:16FA:ED5E:EA6A:9F6C:C78F:7F91:AF6B:027E:62F4:69C3:B1AA > generateDNSCryptCertificate("/path/to/providerPrivate.key", "/path/to/resolver.cert", "/path/to/resolver.key", serial, validFrom, validUntil) ``` @@ -675,6 +676,12 @@ You can display the currently configured DNSCrypt binds with: 0 127.0.0.1:8443 2.name 14 2016-04-10 08:14:15 0 - ``` +If you forgot to write down the provider fingerprint value after generating the provider keys, you can use `printDNSCryptProviderFingerprint()` to retrieve it later: +``` +> printDNSCryptProviderFingerprint("/path/to/providerPublic.key") +Provider fingerprint is: E1D7:2108:9A59:BF8D:F101:16FA:ED5E:EA6A:9F6C:C78F:7F91:AF6B:027E:62F4:69C3:B1AA +``` + All functions and types ----------------------- Within `dnsdist` several core object types exist: diff --git a/pdns/dnsdist-dnscrypt.cc b/pdns/dnsdist-dnscrypt.cc index 9b9c991a9..c94394e9a 100644 --- a/pdns/dnsdist-dnscrypt.cc +++ b/pdns/dnsdist-dnscrypt.cc @@ -11,7 +11,7 @@ int handleDnsCryptQuery(DnsCryptContext* ctx, char* packet, uint16_t len, std::s ctx->parsePacket(packet, len, query, tcp, decryptedQueryLen); if (query->valid == false) { - vinfolog("Dropping DnsCrypt invalid query"); + vinfolog("Dropping DNSCrypt invalid query"); return false; } diff --git a/pdns/dnsdist-lua2.cc b/pdns/dnsdist-lua2.cc index 8bd81e2a6..ba0310bd5 100644 --- a/pdns/dnsdist-lua2.cc +++ b/pdns/dnsdist-lua2.cc @@ -380,6 +380,30 @@ void moreLua() #endif }); + g_lua.writeFunction("printDNSCryptProviderFingerprint", [](const std::string& publicKeyFile) { + setLuaNoSideEffect(); +#ifdef HAVE_DNSCRYPT + unsigned char publicKey[DNSCRYPT_PROVIDER_PUBLIC_KEY_SIZE]; + + try { + ifstream file(publicKeyFile); + file.read((char *) &publicKey, sizeof(publicKey)); + + if (file.fail()) + throw std::runtime_error("Invalid dnscrypt provider public key file " + publicKeyFile); + + file.close(); + g_outputBuffer="Provider fingerprint is: " + DnsCryptContext::getProviderFingerprint(publicKey) + "\n"; + } + catch(std::exception& e) { + errlog(e.what()); + g_outputBuffer="Error: "+string(e.what())+"\n"; + } +#else + g_outputBuffer="Error: DNSCrypt support is not enabled.\n"; +#endif + }); + g_lua.writeFunction("generateDNSCryptCertificate", [](const std::string& providerPrivateKeyFile, const std::string& certificateFile, const std::string privateKeyFile, uint32_t serial, time_t begin, time_t end) { setLuaNoSideEffect(); #ifdef HAVE_DNSCRYPT diff --git a/regression-tests.dnsdist/.gitignore b/regression-tests.dnsdist/.gitignore index 5132bb74a..8fda00e53 100644 --- a/regression-tests.dnsdist/.gitignore +++ b/regression-tests.dnsdist/.gitignore @@ -3,5 +3,6 @@ /*.pid /*.pyc dnsdist_*.conf +DNSCryptResolver* .dnsdist_history .history diff --git a/regression-tests.dnsdist/DNSCryptProviderPrivate.key b/regression-tests.dnsdist/DNSCryptProviderPrivate.key new file mode 100644 index 000000000..d6a9c3909 Binary files /dev/null and b/regression-tests.dnsdist/DNSCryptProviderPrivate.key differ diff --git a/regression-tests.dnsdist/DNSCryptProviderPublic.key b/regression-tests.dnsdist/DNSCryptProviderPublic.key new file mode 100644 index 000000000..6f9fb4f08 --- /dev/null +++ b/regression-tests.dnsdist/DNSCryptProviderPublic.key @@ -0,0 +1 @@ +á×!šY¿ñúí^êjŸlǏ‘¯k~bôiñª \ No newline at end of file diff --git a/regression-tests.dnsdist/dnscrypt.py b/regression-tests.dnsdist/dnscrypt.py new file mode 100644 index 000000000..bb6689595 --- /dev/null +++ b/regression-tests.dnsdist/dnscrypt.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python2 +import dns +import dns.message +import socket +import struct +import time +import libnacl +import libnacl.utils + +class DNSCryptResolverCertificate: + DNSCRYPT_CERT_MAGIC = '\x44\x4e\x53\x43' + DNSCRYPT_ES_VERSION = '\x00\x01' + DNSCRYPT_PROTOCOL_MIN_VERSION = '\x00\x00' + + def __init__(self, serial, validFrom, validUntil, publicKey, clientMagic): + self.serial = serial + self.validFrom = validFrom + self.validUntil = validUntil + self.publicKey = publicKey + self.clientMagic = clientMagic + + def isValid(self): + now = time.time() + return self.validFrom <= now and self.validUntil >= now + + @staticmethod + def fromBinary(binary, providerFP): + if len(binary) != 124: + raise Exception("Invalid binary certificate") + + certMagic = binary[0:4] + esVersion = binary[4:6] + protocolMinVersion = binary[6:8] + + if certMagic != DNSCryptResolverCertificate.DNSCRYPT_CERT_MAGIC or esVersion != DNSCryptResolverCertificate.DNSCRYPT_ES_VERSION or protocolMinVersion != DNSCryptResolverCertificate.DNSCRYPT_PROTOCOL_MIN_VERSION: + raise Exception("Invalid binary certificate") + + orig = libnacl.crypto_sign_open(binary[8:124], providerFP) + + resolverPK = orig[0:32] + clientMagic = orig[32:40] + serial = struct.unpack_from("I", orig[40:44]) + validFrom = struct.unpack_from("!I", orig[44:48])[0]; + validUntil = struct.unpack_from("!I", orig[48:52])[0]; + return DNSCryptResolverCertificate(serial, validFrom, validUntil, resolverPK, clientMagic) + +class DNSCryptClient: + DNSCRYPT_NONCE_SIZE = 24 + DNSCRYPT_MAC_SIZE = 16 + DNSCRYPT_PADDED_BLOCK_SIZE = 64 + DNSCRYPT_MIN_UDP_LENGTH = 256 + DNSCRYPT_RESOLVER_MAGIC = '\x72\x36\x66\x6e\x76\x57\x6a\x38' + + @staticmethod + def _addrToSocketType(addr): + result = None + try: + socket.inet_pton(socket.AF_INET6, addr) + result = socket.AF_INET6 + except socket.error: + socket.inet_pton(socket.AF_INET, addr) + result = socket.AF_INET + + return result + + def __init__(self, providerName, providerFingerprint, resolverAddress, resolverPort=443): + self._providerName = providerName + self._providerFingerprint = providerFingerprint.lower().replace(':', '').decode('hex') + self._resolverAddress = resolverAddress + self._resolverPort = resolverPort + self._resolverCertificates = [] + self._publicKey, self._privateKey = libnacl.crypto_box_keypair() + + addrType = self._addrToSocketType(self._resolverAddress) + self._sock = socket.socket(addrType, socket.SOCK_DGRAM) + self._sock.connect((self._resolverAddress, self._resolverPort)) + self._sock.settimeout(2) + + def _sendQuery(self, queryContent): + self._sock.send(queryContent) + data = self._sock.recv(4096) + return data + + def _hasValidResolverCertificate(self): + + for cert in self._resolverCertificates: + if cert.isValid(): + return True + + return False + + def _getResolverCertificates(self): + query = dns.message.make_query(self._providerName, dns.rdatatype.TXT, dns.rdataclass.IN) + data = self._sendQuery(query.to_wire()) + + response = dns.message.from_wire(data) + if response.rcode() != dns.rcode.NOERROR or len(response.answer) != 1: + raise Exception("Invalid response to public key request") + + an = response.answer[0] + if an.rdclass != dns.rdataclass.IN or an.rdtype != dns.rdatatype.TXT or len(an.items) == 0: + raise Exception("Invalid response to public key request") + + for item in an.items: + if len(item.strings) != 1: + continue + + cert = DNSCryptResolverCertificate.fromBinary(item.strings[0], self._providerFingerprint) + if cert.isValid(): + self._resolverCertificates.append(cert) + + def _getResolverCertificate(self): + certs = self._resolverCertificates + result = None + for cert in certs: + if cert.isValid(): + if result is None or cert.serial > result.serial: + result = cert + + return result + + @staticmethod + def _generateNonce(): + nonce = libnacl.utils.rand_nonce() + return nonce[:(DNSCryptClient.DNSCRYPT_NONCE_SIZE / 2)] + + def _encryptQuery(self, queryContent, resolverCert, nonce): + header = resolverCert.clientMagic + self._publicKey + nonce + requiredSize = len(header) + self.DNSCRYPT_MAC_SIZE + len(queryContent) + paddingSize = self.DNSCRYPT_PADDED_BLOCK_SIZE - (len(queryContent) % self.DNSCRYPT_PADDED_BLOCK_SIZE) + # padding size should be DNSCRYPT_PADDED_BLOCK_SIZE <= padding size <= 4096 + if requiredSize < self.DNSCRYPT_MIN_UDP_LENGTH: + paddingSize += self.DNSCRYPT_MIN_UDP_LENGTH - requiredSize + requiredSize = self.DNSCRYPT_MIN_UDP_LENGTH + + padding = '\x80' + idx = 0 + while idx < (paddingSize - 1): + padding = padding + '\x00' + idx += 1 + + data = queryContent + padding + nonce = nonce + ('\x00'*(self.DNSCRYPT_NONCE_SIZE / 2)) + box = libnacl.crypto_box(data, nonce, resolverCert.publicKey, self._privateKey) + return header + box + + def _decryptResponse(self, encryptedResponse, resolverCert, clientNonce): + resolverMagic = encryptedResponse[:8] + if resolverMagic != self.DNSCRYPT_RESOLVER_MAGIC: + raise Exception("Invalid encrypted response: bad resolver magic") + + nonce = encryptedResponse[8:32] + if nonce[0:self.DNSCRYPT_NONCE_SIZE / 2] != clientNonce: + raise Exception("Invalid encrypted response: bad nonce") + + cleartext = libnacl.crypto_box_open(encryptedResponse[32:], nonce, resolverCert.publicKey, self._privateKey) + idx = len(cleartext) - 1 + while idx > 0: + if cleartext[idx] != '\x00': + break + idx -= 1 + + if idx == 0 or cleartext[idx] != '\x80': + raise Exception("Invalid encrypted response: invalid padding") + + idx -= 1 + paddingLen = len(cleartext) - idx + + return cleartext[:idx+1] + + def query(self, queryContent): + + if not self._hasValidResolverCertificate(): + self._getResolverCertificates() + + nonce = self._generateNonce() + resolverCert = self._getResolverCertificate() + if resolverCert is None: + raise Exception("No valid certificate found") + encryptedQuery = self._encryptQuery(queryContent, resolverCert, nonce) + encryptedResponse = self._sendQuery(encryptedQuery) + response = self._decryptResponse(encryptedResponse, resolverCert, nonce) + return response diff --git a/regression-tests.dnsdist/dnsdisttests.py b/regression-tests.dnsdist/dnsdisttests.py index f66f8c8a0..a650c6c76 100644 --- a/regression-tests.dnsdist/dnsdisttests.py +++ b/regression-tests.dnsdist/dnsdisttests.py @@ -2,6 +2,7 @@ import clientsubnetoption import dns +import dns.message import Queue import os import socket diff --git a/regression-tests.dnsdist/requirements.txt b/regression-tests.dnsdist/requirements.txt index 5962a6862..e33025f32 100644 --- a/regression-tests.dnsdist/requirements.txt +++ b/regression-tests.dnsdist/requirements.txt @@ -1,2 +1,3 @@ dnspython>=1.11 nose==1.3.0 +libnacl>=1.4.3 diff --git a/regression-tests.dnsdist/test_DNSCrypt.py b/regression-tests.dnsdist/test_DNSCrypt.py new file mode 100644 index 000000000..0b4e233b9 --- /dev/null +++ b/regression-tests.dnsdist/test_DNSCrypt.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +import dns +import dns.message +import os +import socket +import subprocess +import time +import unittest +from dnsdisttests import DNSDistTest +import dnscrypt + +class TestDNSCrypt(DNSDistTest): + """ + dnsdist is configured to accept DNSCrypt queries on 127.0.0.1:_dnsDistPortDNSCrypt. + The provider's keys have been generated with: + generateDNSCryptProviderKeys("DNSCryptProviderPublic.key", "DNSCryptProviderPrivate.key") + Be careful to change the _providerFingerprint below if you want to regenerate the keys. + """ + + _dnsDistPort = 5340 + _dnsDistPortDNSCrypt = 8443 + _config_template = """ + generateDNSCryptCertificate("DNSCryptProviderPrivate.key", "DNSCryptResolver.cert", "DNSCryptResolver.key", 42, %d, %d) + addDNSCryptBind("127.0.0.1:%d", "%s", "DNSCryptResolver.cert", "DNSCryptResolver.key") + newServer{address="127.0.0.1:%s"} + """ + + _dnsdistcmd = (os.environ['DNSDISTBIN'] + " -C dnsdist_DNSCrypt.conf --acl 127.0.0.1/32 -l 127.0.0.1:" + str(_dnsDistPort)).split() + _providerFingerprint = 'E1D7:2108:9A59:BF8D:F101:16FA:ED5E:EA6A:9F6C:C78F:7F91:AF6B:027E:62F4:69C3:B1AA' + _providerName = "2.provider.name" + + @classmethod + def startDNSDist(cls, shutUp=True): + print("Launching dnsdist..") + # valid from 60s ago until 2h from now + validFrom = time.time() - 60 + validUntil = time.time() + 7200 + with open('dnsdist_DNSCrypt.conf', 'w') as conf: + conf.write(cls._config_template % (validFrom, validUntil, cls._dnsDistPortDNSCrypt, cls._providerName, str(cls._testServerPort))) + + print(' '.join(cls._dnsdistcmd)) + if shutUp: + with open(os.devnull, 'w') as fdDevNull: + cls._dnsdist = subprocess.Popen(cls._dnsdistcmd, close_fds=True, stdout=fdDevNull, stderr=fdDevNull) + else: + cls._dnsdist = subprocess.Popen(cls._dnsdistcmd, close_fds=True) + + time.sleep(1) + + if cls._dnsdist.poll() is not None: + cls._dnsdist.terminate() + cls._dnsdist.wait() + sys.exit(cls._dnsdist.returncode) + + + def testSimpleA(self): + """ + Send an encrypted A query. + """ + client = dnscrypt.DNSCryptClient(self._providerName, self._providerFingerprint, "127.0.0.1", 8443) + name = 'a.dnscrypt.tests.powerdns.com.' + query = dns.message.make_query(name, 'A', 'IN') + response = dns.message.make_response(query) + rrset = dns.rrset.from_text(name, + 3600, + dns.rdataclass.IN, + dns.rdatatype.A, + '127.0.0.1') + response.answer.append(rrset) + + self._toResponderQueue.put(response) + data = client.query(query.to_wire()) + receivedResponse = dns.message.from_wire(data) + receivedQuery = None + if not self._fromResponderQueue.empty(): + receivedQuery = self._fromResponderQueue.get(query) + + self.assertTrue(receivedQuery) + self.assertTrue(receivedResponse) + receivedQuery.id = query.id + receivedResponse.id = response.id + self.assertEquals(query, receivedQuery) + self.assertEquals(response, receivedResponse) + +if __name__ == '__main__': + unittest.main() + exit(0)