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;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import org.slf4j.Logger;
@ -7,12 +8,17 @@ import org.slf4j.LoggerFactory;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
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.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ExecutionException;
import static com.sparrowwallet.drongo.uri.BitcoinURI.BITCOIN_SCHEME;
@ -27,8 +33,13 @@ public class DnsPaymentResolver {
private final String hrn;
private final String domain;
private final Clock clock;
public DnsPaymentResolver(String hrn) {
this(hrn, Clock.systemUTC());
}
public DnsPaymentResolver(String hrn, Clock clock) {
if(!StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) {
throw new IllegalArgumentException("Invalid HRN containing non-ASCII characters: " + hrn);
}
@ -38,9 +49,10 @@ public class DnsPaymentResolver {
throw new IllegalArgumentException("Invalid HRN: " + hrn);
}
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);
}
@ -53,32 +65,40 @@ public class DnsPaymentResolver {
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @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);
PersistingResolver persistingResolver = new PersistingResolver(resolverIpAddress);
ValidatingResolver resolver = new ValidatingResolver(persistingResolver);
resolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
resolver.setEDNS(0, 0, ExtendedFlags.DO);
ValidatingResolver validatingResolver = new ValidatingResolver(persistingResolver, clock);
validatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII)));
validatingResolver.setEDNS(0, 0, ExtendedFlags.DO);
AuthenticatingResolver authenticatingResolver = new AuthenticatingResolver(validatingResolver);
Lookup lookup = new Lookup(domain, Type.TXT);
lookup.setResolver(resolver);
try {
LookupSession lookupSession = LookupSession.builder().resolver(authenticatingResolver).build();
LookupResult result = lookupSession.lookupAsync(getName(), Type.TXT, DClass.IN).toCompletableFuture().get();
if(result.getRecords().isEmpty()) {
return Optional.empty();
}
Message query = getQuery();
Message response = resolver.send(query);
if(response.getSection(Section.ANSWER).isEmpty()) {
return Optional.empty();
String strBitcoinUri = getBitcoinURI(result.getRecords());
if(strBitcoinUri.isEmpty()) {
return Optional.empty();
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(authenticatingResolver, new ArrayList<>(persistingResolver.getChain()));
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;
}
}
checkResponse(response, new ArrayList<>(persistingResolver.getChain()));
String strBitcoinUri = getBitcoinURI(response.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) {
return Optional.empty();
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(response, new ArrayList<>(persistingResolver.getChain()));
return Optional.of(new DnsPayment(hrn, bitcoinURI, persistingResolver.chainToWire()));
}
/**
@ -90,12 +110,13 @@ public class DnsPaymentResolver {
* @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure
* @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);
ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver);
ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver, clock);
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);
for(Record record : offlineResolver.getCachedSigs()) {
if(record instanceof RRSIGRecord rrsig) {
@ -107,44 +128,36 @@ public class DnsPaymentResolver {
}
}
Message query = getQuery();
Message offlineResponse = offlineValidatingResolver.send(query);
if(offlineResponse.getSection(Section.ANSWER).isEmpty()) {
return Optional.empty();
}
checkResponse(offlineResponse, offlineResolver.getRecords());
String strBitcoinUri = getBitcoinURI(offlineResponse.getSection(Section.ANSWER));
if(strBitcoinUri.isEmpty()) {
throw new BitcoinURIParseException("The DNS record for " + hrn + " did not contain a Bitcoin URI");
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(offlineResponse, offlineResolver.getRecords());
return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain));
}
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());
}
try {
LookupSession lookupSession = LookupSession.builder().resolver(authenticatingResolver).build();
LookupResult result = lookupSession.lookupAsync(getName(), Type.TXT, DClass.IN).toCompletableFuture().get();
if(result.getRecords().isEmpty()) {
return Optional.empty();
}
throw new DnsPaymentValidationException("DNS query for " + domain + " failed, " + (reason.isEmpty() ? "rcode was " + response.getRcode() : reason.toString()));
String strBitcoinUri = getBitcoinURI(result.getRecords());
if(strBitcoinUri.isEmpty()) {
return Optional.empty();
}
BitcoinURI bitcoinURI = new BitcoinURI(strBitcoinUri);
validateResponse(authenticatingResolver, offlineResolver.getRecords());
return Optional.of(new DnsPayment(hrn, bitcoinURI, proofChain));
} catch(ExecutionException e) {
if(e.getCause() instanceof NoSuchDomainException) {
return Optional.empty();
} else {
throw e;
}
}
}
private void validateResponse(Message response, List<Record> records) throws DnsPaymentValidationException {
boolean isValidated = response.getHeader().getFlag(Flags.AD);
private Name getName() throws TextParseException {
return Name.fromString(domain + ".");
}
private void validateResponse(AuthenticatingResolver resolver, List<Record> records) throws DnsPaymentValidationException {
boolean isValidated = resolver.isAuthenticated();
if(!isValidated) {
throw new DnsPaymentValidationException("DNSSEC validation failed, could not authenticate the payment instruction");
}

View file

@ -58,32 +58,80 @@ public class OfflineResolver implements Resolver {
@Override
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
Record question = query.getQuestion();
List<Record> records = new ArrayList<>();
Message response = makeNoErrorResponse(query);
addRecords(query.getQuestion(), response);
for(Record it : cachedRrs) {
if(it.getName().equals(question.getName()) && it.getType() == question.getType() && it.getDClass() == question.getDClass()) {
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);
if(response.getSection(Section.ANSWER).isEmpty() && response.getSection(Section.AUTHORITY).isEmpty()) {
response = makeNoDomainResponse(query);
}
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();
messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NXDOMAIN);
@ -95,12 +143,14 @@ public class OfflineResolver implements Resolver {
Message answerMessage = new Message();
answerMessage.setHeader(messageHeader);
for(Record record : query.getSection(Section.QUESTION)) {
answerMessage.addRecord(record, Section.QUESTION);
}
return answerMessage;
}
private Message makeResponseForRecords(List<Record> records, Message query) {
Message answerMessage = new Message();
private Message makeNoErrorResponse(Message query) {
Header messageHeader = new Header();
messageHeader.setID(query.getHeader().getID());
messageHeader.setRcode(Rcode.NOERROR);
@ -108,16 +158,14 @@ public class OfflineResolver implements Resolver {
messageHeader.setFlag(Flags.CD);
messageHeader.setFlag(Flags.RD);
messageHeader.setFlag(Flags.RA);
Message answerMessage = new Message();
answerMessage.setHeader(messageHeader);
for(Record record : query.getSection(Section.QUESTION)) {
answerMessage.addRecord(record, Section.QUESTION);
}
for(Record record : records) {
answerMessage.addRecord(record, Section.ANSWER);
}
return answerMessage;
}

View file

@ -1,5 +1,7 @@
package com.sparrowwallet.drongo.dns;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xbill.DNS.*;
import org.xbill.DNS.Record;
@ -10,6 +12,8 @@ import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
public class PersistingResolver extends SimpleResolver {
private static final Logger log = LoggerFactory.getLogger(PersistingResolver.class);
private final Set<Record> chain = new LinkedHashSet<>();
public PersistingResolver(String hostname) throws UnknownHostException {
@ -20,6 +24,10 @@ public class PersistingResolver extends SimpleResolver {
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
CompletionStage<Message> result = super.sendAsync(query, executor);
return result.thenApply(response -> {
if(log.isDebugEnabled()) {
log.debug(responseToString(query, response));
}
addAnswerSectionToChain(response.getSection(Section.ANSWER));
addAuthoritySectionToChain(response.getSection(Section.AUTHORITY));
return response;
@ -35,7 +43,7 @@ public class PersistingResolver extends SimpleResolver {
private void addAuthoritySectionToChain(List<Record> section) {
if(section != null) {
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);
}
}
@ -57,4 +65,15 @@ public class PersistingResolver extends SimpleResolver {
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;
}
/**
* 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.util.*;
import java.util.concurrent.ExecutionException;
import static com.sparrowwallet.drongo.protocol.ScriptType.*;
import static com.sparrowwallet.drongo.psbt.PSBTEntry.*;
@ -294,7 +295,7 @@ public class PSBTOutput {
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()) {
return Optional.empty();
}

File diff suppressed because one or more lines are too long