improve dnssec validation for cnames, wildcards and overrides

This commit is contained in:
Craig Raw 2025-07-29 12:50:48 +02:00
parent 58cc096f8e
commit 056d5f83a6
7 changed files with 317 additions and 89 deletions

View file

@ -0,0 +1,59 @@
package com.sparrowwallet.drongo.dns;
import org.xbill.DNS.*;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
public class AuthenticatingResolver implements Resolver {
private final Resolver delegate;
private boolean authenticated;
public AuthenticatingResolver(Resolver delegate) {
this.delegate = delegate;
}
@Override
public void setPort(int port) {
delegate.setPort(port);
}
@Override
public void setTCP(boolean flag) {
delegate.setTCP(flag);
}
@Override
public void setIgnoreTruncation(boolean flag) {
delegate.setIgnoreTruncation(flag);
}
@Override
public void setEDNS(int version, int payloadSize, int flags, List<EDNSOption> options) {
delegate.setEDNS(version, payloadSize, flags, options);
}
@Override
public void setTSIGKey(TSIG key) {
delegate.setTSIGKey(key);
}
@Override
public void setTimeout(Duration timeout) {
delegate.setTimeout(timeout);
}
@Override
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
return delegate.sendAsync(query, executor).thenApply(response -> {
this.authenticated = response.getHeader().getFlag(Flags.AD);
return response;
});
}
public boolean isAuthenticated() {
return authenticated;
}
}

View file

