// Copyright (C) 2010, 2022 Verisign, Inc. // // This library is free software; you can redistribute it and/or // modify it under the terms of the GNU Lesser General Public // License as published by the Free Software Foundation; either // version 2.1 of the License, or (at your option) any later version. // // This library is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public // License along with this library; if not, write to the Free Software // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 // USA package com.verisignlabs.dnssec.security; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; import java.util.logging.Logger; import org.xbill.DNS.DNSKEYRecord; import org.xbill.DNS.NSEC3PARAMRecord; import org.xbill.DNS.NSEC3Record; import org.xbill.DNS.NSECRecord; import org.xbill.DNS.Name; import org.xbill.DNS.RRSIGRecord; import org.xbill.DNS.RRset; import org.xbill.DNS.Record; import org.xbill.DNS.TextParseException; import org.xbill.DNS.Type; import org.xbill.DNS.utils.base32; /** * A class for whole zone DNSSEC verification. Along with cryptographically * verifying signatures, this class will also detect invalid NSEC and NSEC3 * chains. * * @author David Blacka */ public class ZoneVerifier { private SortedMap> mNodeMap; private HashMap mRRsetMap; private SortedMap mNSECMap; private SortedMap mNSEC3Map; private Name mZoneName; private DNSSECType mDNSSECType; private NSEC3PARAMRecord mNSEC3params; private boolean mIgnoreDuplicateRRs; private DnsSecVerifier mVerifier; private base32 mBase32; private ByteArrayComparator mBAcmp; private Logger log = Logger.getLogger("ZoneVerifier"); // The various types of signed zones. enum DNSSECType { UNSIGNED, NSEC, NSEC3, NSEC3_OPTOUT; } // The types of nodes (a node consists of all RRs with the same name). enum NodeType { NORMAL, DELEGATION, GLUE; } /** * This is a subclass of {@link org.xbill.DNS.RRset} that adds a "mark". */ private class MarkRRset extends RRset { private static final long serialVersionUID = 1L; private boolean mIsMarked = false; boolean getMark() { return mIsMarked; } void setMark(boolean value) { mIsMarked = value; } } public ZoneVerifier() { mVerifier = new DnsSecVerifier(); mBase32 = new base32(base32.Alphabet.BASE32HEX, false, true); mBAcmp = new ByteArrayComparator(); mIgnoreDuplicateRRs = false; } /** @return the DnsSecVerifier object used to verify individual RRsets. */ public DnsSecVerifier getVerifier() { return mVerifier; } public void setIgnoreDuplicateRRs(boolean value) { mIgnoreDuplicateRRs = value; } private static String key(Name n, int type) { return n.toString() + ':' + type; } private boolean addRRtoRRset(RRset rrset, Record rr) { if (mIgnoreDuplicateRRs) { rrset.addRR(rr); return true; } if (rr instanceof RRSIGRecord) { for (RRSIGRecord sigrec : rrset.sigs()) { if (rr.equals(sigrec)) { return false; } } } else { for (Record rec : rrset.rrs()) { if (rr.equals(rec)) { return false; } } } rrset.addRR(rr); return true; } /** * Add a record to the various maps. * * @return TODO */ private boolean addRR(Record r) { Name n = r.getName(); int t = r.getType(); if (t == Type.RRSIG) t = ((RRSIGRecord) r).getTypeCovered(); // Add NSEC and NSEC3 RRs to their respective maps if (t == Type.NSEC) { if (mNSECMap == null) { mNSECMap = new TreeMap<>(); } MarkRRset rrset = mNSECMap.computeIfAbsent(n, k -> new MarkRRset()); return addRRtoRRset(rrset, r); } if (t == Type.NSEC3) { if (mNSEC3Map == null) { mNSEC3Map = new TreeMap<>(); } MarkRRset rrset = mNSECMap.computeIfAbsent(n, k -> new MarkRRset()); return addRRtoRRset(rrset, r); } // Add the name and type to the node map Set typeset = mNodeMap.computeIfAbsent(n, k -> new HashSet<>()); typeset.add(r.getType()); // add the original type // Add the record to the RRset map String k = key(n, t); RRset rrset = mRRsetMap.computeIfAbsent(k, k2 -> new RRset()); return addRRtoRRset(rrset, r); } /** * Given a record, determine the DNSSEC signing type. If the record doesn't * determine that, DNSSECType.UNSIGNED is returned */ private DNSSECType determineDNSSECType(Record r) { if (r.getType() == Type.NSEC) return DNSSECType.NSEC; if (r.getType() == Type.NSEC3) { NSEC3Record nsec3 = (NSEC3Record) r; if ((nsec3.getFlags() & NSEC3Record.Flags.OPT_OUT) == NSEC3Record.Flags.OPT_OUT) { return DNSSECType.NSEC3_OPTOUT; } return DNSSECType.NSEC3; } return DNSSECType.UNSIGNED; } /** * Given an unsorted list of records, load the node and rrset maps, as well as * determine the NSEC3 parameters and signing type. * * @param records * @return TODO */ private int calculateNodes(List records) { mNodeMap = new TreeMap<>(); mRRsetMap = new HashMap<>(); // The zone is unsigned until we get a clue otherwise. mDNSSECType = DNSSECType.UNSIGNED; int errors = 0; for (Record r : records) { Name n = r.getName(); int t = r.getType(); // Add the record to the various maps. boolean res = addRR(r); if (!res) { log.warning("Record '" + r + "' detected as a duplicate"); errors++; } // Learn some things about the zone as we do this pass. if (t == Type.SOA) mZoneName = n; if (t == Type.NSEC3PARAM) mNSEC3params = (NSEC3PARAMRecord) r; if (t == Type.DNSKEY) { DNSKEYRecord dnskey = (DNSKEYRecord) r; mVerifier.addTrustedKey(dnskey); log.info("Adding trusted key: " + dnskey + " ; keytag = " + dnskey.getFootprint()); } if (mDNSSECType == DNSSECType.UNSIGNED) mDNSSECType = determineDNSSECType(r); } return errors; } /** * Given a name, typeset, and name of the last zone cut, determine the node * type. */ private NodeType determineNodeType(Name n, Set typeset, Name last_cut) { // All RRs at the zone apex are normal if (n.equals(mZoneName)) return NodeType.NORMAL; // If the node is not below the zone itself, we will treat it as glue (it is // really junk). if (!n.subdomain(mZoneName)) { return NodeType.GLUE; } // If the node is below a zone cut (either a delegation or DNAME), it is // glue. if (last_cut != null && n.subdomain(last_cut) && !n.equals(last_cut)) { return NodeType.GLUE; } // If the node has a NS record it is a delegation. if (typeset.contains(Integer.valueOf(Type.NS))) return NodeType.DELEGATION; return NodeType.NORMAL; } private Set cleanupDelegationTypeset(Set typeset) { Set t = new HashSet<>(); if (typeset.contains(Type.NS)) t.add(Type.NS); if (typeset.contains(Type.DS)) t.add(Type.DS); if (typeset.contains(Type.RRSIG)) t.add(Type.RRSIG); if (!typeset.equals(t)) return t; return typeset; } /** * For each node, determine which RRsets should be signed, verify those, and * determine which nodes get NSEC or NSEC3 RRs and verify those. */ private int processNodes() throws NoSuchAlgorithmException, TextParseException { int errors = 0; Name last_cut = null; for (Map.Entry> entry : mNodeMap.entrySet()) { Name n = entry.getKey(); Set typeset = entry.getValue(); NodeType ntype = determineNodeType(n, typeset, last_cut); log.finest("Node " + n + " is type " + ntype); // we can ignore glue/invalid RRs. if (ntype == NodeType.GLUE) continue; // record the last zone cut if this node is a zone cut. if (ntype == NodeType.DELEGATION || typeset.contains(Type.DNAME)) { last_cut = n; } // check all of the RRsets that should be signed for (int type : typeset) { if (type == Type.RRSIG) continue; // at delegation points, only DS RRs are signed (and NSEC, but those are // checked separately) if (ntype == NodeType.DELEGATION && type != Type.DS) continue; // otherwise, verify the RRset. String k = key(n, type); RRset rrset = mRRsetMap.get(k); errors += processRRset(rrset); } // cleanup the typesets of delegation nodes. // the only types that should be there are NS, DS and RRSIG. if (ntype == NodeType.DELEGATION) { typeset = cleanupDelegationTypeset(typeset); } switch (mDNSSECType) { case NSEC: // all nodes with NSEC records have NSEC and RRSIG types typeset.add(Type.NSEC); typeset.add(Type.RRSIG); errors += processNSEC(n, typeset); break; case NSEC3: errors += processNSEC3(n, typeset, ntype); break; case NSEC3_OPTOUT: if (ntype == NodeType.NORMAL || (ntype == NodeType.DELEGATION && typeset.contains(Type.DS))) { errors += processNSEC3(n, typeset, ntype); } break; } } return errors; } private static String reasonListToString(List reasons) { if (reasons == null) return ""; StringBuilder out = new StringBuilder(); for (Iterator i = reasons.iterator(); i.hasNext();) { out.append("Reason: "); out.append(i.next()); if (i.hasNext()) out.append("\n"); } return out.toString(); } private int processRRset(RRset rrset) { List reasons = new ArrayList<>(); boolean result = false; for (RRSIGRecord sigrec : rrset.sigs()) { boolean res = mVerifier.verifySignature(rrset, sigrec, reasons); if (!res) { log.warning("Signature failed to verify RRset:\n rr: " + ZoneUtils.rrsetToString(rrset, false) + "\n sig: " + sigrec + "\n" + reasonListToString(reasons)); } if (res) result = res; } String rrsetname = rrset.getName() + "/" + Type.string(rrset.getType()); if (result) { log.fine("RRset " + rrsetname + " verified."); } else { log.warning("RRset " + rrsetname + " did not verify."); } return result ? 0 : 1; } private String typesToString(int[] types) { StringBuilder sb = new StringBuilder(); Arrays.sort(types); for (int i = 0; i < types.length; ++i) { if (i != 0) sb.append(' '); sb.append(Type.string(types[i])); } return sb.toString(); } private String typesetToString(Set typeset) { if (typeset == null) return ""; int[] types = new int[typeset.size()]; int i = 0; for (int type : typeset) { types[i++] = type; } return typesToString(types); } private boolean checkTypeMap(Set typeset, int[] types) { // a null typeset means that we are expecting the typemap of an ENT, which // should be empty. if (typeset == null) return types.length == 0; Set compareTypeset = new HashSet<>(); for (int i = 0; i < types.length; ++i) { compareTypeset.add(types[i]); } return typeset.equals(compareTypeset); } private int processNSEC(Name n, Set typeset) { MarkRRset rrset = mNSECMap.get(n); if (n == null) { log.warning("Missing NSEC for " + n); return 1; } int errors = 0; rrset.setMark(true); NSECRecord nsec = (NSECRecord) rrset.first(); // check typemap if (!checkTypeMap(typeset, nsec.getTypes())) { log.warning("Typemap for NSEC RR " + n + " did not match what was expected. Expected '" + typesetToString(typeset) + "', got '" + typesToString(nsec.getTypes())); errors++; } // verify rrset errors += processRRset(rrset); return errors; } private boolean shouldCheckENTs(Name n, Set typeset, NodeType ntype) { // if we are just one (or zero) labels longer than the zonename, the node // can't create a ENT if (n.labels() <= mZoneName.labels() + 1) return false; // we probably won't ever get called for a GLUE node if (ntype == NodeType.GLUE) return false; // if we aren't doing opt-out, then all possible ENTs must be checked. if (mDNSSECType == DNSSECType.NSEC3) return true; // if we are opt-out, and the node is an insecure delegation, don't check // ENTs. return !(ntype == NodeType.DELEGATION && !typeset.contains(Type.DS)); } private int processNSEC3(Name n, Set typeset, NodeType ntype) throws NoSuchAlgorithmException, TextParseException { // calculate the NSEC3 RR name byte[] hash = mNSEC3params.hashName(n); String hashstr = mBase32.toString(hash); Name hashname = new Name(hashstr, mZoneName); MarkRRset rrset = mNSEC3Map.get(hashname); if (rrset == null) { log.warning("Missing NSEC3 for " + hashname + " corresponding to " + n); return 1; } int errors = 0; rrset.setMark(true); NSEC3Record nsec3 = (NSEC3Record) rrset.first(); // check typemap if (!checkTypeMap(typeset, nsec3.getTypes())) { log.warning("Typemap for NSEC3 RR " + hashname + " for " + n + " did not match what was expected. Expected '" + typesetToString(typeset) + "', got '" + typesToString(nsec3.getTypes()) + "'"); errors++; } // verify rrset errors += processRRset(rrset); // check NSEC3 RRs for empty non-terminals. // this is recursive. if (shouldCheckENTs(n, typeset, ntype)) { Name ent = new Name(n, 1); if (mNodeMap.get(ent) == null) { errors += processNSEC3(ent, null, NodeType.NORMAL); } } return errors; } private int processNSECChain() { int errors = 0; NSECRecord lastNSEC = null; for (Iterator> i = mNSECMap.entrySet().iterator(); i.hasNext();) { // check the internal ordering of the previous NSEC record. This avoids // looking at the last one, // which is different. if (lastNSEC != null && lastNSEC.getName().compareTo(lastNSEC.getNext()) >= 0) { log.warning("NSEC for " + lastNSEC.getName() + " has next name >= owner but is not the last NSEC in the chain."); errors++; } Map.Entry entry = i.next(); Name n = entry.getKey(); MarkRRset rrset = entry.getValue(); // check to see if the NSEC is marked. If not, it was not correlated to a // signed node. if (!rrset.getMark()) { log.warning("NSEC RR for " + n + " appears to be extra."); errors++; } NSECRecord nsec = (NSECRecord) rrset.first(); // This is just a sanity check. If this isn't true, we are constructing // the // nsec map incorrectly. if (!n.equals(nsec.getName())) { log.warning("The NSEC in the map for name " + n + " has name " + nsec.getName()); errors++; } // If this is the first row, ensure that the owner name equals the zone // name if (lastNSEC == null && !n.equals(mZoneName)) { log.warning("The first NSEC in the chain does not match the zone name: name = " + n + " zonename = " + mZoneName); errors++; } // Check that the prior NSEC's next name equals this rows owner name. if (lastNSEC != null && !lastNSEC.getNext().equals(nsec.getName())) { log.warning("NSEC for " + lastNSEC.getName() + " does not point to the next NSEC in the chain: " + n); errors++; } lastNSEC = nsec; } // check the internal ordering of the last NSEC in the chain // the ownername should be >= next name. if (lastNSEC.getName().compareTo(lastNSEC.getNext()) < 0) { log.warning("The last NSEC RR in the chain did not have an owner >= next: owner = " + lastNSEC.getName() + " next = " + lastNSEC.getNext()); errors++; } // check to make sure it links to the first NSEC in the chain if (!lastNSEC.getNext().equals(mZoneName)) { log.warning("The last NSEC RR in the chain did not link to the first NSEC"); errors++; } return errors; } private int compareNSEC3Hashes(Name owner, byte[] hash) { // we will compare the binary images String ownerhashstr = owner.getLabelString(0); byte[] ownerhash = mBase32.fromString(ownerhashstr); return mBAcmp.compare(ownerhash, hash); } private int processNSEC3Chain() { int errors = 0; NSEC3Record lastNSEC3 = null; NSEC3Record firstNSEC3 = null; for (Iterator> i = mNSEC3Map.entrySet().iterator(); i.hasNext();) { // check the internal ordering of the previous NSEC3 record. This avoids // looking at the last one, // which is different. if (lastNSEC3 != null && compareNSEC3Hashes(lastNSEC3.getName(), lastNSEC3.getNext()) >= 0) { log.warning("NSEC3 for " + lastNSEC3.getName() + " has next name >= owner but is not the last NSEC3 in the chain."); errors++; } Map.Entry entry = i.next(); Name n = entry.getKey(); MarkRRset rrset = entry.getValue(); // check to see if the NSEC is marked. If not, it was not correlated to a // signed node. if (!rrset.getMark()) { log.warning("NSEC3 RR for " + n + " appears to be extra."); errors++; } NSEC3Record nsec3 = (NSEC3Record) rrset.first(); // This is just a sanity check. If this isn't true, we are constructing // the // nsec3 map incorrectly. if (!n.equals(nsec3.getName())) { log.severe("The NSEC3 in the map for name " + n + " has name " + nsec3.getName()); errors++; } // note the first NSEC3 in the chain. if (lastNSEC3 == null) { firstNSEC3 = nsec3; } else // Check that the prior NSEC3's next hashed name equals this row's hashed // owner name. { if (compareNSEC3Hashes(nsec3.getName(), lastNSEC3.getNext()) != 0) { String nextstr = mBase32.toString(lastNSEC3.getNext()); log.warning("NSEC3 for " + lastNSEC3.getName() + " does not point to the next NSEC3 in the chain: " + nsec3.getName() + ", instead points to: " + nextstr); errors++; } } lastNSEC3 = nsec3; } // check the internal ordering of the last NSEC in the chain // the ownername should be >= next name. if (compareNSEC3Hashes(lastNSEC3.getName(), lastNSEC3.getNext()) < 0) { String nextstr = mBase32.toString(lastNSEC3.getNext()); log.warning("The last NSEC3 RR in the chain did not have an owner >= next: owner = " + lastNSEC3.getName() + " next = " + nextstr); errors++; } // check to make sure it links to the first NSEC in the chain if (compareNSEC3Hashes(firstNSEC3.getName(), lastNSEC3.getNext()) != 0) { log.warning("The last NSEC3 RR in the chain did not link to the first NSEC3"); errors++; } return errors; } public int verifyZone(List records) throws NoSuchAlgorithmException, TextParseException { int errors = 0; errors += calculateNodes(records); errors += processNodes(); if (mDNSSECType == DNSSECType.NSEC) { errors += processNSECChain(); } else if (mDNSSECType == DNSSECType.NSEC3 || mDNSSECType == DNSSECType.NSEC3_OPTOUT) { errors += processNSEC3Chain(); } if (errors > 0) { log.info("Zone " + mZoneName + " failed verification with " + errors + " errors"); } else { log.info("Zone " + mZoneName + " verified with 0 errors"); } return errors; } }