diff --git a/lib/server/auth.js b/lib/server/auth.js index 4e0c8b4..3d6a4e1 100644 --- a/lib/server/auth.js +++ b/lib/server/auth.js @@ -23,6 +23,11 @@ class AuthServer extends DNSServer { this.initOptions(options); } + setZSKFromString(str) { + this.zone.setZSKFromString(str); + return this; + } + setOrigin(name) { this.zone.setOrigin(name); return this; diff --git a/lib/zone.js b/lib/zone.js index 2568026..d990cbb 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -14,6 +14,10 @@ const fs = require('bfile'); const constants = require('./constants'); const util = require('./util'); const wire = require('./wire'); +const dnssec = require('./dnssec'); +const {keyFlags} = dnssec; +const {ZONE} = keyFlags; + const { types, @@ -46,8 +50,10 @@ class Zone { this.origin = '.'; this.count = 0; this.names = new Map(); - this.wild = new RecordMap(); + this.wild = new RecordMap(this); this.nsec = new NameList(); + this.zskpriv = null; + this.zskkey = null; this.setOrigin(origin); } @@ -65,6 +71,12 @@ class Zone { return this; } + setZSKFromString(str) { + const [alg, zskpriv] = dnssec.decodePrivate(str); + this.zskpriv = zskpriv; + this.zskkey = dnssec.makeKey(this.origin, alg, zskpriv, ZONE); + } + setOrigin(origin) { if (origin == null) origin = '.'; @@ -95,7 +107,7 @@ class Zone { this.wild.insert(rr); } else { if (!this.names.has(rr.name)) - this.names.set(rr.name, new RecordMap()); + this.names.set(rr.name, new RecordMap(this)); const map = this.names.get(rr.name); @@ -121,8 +133,8 @@ class Zone { if (map) map.push(name, type, an); - - this.wild.push(name, type, an); + else + this.wild.push(name, type, an); return this; } @@ -145,12 +157,26 @@ class Zone { return map.rrs.has(type); } - glue(name, an) { + glue(name, an, type, ns) { assert(util.isFQDN(name)); assert(Array.isArray(an)); - this.push(name, types.A, an); - this.push(name, types.AAAA, an); + const initial = an.length; + + if (!type) { + this.push(name, types.A, an); + this.push(name, types.AAAA, an); + } else { + this.push(name, type, an); + } + + const final = an.length; + + // If the only answer we have is a CNAME with no "glue", + // include an SOA in the authority section, just like + // if we had no answer for a name we're authoritative over. + if (initial === final) + this.push(name, types.SOA, ns); return this; } @@ -158,14 +184,15 @@ class Zone { find(name, type) { const an = this.get(name, type); const ar = []; + const ns = []; for (const rr of an) { switch (rr.type) { case types.CNAME: - this.glue(rr.data.target, an); + this.glue(rr.data.target, an, type, ns); break; case types.DNAME: - this.glue(rr.data.target, an); + this.glue(rr.data.target, an, type, ns); break; case types.NS: this.glue(rr.data.ns, ar); @@ -182,7 +209,7 @@ class Zone { } } - return [an, ar]; + return [an, ar, ns]; } getHints() { @@ -230,12 +257,17 @@ class Zone { assert(util.isFQDN(name)); assert((type & 0xffff) === type); - const [an, ar] = this.find(name, type); + const labels = util.split(name); + const zone = util.from(name, labels, -this.count); + const authority = util.equal(zone, this.origin); + + let [an, ar, ns] = this.find(name, type); + let glue; // Do we have an answer? if (an.length > 0) { // Are we authoritative for this name? - if (!this.has(name, types.SOA)) { + if (!authority) { // If we're not authoritative for this // name, this is probably a request // for a DS or NSEC record. @@ -246,34 +278,17 @@ class Zone { return [[], an, ar, false, true]; } - // Send the answer but do - // not set the `aa` bit. return [an, [], ar, false, true]; } // We're authoritative. Send the // answer and set the `aa` bit. - return [an, [], ar, true, true]; - } - - const labels = util.split(name); - - // Are they requesting a child of our - // origin? If not, handle the mishap - // gracefully. - if (this.origin !== '.') { - const zone = util.from(name, labels, -this.count); - - // Refer them back to the root zone. - if (!util.equal(zone, this.origin)) { - const [ns, ar] = this.getHints(); - return [[], ns, ar, false, true]; - } + return [an, ns, ar, true, true]; } // Couldn't find anything. // Serve an SoA (no data). - if (labels.length === this.count) { + if (authority) { const ns = this.get(this.origin, types.SOA); this.proveNoData(ns); return [[], ns, [], true, false]; @@ -284,13 +299,18 @@ class Zone { // might have a referral for. const index = this.count + 1; const child = util.from(name, labels, -index); - const [ns, glue] = this.find(child, types.NS); + [ns, glue] = this.find(child, types.NS); // Couldn't find any nameservers. // Serve an SoA (nxdomain). if (ns.length === 0) { - const ns = this.get(this.origin, types.SOA); - this.proveNameError(child, ns); + let ns = []; + // The root zone can prove the TLD doesn't exist with authority + // but regular authoritative name servers should be as quiet as possible. + if (this.origin === '.') { + ns = this.get(this.origin, types.SOA); + this.proveNameError(child, ns); + } return [[], ns, [], false, false]; } @@ -348,11 +368,12 @@ class Zone { */ class RecordMap { - constructor() { + constructor(zone) { // type -> rrs this.rrs = new Map(); // type covered -> sigs this.sigs = new Map(); + this.zone = zone; } clear() { @@ -388,24 +409,94 @@ class RecordMap { return this; } + filterMatches(name, rrs) { + const ret = []; + + for (const rr of rrs) { + if (!isWild(rr.name)) { + ret.push(rr); + continue; + } + + const x = util.splitName(name); + const y = util.splitName(rr.name); + + if (x.length < y.length) + continue; + + // Remove '*' label and test remainder + y.shift(); + + let push = true; + for (let i = 1; i <= y.length; i++) { + if (y[y.length - i] !== x[x.length - i]) { + push = false; + break; + } + } + if (!push) + continue; + + ret.push(rr); + } + + return ret; + } + push(name, type, an) { assert(util.isFQDN(name)); assert((type & 0xffff) === type); assert(Array.isArray(an)); - const rrs = this.rrs.get(type); + // If a name has a CNAME record, there should be no + // other records for that name in the zone. + // (RFC 1034 section 3.6.2, RFC 1912 section 2.4) + if (type !== types.CNAME) { + let rrs = this.rrs.get(types.CNAME); - if (!rrs || rrs.length === 0) - return this; + if (rrs && rrs.length > 0) { + rrs = this.filterMatches(name, rrs); + for (const rr of rrs) + an.push(convert(name, rr)); - for (const rr of rrs) - an.push(convert(name, rr)); + let sigs = this.sigs.get(types.CNAME); - const sigs = this.sigs.get(type); + if (sigs) { + sigs = this.filterMatches(name, sigs); + for (const rr of sigs) + an.push(convert(name, rr)); + } - if (sigs) { - for (const rr of sigs) + if (!sigs && this.zone.zskkey && this.zone.zskpriv) { + // Create dnssec sig on the fly (especially useful for wildcard) + const sig = dnssec.sign(this.zone.zskkey, this.zone.zskpriv, an); + an.push(sig); + } + + return this; + } + } + + let rrs = this.rrs.get(type); + + if (rrs && rrs.length > 0) { + rrs = this.filterMatches(name, rrs); + for (const rr of rrs) an.push(convert(name, rr)); + + let sigs = this.sigs.get(type); + + if (sigs) { + sigs = this.filterMatches(name, sigs); + for (const rr of sigs) + an.push(convert(name, rr)); + } + + if (!sigs && this.zone.zskkey && this.zone.zskpriv) { + // Create dnssec sig on the fly (especially useful for wildcard) + const sig = dnssec.sign(this.zone.zskkey, this.zone.zskpriv, an); + an.push(sig); + } } return this; @@ -527,19 +618,9 @@ function convert(name, rr) { if (!isWild(rr.name)) return rr; - const x = util.splitName(name); - const y = util.splitName(rr.name); - - assert(y.length > 0); - - if (x.length < y.length) - return rr; - rr = rr.clone(); - y[0] = x[x.length - y.length]; - - rr.name = `${y.join('.')}.`; + rr.name = name; return rr; } diff --git a/test/zone-test.js b/test/zone-test.js index ac010e2..9e15b94 100644 --- a/test/zone-test.js +++ b/test/zone-test.js @@ -9,6 +9,9 @@ const fs = require('bfile'); const wire = require('../lib/wire'); const Zone = require('../lib/zone'); const {types, codes} = wire; +const dnssec = require('../lib/dnssec'); +const {RSASHA256} = dnssec.algs; +const {ZONE, KSK} = dnssec.keyFlags; const ROOT_ZONE = Path.resolve(__dirname, 'data', 'root.zone'); const COM_RESPONSE = Path.resolve(__dirname, 'data', 'com-response.zone'); @@ -52,4 +55,279 @@ describe('Zone', function() { assert.deepStrictEqual(msg.authority, expect); } }); + + describe('Serve records from zone', function() { + const zone = new Zone(); + const domain = 'thebnszone.'; + const subdomainWithGlue = 'subdomain-glue.' + domain; + const subdomainNoGlue = 'subdomain-external.' + domain; + const subdomainWithText = 'subdomain-text.' + domain; + + // TLD + zone.setOrigin(domain); + // A record for TLD (Common in Handshake, not in DNS) + zone.fromString(`${domain} 21600 IN A 10.20.30.40`); + // TXT record for TLD + zone.fromString(`${subdomainWithText} 21600 IN TXT "subdomain-with-text"`); + // TXT for wildcard + zone.fromString('* 21600 IN TXT "wildcard"'); + // CNAME for subdomain -> TLD + zone.fromString(`${subdomainWithGlue} 21600 IN CNAME ${domain}`); + // CNAME for subdomain -> other zone + zone.fromString(`${subdomainNoGlue} 21600 IN CNAME idontexist.`); + // SOA + zone.fromString( + `${domain} 21600 IN SOA ns1.${domain} admin.${domain} ` + + '2020070500 86400 7200 604800 300' + ); + + it('should serve A record', () => { + const msg = zone.resolve(domain, types.A); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 1); + assert(msg.answer[0].data.address = '10.20.30.40'); + }); + + it('should serve SOA record for missing type', () => { + const msg = zone.resolve(domain, types.AAAA); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 1); + assert(msg.additional.length === 0); + assert(msg.answer.length === 0); + }); + + it('should serve nothing for missing name', () => { + const msg = zone.resolve('idontexist.', types.A); + assert(msg.code === codes.NXDOMAIN); + assert(!msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 0); + }); + + it('should serve TXT record for wildcard', () => { + const msg = zone.resolve(`idontexist.${domain}`, types.TXT); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 1); + assert(msg.answer[0].data.txt.length === 1); + assert(msg.answer[0].data.txt[0] === 'wildcard' ); + }); + + it('should serve TXT record for defined subdomain', () => { + const msg = zone.resolve(`${subdomainWithText}`, types.TXT); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 1); + assert(msg.answer[0].data.txt.length === 1); + assert(msg.answer[0].data.txt[0] === 'subdomain-with-text'); + }); + + for (const t of Object.keys(types)) { + it(`should serve CNAME + glue as answers for type: ${t}`, () => { + if (t === 'NS' || t === 'ANY' || t === 'UNKNOWN' || t === 'SOA') + this.skip(); // TODO + + const msg = zone.resolve(subdomainWithGlue, types[t]); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.additional.length === 0); + + if (t !== 'A') { + assert(msg.authority.length === 1); + assert(msg.answer.length === 1); + assert(msg.answer[0].type === types.CNAME); + } else { + assert(msg.authority.length === 0); + + let cname = false; + let a = false; + for (const an of msg.answer) { + if (an.type === types.CNAME) + cname = true; + + if (an.type === types.A) { + a = true; + assert (an.data.address === '10.20.30.40'); + } + } + assert(cname); + assert(a); + } + }); + } + + for (const t of Object.keys(types)) { + it(`should serve CNAME only for type: ${t}`, () => { + if (t === 'NS' || t === 'ANY') + this.skip(); // TODO + + const msg = zone.resolve(subdomainNoGlue, types[t]); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 1); + assert(msg.answer[0].type === types.CNAME); + assert(msg.answer[0].data.target === 'idontexist.'); + }); + } + }); + + describe('CNAME for wildcard', function() { + const zone = new Zone(); + const domain = 'thebnszone.'; + const subdomainWithGlue = 'subdomain-glue.' + domain; + + // TLD + zone.setOrigin(domain); + // Reset zone. + zone.clearRecords(); + // A record for TLD (Common in Handshake, not in DNS) + zone.fromString(`${domain} 21600 IN A 10.20.30.40`); + // CNAME for wildcard -> TXT + zone.fromString(`* 21600 IN CNAME ${domain}`); + // SOA + zone.fromString( + `${domain} 21600 IN SOA ns1.${domain} admin.${domain} ` + + '2020070500 86400 7200 604800 300' + ); + + for (const t of Object.keys(types)) { + it(`should serve CNAME + glue as answers for type: ${t}`, () => { + if (t === 'NS' || t === 'ANY' || t === 'UNKNOWN' || t === 'SOA') + this.skip(); // TODO + + const msg = zone.resolve(subdomainWithGlue, types[t]); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.additional.length === 0); + + if (t !== 'A') { + assert(msg.authority.length === 1); + assert(msg.answer.length === 1); + assert(msg.answer[0].type === types.CNAME); + } else { + assert(msg.authority.length === 0); + let cname = false; + let a = false; + for (const an of msg.answer) { + if (an.type === types.CNAME) + cname = true; + + if (an.type === types.A) { + a = true; + assert (an.data.address === '10.20.30.40'); + } + } + assert(cname); + assert(a); + } + }); + } + }); + + describe('DNSSEC for wildcard', function() { + const zone = new Zone(); + const domain = 'thebnszone.'; + const subdomain = 'subdomain.' + domain; + + // TLD + zone.setOrigin(domain); + // Reset zone. + zone.clearRecords(); + // A record for TLD (Common in Handshake, not in DNS) + zone.fromString(`${domain} 21600 IN A 10.20.30.40`); + // wildcard for subdomains + zone.fromString(`*.${domain} 21600 IN A 50.60.70.80`); + // SOA + zone.fromString( + `${domain} 21600 IN SOA ns1.${domain} admin.${domain} ` + + '2020070500 86400 7200 604800 300' + ); + + zone.zskpriv = dnssec.createPrivate(RSASHA256, 2048); + zone.zskkey = dnssec.makeKey(domain, RSASHA256, zone.zskpriv, ZONE); + + let wrongsig = null; + + it('should serve signed A record from defined name', () => { + const msg = zone.resolve(domain, types.A); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 2); + let rrsig = null; + let a = null; + for (const an of msg.answer) { + if (an.type === types.RRSIG) + rrsig = an; + + if (an.type === types.A) { + a = an; + assert (an.data.address === '10.20.30.40'); + } + } + assert(rrsig); + assert(a); + assert(dnssec.verify(rrsig, zone.zskkey, [a])); + wrongsig = rrsig; + }); + + it('should serve signed A record from wildcard', () => { + const msg = zone.resolve(subdomain, types.A); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 2); + let rrsig = null; + let a = null; + for (const an of msg.answer) { + if (an.type === types.RRSIG) + rrsig = an; + + if (an.type === types.A) { + a = an; + assert (an.data.address === '50.60.70.80'); + } + } + assert(rrsig); + assert(a); + assert(dnssec.verify(rrsig, zone.zskkey, [a])); + }); + + it('should not verify with the wrong signature', () => { + const msg = zone.resolve(subdomain, types.A); + assert(msg.code === codes.NOERROR); + assert(msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 2); + let rrsig = null; + let a = null; + for (const an of msg.answer) { + if (an.type === types.RRSIG) + rrsig = an; + + if (an.type === types.A) { + a = an; + assert (an.data.address === '50.60.70.80'); + } + } + assert(rrsig); + assert(a); + // sanity check + assert(!dnssec.verify(wrongsig, zone.zskkey, [a])); + }); + }); });