From 88bc775968915fd32b21136d0bcc05486ab1cb41 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 6 Jul 2020 16:30:41 -0400 Subject: [PATCH 1/8] zone: only serve wildcard if there was otherwise no match --- lib/zone.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index 2568026..de4532d 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -121,8 +121,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; } From fe9383b031847a72d1c938f4b82f8e02c5c0a615 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 6 Jul 2020 16:30:55 -0400 Subject: [PATCH 2/8] zone: return CNAME records for any type requested --- lib/zone.js | 36 ++++++++--- test/zone-test.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index de4532d..59e8d2b 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -393,19 +393,39 @@ class RecordMap { 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) { + const rrs = this.rrs.get(types.CNAME); - if (!rrs || rrs.length === 0) - return this; + if (rrs && rrs.length > 0) { + for (const rr of rrs) + an.push(convert(name, rr)); - for (const rr of rrs) - an.push(convert(name, rr)); + const sigs = this.sigs.get(types.CNAME); + + if (sigs) { + for (const rr of sigs) + an.push(convert(name, rr)); + } - const sigs = this.sigs.get(type); + return this; + } + } - if (sigs) { - for (const rr of sigs) + const rrs = this.rrs.get(type); + + if (rrs && rrs.length > 0) { + for (const rr of rrs) an.push(convert(name, rr)); + + const sigs = this.sigs.get(type); + + if (sigs) { + for (const rr of sigs) + an.push(convert(name, rr)); + } } return this; diff --git a/test/zone-test.js b/test/zone-test.js index ac010e2..cde7509 100644 --- a/test/zone-test.js +++ b/test/zone-test.js @@ -52,4 +52,158 @@ 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 to trigger authority flag + 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 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') + this.skip(); // TODO + + const msg = zone.resolve(subdomainWithGlue, types[t]); + assert(msg.code === codes.NOERROR); + assert(!msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 2); + + 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}`); + + for (const t of Object.keys(types)) { + it(`should serve CNAME + glue as answers for type: ${t}`, () => { + if (t === 'NS' || t === 'ANY') + this.skip(); // TODO + + const msg = zone.resolve(subdomainWithGlue, types[t]); + assert(msg.code === codes.NOERROR); + assert(!msg.aa); + assert(msg.authority.length === 0); + assert(msg.additional.length === 0); + assert(msg.answer.length === 2); + + 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); + }); + } + }); }); From 81fb262d39b8cb4fa937b6f450d179e7f3e176ea Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 6 Jul 2020 18:48:51 -0400 Subject: [PATCH 3/8] zone: glue requested type, default A / AAAA --- lib/zone.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index 59e8d2b..3760fca 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -145,12 +145,16 @@ class Zone { return map.rrs.has(type); } - glue(name, an) { + glue(name, an, type) { assert(util.isFQDN(name)); assert(Array.isArray(an)); - this.push(name, types.A, an); - this.push(name, types.AAAA, an); + if (!type) { + this.push(name, types.A, an); + this.push(name, types.AAAA, an); + } else { + this.push(name, type, an); + } return this; } @@ -162,10 +166,10 @@ class Zone { for (const rr of an) { switch (rr.type) { case types.CNAME: - this.glue(rr.data.target, an); + this.glue(rr.data.target, an, type); break; case types.DNAME: - this.glue(rr.data.target, an); + this.glue(rr.data.target, an, type); break; case types.NS: this.glue(rr.data.ns, ar); From ea87da1c4e88ac7c0f95312d50a83df17c36fbbe Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 6 Jul 2020 20:50:11 -0400 Subject: [PATCH 4/8] zone: wildcard matches more than one label --- lib/zone.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/zone.js b/lib/zone.js index 3760fca..ea5c3af 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -561,7 +561,11 @@ function convert(name, rr) { rr = rr.clone(); - y[0] = x[x.length - y.length]; + // Replace '*' with prefix, may be multiple labels + y.shift(); + let diff = x.length - y.length - 1; + for (; diff >= 0; diff--) + y.unshift(x[diff]); rr.name = `${y.join('.')}.`; From 28a6962b8bd14653cd71d6d1a1e6ea994dc3ebd9 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 6 Jul 2020 22:27:14 -0400 Subject: [PATCH 5/8] zone: filter out wildcards that do not match --- lib/zone.js | 62 ++++++++++++++++++++++++++++++++--------------- test/zone-test.js | 60 +++++++++++++++++++++++++-------------------- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index ea5c3af..7bfe3cb 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -392,6 +392,40 @@ 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); @@ -401,15 +435,17 @@ class RecordMap { // other records for that name in the zone. // (RFC 1034 section 3.6.2, RFC 1912 section 2.4) if (type !== types.CNAME) { - const rrs = this.rrs.get(types.CNAME); + let rrs = this.rrs.get(types.CNAME); if (rrs && rrs.length > 0) { + rrs = this.filterMatches(name, rrs); for (const rr of rrs) an.push(convert(name, rr)); - const sigs = this.sigs.get(types.CNAME); + let sigs = this.sigs.get(types.CNAME); if (sigs) { + sigs = this.filterMatches(name, sigs); for (const rr of sigs) an.push(convert(name, rr)); } @@ -418,15 +454,17 @@ class RecordMap { } } - const rrs = this.rrs.get(type); + 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)); - const sigs = this.sigs.get(type); + let sigs = this.sigs.get(type); if (sigs) { + sigs = this.filterMatches(name, sigs); for (const rr of sigs) an.push(convert(name, rr)); } @@ -551,23 +589,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(); - // Replace '*' with prefix, may be multiple labels - y.shift(); - let diff = x.length - y.length - 1; - for (; diff >= 0; diff--) - y.unshift(x[diff]); - - rr.name = `${y.join('.')}.`; + rr.name = name; return rr; } diff --git a/test/zone-test.js b/test/zone-test.js index cde7509..4626b46 100644 --- a/test/zone-test.js +++ b/test/zone-test.js @@ -121,7 +121,7 @@ describe('Zone', function() { for (const t of Object.keys(types)) { it(`should serve CNAME + glue as answers for type: ${t}`, () => { - if (t === 'NS' || t === 'ANY') + if (t === 'NS' || t === 'ANY' || t === 'UNKNOWN' || t === 'SOA') this.skip(); // TODO const msg = zone.resolve(subdomainWithGlue, types[t]); @@ -129,21 +129,25 @@ describe('Zone', function() { assert(!msg.aa); assert(msg.authority.length === 0); assert(msg.additional.length === 0); - assert(msg.answer.length === 2); - - 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'); + if (t !== 'A') { + assert(msg.answer.length === 1); + assert(msg.answer[0].type === types.CNAME); + } else { + 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); } - assert(cname); - assert(a); }); } @@ -180,7 +184,7 @@ describe('Zone', function() { for (const t of Object.keys(types)) { it(`should serve CNAME + glue as answers for type: ${t}`, () => { - if (t === 'NS' || t === 'ANY') + if (t === 'NS' || t === 'ANY' || t === 'UNKNOWN') this.skip(); // TODO const msg = zone.resolve(subdomainWithGlue, types[t]); @@ -188,21 +192,25 @@ describe('Zone', function() { assert(!msg.aa); assert(msg.authority.length === 0); assert(msg.additional.length === 0); - assert(msg.answer.length === 2); - - 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'); + if (t !== 'A') { + assert(msg.answer.length === 1); + assert(msg.answer[0].type === types.CNAME); + } else { + 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); } - assert(cname); - assert(a); }); } }); From ac89c7e1b8bcbefde2122cb242363cdb626ed562 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Tue, 7 Jul 2020 10:27:55 -0400 Subject: [PATCH 6/8] zone: add SOA if authoritative but no answers. Applies to CNAME glue --- lib/zone.js | 51 +++++++++++++++++++++++------------------------ test/zone-test.js | 26 +++++++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index 7bfe3cb..e7d41b5 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -145,10 +145,12 @@ class Zone { return map.rrs.has(type); } - glue(name, an, type) { + glue(name, an, type, ns) { assert(util.isFQDN(name)); assert(Array.isArray(an)); + const initial = an.length; + if (!type) { this.push(name, types.A, an); this.push(name, types.AAAA, an); @@ -156,20 +158,29 @@ class Zone { 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; } 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, type); + this.glue(rr.data.target, an, type, ns); break; case types.DNAME: - this.glue(rr.data.target, an, type); + this.glue(rr.data.target, an, type, ns); break; case types.NS: this.glue(rr.data.ns, ar); @@ -186,7 +197,7 @@ class Zone { } } - return [an, ar]; + return [an, ar, ns]; } getHints() { @@ -234,12 +245,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. @@ -250,34 +266,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]; @@ -288,7 +287,7 @@ 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). diff --git a/test/zone-test.js b/test/zone-test.js index 4626b46..e330739 100644 --- a/test/zone-test.js +++ b/test/zone-test.js @@ -72,7 +72,7 @@ describe('Zone', function() { zone.fromString(`${subdomainWithGlue} 21600 IN CNAME ${domain}`); // CNAME for subdomain -> other zone zone.fromString(`${subdomainNoGlue} 21600 IN CNAME idontexist.`); - // SOA to trigger authority flag + // SOA zone.fromString( `${domain} 21600 IN SOA ns1.${domain} admin.${domain} ` + '2020070500 86400 7200 604800 300' @@ -100,7 +100,7 @@ describe('Zone', function() { 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.aa); assert(msg.authority.length === 0); assert(msg.additional.length === 0); assert(msg.answer.length === 1); @@ -111,7 +111,7 @@ describe('Zone', function() { 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.aa); assert(msg.authority.length === 0); assert(msg.additional.length === 0); assert(msg.answer.length === 1); @@ -126,14 +126,16 @@ describe('Zone', function() { const msg = zone.resolve(subdomainWithGlue, types[t]); assert(msg.code === codes.NOERROR); - assert(!msg.aa); - assert(msg.authority.length === 0); + 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) { @@ -158,7 +160,7 @@ describe('Zone', function() { const msg = zone.resolve(subdomainNoGlue, types[t]); assert(msg.code === codes.NOERROR); - assert(!msg.aa); + assert(msg.aa); assert(msg.authority.length === 0); assert(msg.additional.length === 0); assert(msg.answer.length === 1); @@ -181,22 +183,28 @@ describe('Zone', function() { 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') + 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.authority.length === 0); + 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) { From eab0e29658cd4db6a298f10a390788ee47b57409 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Tue, 7 Jul 2020 11:19:42 -0400 Subject: [PATCH 7/8] zone: no-authority no-records answer should be as quiet as possible This makes sense as it applies to reflection / amplification attacks. If we are serving a root zone, we can always offer a SOA --- lib/zone.js | 9 +++++++-- test/zone-test.js | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/zone.js b/lib/zone.js index e7d41b5..d3dedf3 100644 --- a/lib/zone.js +++ b/lib/zone.js @@ -292,8 +292,13 @@ class Zone { // 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]; } diff --git a/test/zone-test.js b/test/zone-test.js index e330739..53659d2 100644 --- a/test/zone-test.js +++ b/test/zone-test.js @@ -97,6 +97,15 @@ describe('Zone', function() { 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); From b08696583bec5d6483ca44c0dfc404084f4ff3d7 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 8 Jul 2020 09:23:31 -0400 Subject: [PATCH 8/8] zone: add zsk property and enable ad-hoc signing --- lib/server/auth.js | 5 +++ lib/zone.js | 31 +++++++++++-- test/zone-test.js | 107 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 7 deletions(-) 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 d3dedf3..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); @@ -356,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() { @@ -454,6 +467,12 @@ class RecordMap { 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; } } @@ -472,6 +491,12 @@ class RecordMap { 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; diff --git a/test/zone-test.js b/test/zone-test.js index 53659d2..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'); @@ -153,7 +156,7 @@ describe('Zone', function() { if (an.type === types.A) { a = true; - assert (an.data.address = '10.20.30.40'); + assert (an.data.address === '10.20.30.40'); } } assert(cname); @@ -173,8 +176,8 @@ describe('Zone', function() { 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.'); + assert(msg.answer[0].type === types.CNAME); + assert(msg.answer[0].data.target === 'idontexist.'); }); } }); @@ -222,7 +225,7 @@ describe('Zone', function() { if (an.type === types.A) { a = true; - assert (an.data.address = '10.20.30.40'); + assert (an.data.address === '10.20.30.40'); } } assert(cname); @@ -231,4 +234,100 @@ describe('Zone', function() { }); } }); + + 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])); + }); + }); });