@ -1,5 +1,6 @@
package com.sparrowwallet.drongo.dns; package com.sparrowwallet.drongo.dns;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.uri.BitcoinURI; import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException; import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -7,12 +8,17 @@ import org.slf4j.LoggerFactory;
import org.xbill.DNS.*; import org.xbill.DNS.*;
import org.xbill.DNS.Record; import org.xbill.DNS.Record;
import org.xbill.DNS.dnssec.ValidatingResolver; import org.xbill.DNS.dnssec.ValidatingResolver;
import org.xbill.DNS.lookup.LookupResult;
import org.xbill.DNS.lookup.LookupSession;
import org.xbill.DNS.lookup.NoSuchDomainException;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutionException;
import static com.sparrowwallet.drongo.uri.BitcoinURI.BITCOIN_SCHEME; import static com.sparrowwallet.drongo.uri.BitcoinURI.BITCOIN_SCHEME;
@ -27,8 +33,13 @@ public class DnsPaymentResolver {
private final String hrn; private final String hrn;
private final String domain; private final String domain;
private final Clock clock;
public DnsPaymentResolver(String hrn) { public DnsPaymentResolver(String hrn) {
this(hrn, Clock.systemUTC());
}
public DnsPaymentResolver(String hrn, Clock clock) {
if(!StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) { if(!StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) {
throw new IllegalArgumentException("Invalid HRN containing non-ASCII characters: " + hrn); throw new IllegalArgumentException("Invalid HRN containing non-ASCII characters: " + hrn);
} }
@ -38,9 +49,10 @@ public class DnsPaymentResolver {
throw new IllegalArgumentException("Invalid HRN: " + hrn); throw new IllegalArgumentException("Invalid HRN: " + hrn);
} }
this.domain = parts[0] + ".user._bitcoin-payment." + parts[1]; this.domain = parts[0] + ".user._bitcoin-payment." + parts[1];
this.clock = clock;
} }
public Optional<DnsPayment> resolve() throws IOException, DnsPaymentValidationException, BitcoinURIParseException { public Optional<DnsPayment> resolve() throws IOException, DnsPaymentValidationException, BitcoinURIParseException, ExecutionException, InterruptedException {
return resolve(DEFAULT_RESOLVER_IP_ADDRESS); return resolve(DEFAULT_RESOLVER_IP_ADDRESS);
} }
@ -53,32 +65,40 @@ public class DnsPaymentResolver {
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure * @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI * @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI
*/ */
public Optional<DnsPayment> resolve(String resolverIpAddress) throws IOException, DnsPaymentValidationException, BitcoinURIParseException { public Optional<DnsPayment> resolve(String resolverIpAddress) throws IOException, DnsPaymentValidationException, BitcoinURIParseException, ExecutionException, InterruptedException {
log.debug("Resolving payment record for: " + domain); log.debug("Resolving payment record for: " + domain);
PersistingResolver persistingResolver = new PersistingResolver(resolverIpAddress); PersistingResolver persistingResolver = new PersistingResolver(resolverIpAddress);
ValidatingResolver resolver = new ValidatingResolver(persistingResolver); ValidatingResolver validatingResolver = new ValidatingResolver(persistingResolver, clock);
resolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII))); validatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
resolver.setEDNS(0, 0, ExtendedFlags.DO); validatingResolver.setEDNS(0, 0, ExtendedFlags.DO);
AuthenticatingResolver authenticatingResolver = new AuthenticatingResolver(validatingResolver);
Lookup lookup = new Lookup(domain, Type.TXT); try {
lookup.setResolver(resolver); LookupSession lookupSession = LookupSession.builder().resolver(authenticatingResolver).build();
LookupResult result = lookupSession.lookupAsync(getName(), Type.TXT, DClass.IN).toCompletableFuture().get();
Message query = getQuery(); if(result.getRecords().isEmpty()) {
Message response = resolver.send(query);
if(response.getSection(Section.ANSWER).isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
checkResponse(response, new ArrayList<>(persistingResolver.getChain())); String strBitcoinUri = getBitcoinURI(result.getRecords());
String strBitcoinUri = getBitcoinURI(response.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) { if(strBitcoinUri.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri); BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(response, new ArrayList<>(persistingResolver.getChain())); validateResponse(authenticatingResolver, new ArrayList<>(persistingResolver.getChain()));
return Optional.of(new DnsPayment(hrn, bitcoinURI, persistingResolver.chainToWire())); byte[] proofChain = persistingResolver.chainToWire();
log.debug("Resolved " + hrn + " with proof " + Utils.bytesToHex(proofChain));
return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain));
} catch(ExecutionException e) {
if(e.getCause() instanceof NoSuchDomainException) {
return Optional.empty();
} else {
throw e;
}
}
} }
/** /**
@ -90,12 +110,13 @@ public class DnsPaymentResolver {
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure * @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI * @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI
*/ */
public Optional<DnsPayment> resolve(byte[] proofChain) throws IOException, DnsPaymentValidationException, BitcoinURIParseException { public Optional<DnsPayment> resolve(byte[] proofChain) throws IOException, DnsPaymentValidationException, BitcoinURIParseException, ExecutionException, InterruptedException {
OfflineResolver offlineResolver = new OfflineResolver(proofChain); OfflineResolver offlineResolver = new OfflineResolver(proofChain);
ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver); ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver, clock);
offlineValidatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII))); offlineValidatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
AuthenticatingResolver authenticatingResolver = new AuthenticatingResolver(offlineValidatingResolver);
Instant now = Instant.now(); Instant now = clock.instant();
Instant oneHourAgo = now.minusSeconds(3600); Instant oneHourAgo = now.minusSeconds(3600);
for(Record record : offlineResolver.getCachedSigs()) { for(Record record : offlineResolver.getCachedSigs()) {
if(record instanceof RRSIGRecord rrsig) { if(record instanceof RRSIGRecord rrsig) {
@ -107,44 +128,36 @@ public class DnsPaymentResolver {
} }
} }
Message query = getQuery(); try {
Message offlineResponse = offlineValidatingResolver.send(query); LookupSession lookupSession = LookupSession.builder().resolver(authenticatingResolver).build();
if(offlineResponse.getSection(Section.ANSWER).isEmpty()) { LookupResult result = lookupSession.lookupAsync(getName(), Type.TXT, DClass.IN).toCompletableFuture().get();
if(result.getRecords().isEmpty()) {
return Optional.empty(); return Optional.empty();
} }
checkResponse(offlineResponse, offlineResolver.getRecords()); String strBitcoinUri = getBitcoinURI(result.getRecords());
String strBitcoinUri = getBitcoinURI(offlineResponse.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) { if(strBitcoinUri.isEmpty()) {
throw new BitcoinURIParseException("The DNS record for " + hrn + " did not contain a Bitcoin URI"); return Optional.empty();
} }
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri); BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(offlineResponse, offlineResolver.getRecords()); validateResponse(authenticatingResolver, offlineResolver.getRecords());
return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain)); return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain));
} catch(ExecutionException e) {
if(e.getCause() instanceof NoSuchDomainException) {
return Optional.empty();
} else {
throw e;
} }
private Message getQuery() throws TextParseException {
Name queryName = Name.fromString(domain + ".");
Record question = Record.newRecord(queryName, Type.TXT, DClass.IN);
return Message.newQuery(question);
}
private void checkResponse(Message response, List<Record> records) throws DnsPaymentValidationException {
if(response.getRcode() != Rcode.NOERROR) {
StringBuilder reason = new StringBuilder();
for(RRset set : response.getSectionRRsets(Section.ADDITIONAL)) {
if(set.getName().equals(Name.root) && set.getType() == Type.TXT && set.getDClass() == ValidatingResolver.VALIDATION_REASON_QCLASS) {
reason.append(((TXTRecord) set.first()).getStrings().getFirst());
} }
} }
throw new DnsPaymentValidationException("DNS query for " + domain + " failed, " + (reason.isEmpty() ? "rcode was " + response.getRcode() : reason.toString())); private Name getName() throws TextParseException {
} return Name.fromString(domain + ".");
} }
private void validateResponse(Message response, List<Record> records) throws DnsPaymentValidationException { private void validateResponse(AuthenticatingResolver resolver, List<Record> records) throws DnsPaymentValidationException {
boolean isValidated = response.getHeader().getFlag(Flags.AD); boolean isValidated = resolver.isAuthenticated();
if(!isValidated) { if(!isValidated) {
throw new DnsPaymentValidationException("DNSSEC validation failed, could not authenticate the payment instruction"); throw new DnsPaymentValidationException("DNSSEC validation failed, could not authenticate the payment instruction");
} }

View file

@ -58,32 +58,80 @@ public class OfflineResolver implements Resolver {
@Override @Override
public CompletionStage<Message> sendAsync(Message query, Executor executor) { public CompletionStage<Message> sendAsync(Message query, Executor executor) {
Record question = query.getQuestion(); Message response = makeNoErrorResponse(query);
List<Record> records = new ArrayList<>(); addRecords(query.getQuestion(), response);
for(Record it : cachedRrs) { if(response.getSection(Section.ANSWER).isEmpty() && response.getSection(Section.AUTHORITY).isEmpty()) {
if(it.getName().equals(question.getName()) && it.getType() == question.getType() && it.getDClass() == question.getDClass()) { response = makeNoDomainResponse(query);
records.add(it);
}
}
for(RRSIGRecord it : cachedSigs) {
if(it.getName().equals(question.getName()) && it.getTypeCovered() == question.getType() && it.getDClass() == question.getDClass()) {
records.add(it);
}
}
Message response;
if(records.isEmpty()) {
response = makeEmptyResponse(query);
} else {
response = makeResponseForRecords(records, query);
} }
return CompletableFuture.completedFuture(response); return CompletableFuture.completedFuture(response);
} }
private Message makeEmptyResponse(Message query) { private void addRecords(Record question, Message response) {
Name name = question.getName();
RRset cnameSet = getRRSet(name, Type.CNAME, question.getDClass());
addRRSetToMessage(response, cnameSet);
if(!cnameSet.rrs().isEmpty() && cnameSet.rrs().getFirst() instanceof CNAMERecord cnameRecord) {
name = cnameRecord.getTarget();
}
RRset answerSet = getRRSet(name, question.getType(), question.getDClass());
addRRSetToMessage(response, answerSet);
}
private void addRRSetToMessage(Message response, RRset rrset) {
rrset.rrs().stream().forEach(it -> response.addRecord(it, Section.ANSWER));
rrset.sigs().stream().forEach(it -> response.addRecord(it, Section.ANSWER));
if(!rrset.sigs().isEmpty()) {
Name wildcard = RecordUtils.rrsetWildcard(rrset);
if(wildcard != null) {
RRset nsecRRset = getNSecRRSetForWildcard(wildcard);
nsecRRset.rrs().stream().forEach(it -> response.addRecord(it, Section.AUTHORITY));
nsecRRset.sigs().stream().forEach(it -> response.addRecord(it, Section.AUTHORITY));
}
}
}
private RRset getRRSet(Name name, int type, int dclass) {
RRset rrset = new RRset();
for(Record it : cachedRrs) {
if(it.getName().equals(name) && it.getType() == type && it.getDClass() == dclass) {
rrset.addRR(it);
}
}
for(RRSIGRecord it : cachedSigs) {
if(it.getName().equals(name) && it.getTypeCovered() == type && it.getDClass() == dclass) {
rrset.addRR(it);
}
}
return rrset;
}
private RRset getNSecRRSetForWildcard(Name wildcard) {
RRset rrset = new RRset();
for(Record it : cachedRrs) {
if((it.getType() == Type.NSEC || it.getType() == Type.NSEC3) && RecordUtils.longestCommonName(it.getName(), wildcard) != Name.root) {
rrset.addRR(it);
}
}
for(RRSIGRecord it : cachedSigs) {
if((it.getTypeCovered() == Type.NSEC || it.getTypeCovered() == Type.NSEC3) && RecordUtils.longestCommonName(it.getName(), wildcard) != Name.root) {
rrset.addRR(it);
}
}
return rrset;
}
private Message makeNoDomainResponse(Message query) {
Header messageHeader = new Header(); Header messageHeader = new Header();
messageHeader.setID(query.getHeader().getID()); messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NXDOMAIN); messageHeader.setRcode(Rcode.NXDOMAIN);
@ -95,12 +143,14 @@ public class OfflineResolver implements Resolver {
Message answerMessage = new Message(); Message answerMessage = new Message();
answerMessage.setHeader(messageHeader); answerMessage.setHeader(messageHeader);
for(Record record : query.getSection(Section.QUESTION)) {
answerMessage.addRecord(record, Section.QUESTION);
}
return answerMessage; return answerMessage;
} }
private Message makeResponseForRecords(List<Record> records, Message query) { private Message makeNoErrorResponse(Message query) {
Message answerMessage = new Message();
Header messageHeader = new Header(); Header messageHeader = new Header();
messageHeader.setID(query.getHeader().getID()); messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NOERROR); messageHeader.setRcode(Rcode.NOERROR);
@ -108,16 +158,14 @@ public class OfflineResolver implements Resolver {
messageHeader.setFlag(Flags.CD); messageHeader.setFlag(Flags.CD);
messageHeader.setFlag(Flags.RD); messageHeader.setFlag(Flags.RD);
messageHeader.setFlag(Flags.RA); messageHeader.setFlag(Flags.RA);
Message answerMessage = new Message();
answerMessage.setHeader(messageHeader); answerMessage.setHeader(messageHeader);
for(Record record : query.getSection(Section.QUESTION)) { for(Record record : query.getSection(Section.QUESTION)) {
answerMessage.addRecord(record, Section.QUESTION); answerMessage.addRecord(record, Section.QUESTION);
} }
for(Record record : records) {
answerMessage.addRecord(record, Section.ANSWER);
}
return answerMessage; return answerMessage;
} }

View file

@ -1,5 +1,7 @@
package com.sparrowwallet.drongo.dns; package com.sparrowwallet.drongo.dns;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xbill.DNS.*; import org.xbill.DNS.*;
import org.xbill.DNS.Record; import org.xbill.DNS.Record;
@ -10,6 +12,8 @@ import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
public class PersistingResolver extends SimpleResolver { public class PersistingResolver extends SimpleResolver {
private static final Logger log = LoggerFactory.getLogger(PersistingResolver.class);
private final Set<Record> chain = new LinkedHashSet<>(); private final Set<Record> chain = new LinkedHashSet<>();
public PersistingResolver(String hostname) throws UnknownHostException { public PersistingResolver(String hostname) throws UnknownHostException {
@ -20,6 +24,10 @@ public class PersistingResolver extends SimpleResolver {
public CompletionStage<Message> sendAsync(Message query, Executor executor) { public CompletionStage<Message> sendAsync(Message query, Executor executor) {
CompletionStage<Message> result = super.sendAsync(query, executor); CompletionStage<Message> result = super.sendAsync(query, executor);
return result.thenApply(response -> { return result.thenApply(response -> {
if(log.isDebugEnabled()) {
log.debug(responseToString(query, response));
}
addAnswerSectionToChain(response.getSection(Section.ANSWER)); addAnswerSectionToChain(response.getSection(Section.ANSWER));
addAuthoritySectionToChain(response.getSection(Section.AUTHORITY)); addAuthoritySectionToChain(response.getSection(Section.AUTHORITY));
return response; return response;
@ -35,7 +43,7 @@ public class PersistingResolver extends SimpleResolver {
private void addAuthoritySectionToChain(List<Record> section) { private void addAuthoritySectionToChain(List<Record> section) {
if(section != null) { if(section != null) {
for(Record r : section) { for(Record r : section) {
if((r.getType() == Type.RRSIG && r.getRRsetType() == Type.NSEC && r.getRRsetType() == Type.NSEC3)|| r.getType() == Type.NSEC || r.getType() == Type.NSEC3) { if((r.getType() == Type.RRSIG && (r.getRRsetType() == Type.NSEC || r.getRRsetType() == Type.NSEC3)) || r.getType() == Type.NSEC || r.getType() == Type.NSEC3) {
chain.add(r); chain.add(r);
} }
} }
@ -57,4 +65,15 @@ public class PersistingResolver extends SimpleResolver {
return baos.toByteArray(); return baos.toByteArray();
} }
private static String responseToString(Message query, Message response) {
StringBuilder sb = new StringBuilder();
sb.append("Query for ").append(query.getQuestion().getName()).append(" returned:\n");
sb.append("Answer section:\n");
response.getSection(Section.ANSWER).stream().forEach(rr -> sb.append(rr).append("\n"));
sb.append("Authority section:\n");
response.getSection(Section.AUTHORITY).stream().forEach(rr -> sb.append(rr).append("\n"));
sb.append("\n");
return sb.toString();
}
} }

View file

@ -63,4 +63,61 @@ public class RecordUtils {
return warnings; return warnings;
} }
/**
* Determine by looking at a signed RRset whether the RRset name was the result of a wildcard
* expansion. If so, return the name of the generating wildcard.
*
* @param rrset The rrset to chedck.
* @return the wildcard name, if the rrset was synthesized from a wildcard. null if not.
*/
public static Name rrsetWildcard(RRset rrset) {
List<RRSIGRecord> sigs = rrset.sigs();
RRSIGRecord firstSig = sigs.getFirst();
// check rest of signatures have identical label count
for(int i = 1; i < sigs.size(); i++) {
if(sigs.get(i).getLabels() != firstSig.getLabels()) {
throw new IllegalArgumentException("Label count mismatch on RRSIGs");
}
}
// if the RRSIG label count is shorter than the number of actual labels,
// then this rrset was synthesized from a wildcard.
// Note that the RRSIG label count doesn't count the root label.
Name wn = rrset.getName();
// skip a leading wildcard label in the dname (RFC4035 2.2)
if(rrset.getName().isWild()) {
wn = new Name(wn, 1);
}
int labelDiff = (wn.labels() - 1) - firstSig.getLabels();
if(labelDiff > 0) {
return wn.wild(labelDiff);
}
return null;
}
/**
* Finds the longest domain name in common with the given name.
*
* @param domain1 The first domain to process.
* @param domain2 The second domain to process.
* @return The longest label in common of domain1 and domain2. The least common name is the root.
*/
public static Name longestCommonName(Name domain1, Name domain2) {
int l = Math.min(domain1.labels(), domain2.labels());
domain1 = new Name(domain1, domain1.labels() - l);
domain2 = new Name(domain2, domain2.labels() - l);
for(int i = 0; i < l - 1; i++) {
Name ns1 = new Name(domain1, i);
if(ns1.equals(new Name(domain2, i))) {
return ns1;
}
}
return Name.root;
}
} }

View file

@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutionException;
import static com.sparrowwallet.drongo.protocol.ScriptType.*; import static com.sparrowwallet.drongo.protocol.ScriptType.*;
import static com.sparrowwallet.drongo.psbt.PSBTEntry.*; import static com.sparrowwallet.drongo.psbt.PSBTEntry.*;
@ -294,7 +295,7 @@ public class PSBTOutput {
return dnssecProof; return dnssecProof;
} }
public Optional<DnsPayment> getDnsPayment() throws DnsPaymentValidationException, IOException, BitcoinURIParseException { public Optional<DnsPayment> getDnsPayment() throws DnsPaymentValidationException, IOException, BitcoinURIParseException, ExecutionException, InterruptedException {
if(dnssecProof == null || dnssecProof.isEmpty()) { if(dnssecProof == null || dnssecProof.isEmpty()) {
return Optional.empty(); return Optional.empty();
} }

File diff suppressed because one or more lines are too long