diff --git a/build.gradle b/build.gradle index 10c45ea..74a7f87 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,9 @@ dependencies { implementation ('de.mkammerer:argon2-jvm:2.11') { exclude group: 'net.java.dev.jna', module: 'jna' } - implementation ('net.java.dev.jna:jna:5.13.0') + implementation('dnsjava:dnsjava:3.6.3') + implementation('com.github.ben-manes.caffeine:caffeine:3.0.1') + implementation ('net.java.dev.jna:jna:5.16.0') implementation ('ch.qos.logback:logback-classic:1.5.18') { exclude group: 'org.slf4j' } diff --git a/src/main/java/com/sparrowwallet/drongo/dns/DnsPayment.java b/src/main/java/com/sparrowwallet/drongo/dns/DnsPayment.java new file mode 100644 index 0000000..ce099cb --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/DnsPayment.java @@ -0,0 +1,28 @@ +package com.sparrowwallet.drongo.dns; + +import com.sparrowwallet.drongo.uri.BitcoinURI; +import org.xbill.DNS.*; +import org.xbill.DNS.Record; + +import static com.sparrowwallet.drongo.dns.RecordUtils.fromWire; + +public record DnsPayment(String hrn, BitcoinURI bitcoinURI, byte[] proofChain) { + public String toString() { + return "₿" + hrn; + } + + public long getTTL() { + long ttl = DnsPaymentCache.MAX_TTL_SECONDS; + DNSInput in = new DNSInput(proofChain); + while(in.remaining() > 0) { + try { + Record record = fromWire(in, Section.ANSWER, false); + ttl = Math.min(ttl, record.getTTL()); + } catch(WireParseException e) { + //ignore + } + } + + return ttl; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentCache.java b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentCache.java new file mode 100644 index 0000000..fb6a265 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentCache.java @@ -0,0 +1,42 @@ +package com.sparrowwallet.drongo.dns; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.sparrowwallet.drongo.address.Address; +import org.checkerframework.checker.index.qual.NonNegative; +import org.checkerframework.checker.nullness.qual.NonNull; + +import java.util.concurrent.TimeUnit; + +public class DnsPaymentCache { + public static final long MAX_TTL_SECONDS = 604800L; + public static final long MIN_TTL_SECONDS = 1800L; + + private static final Cache<@NonNull Address, @NonNull DnsPayment> dnsPayments = Caffeine.newBuilder().expireAfter(new Expiry<@NonNull Address, @NonNull DnsPayment>() { + @Override + public long expireAfterCreate(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime) { + return TimeUnit.SECONDS.toNanos(Math.max(dnsPayment.getTTL(), MIN_TTL_SECONDS)); + } + + @Override + public long expireAfterUpdate(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime, @NonNegative long currentDuration) { + return expireAfterCreate(address, dnsPayment, currentTime); + } + + @Override + public long expireAfterRead(@NonNull Address address, @NonNull DnsPayment dnsPayment, long currentTime, @NonNegative long currentDuration) { + return currentDuration; + } + }).build(); + + private DnsPaymentCache() {} + + public static DnsPayment getDnsPayment(Address address) { + return dnsPayments.getIfPresent(address); + } + + public static void putDnsPayment(Address address, DnsPayment dnsPayment) { + dnsPayments.put(address, dnsPayment); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentResolver.java b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentResolver.java new file mode 100644 index 0000000..f92793a --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentResolver.java @@ -0,0 +1,180 @@ +package com.sparrowwallet.drongo.dns; + +import com.sparrowwallet.drongo.uri.BitcoinURI; +import com.sparrowwallet.drongo.uri.BitcoinURIParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xbill.DNS.*; +import org.xbill.DNS.Record; +import org.xbill.DNS.dnssec.ValidatingResolver; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; + +import static com.sparrowwallet.drongo.uri.BitcoinURI.BITCOIN_SCHEME; + +public class DnsPaymentResolver { + private static final Logger log = LoggerFactory.getLogger(DnsPaymentResolver.class); + + private static final String BITCOIN_URI_PREFIX = BITCOIN_SCHEME + ":"; + private static final String DEFAULT_RESOLVER_IP_ADDRESS = "8.8.8.8"; + + static String ROOT = ". IN DS 20326 8 2 E06D44B80B8F1D39A95C0B0D7C65D08458E880409BBC683457104237C7F8EC8D\n" + + ". IN DS 38696 8 2 683D2D0ACB8C9B712A1948B27F741219298D0A450D612C483AF444A4C0FB2B16"; + + private final String hrn; + private final String domain; + + public DnsPaymentResolver(String hrn) { + if(!StandardCharsets.US_ASCII.newEncoder().canEncode(hrn)) { + throw new IllegalArgumentException("Invalid HRN containing non-ASCII characters: " + hrn); + } + this.hrn = hrn; + String[] parts = hrn.split("@"); + if(parts.length != 2) { + throw new IllegalArgumentException("Invalid HRN: " + hrn); + } + this.domain = parts[0] + ".user._bitcoin-payment." + parts[1]; + } + + public Optional resolve() throws IOException, DnsPaymentValidationException, BitcoinURIParseException { + return resolve(DEFAULT_RESOLVER_IP_ADDRESS); + } + + /** + * Performs online resolution of the BIP 353 HRN via the configured resolver + * + * @param resolverIpAddress the IP address of the resolver to use for the DNS lookup + * @return The DNS payment instruction, if present + * @throws IOException Thrown for a general I/O error + * @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure + * @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI + */ + public Optional resolve(String resolverIpAddress) throws IOException, DnsPaymentValidationException, BitcoinURIParseException { + 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); + + Lookup lookup = new Lookup(domain, Type.TXT); + lookup.setResolver(resolver); + + Message query = getQuery(); + Message response = resolver.send(query); + if(response.getSection(Section.ANSWER).isEmpty()) { + return Optional.empty(); + } + + 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())); + } + + /** + * Performs offline resolution of the BIP 353 HRN via the provided authentication chain + * + * @param proofChain authentication chain of unsorted DNS records in wire format + * @return The DNS payment instruction, if present + * @throws IOException Thrown for a general I/O error + * @throws DnsPaymentValidationException Thrown for a DNSSEC or BIP 353 validation failure + * @throws BitcoinURIParseException Thrown for an invalid BIP 21 URI + */ + public Optional resolve(byte[] proofChain) throws IOException, DnsPaymentValidationException, BitcoinURIParseException { + OfflineResolver offlineResolver = new OfflineResolver(proofChain); + ValidatingResolver offlineValidatingResolver = new ValidatingResolver(offlineResolver); + offlineValidatingResolver.loadTrustAnchors(new ByteArrayInputStream(ROOT.getBytes(StandardCharsets.US_ASCII))); + + Instant now = Instant.now(); + Instant oneHourAgo = now.minusSeconds(3600); + for(Record record : offlineResolver.getCachedSigs()) { + if(record instanceof RRSIGRecord rrsig) { + if(rrsig.getTimeSigned().isAfter(now)) { + throw new DnsPaymentValidationException("Invalid RRSIG record signed in the future"); + } else if(rrsig.getExpire().isBefore(oneHourAgo)) { + throw new DnsPaymentValidationException("Invalid RRSIG record expired earlier than 1 hour ago"); + } + } + } + + 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 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 void validateResponse(Message response, List records) throws DnsPaymentValidationException { + boolean isValidated = response.getHeader().getFlag(Flags.AD); + if(!isValidated) { + throw new DnsPaymentValidationException("DNSSEC validation failed, could not authenticate the payment instruction"); + } + + Map securityWarnings = RecordUtils.checkSecurityConstraints(records); + if(!securityWarnings.isEmpty()) { + Optional optWarning = securityWarnings.entrySet().stream().map(e -> e.getKey().getName() + ": " + e.getValue()).reduce((a, b) -> a + "\n" + b); + throw new DnsPaymentValidationException("DNSSEC validation failed with the following errors:\n" + optWarning.get()); + } + } + + private String getBitcoinURI(List answers) throws DnsPaymentValidationException { + StringBuilder uriBuilder = new StringBuilder(); + for(Record record : answers) { + if(record.getType() == Type.TXT) { + TXTRecord txt = (TXTRecord)record; + List strings = txt.getStrings(); + log.debug("Found TXT records for " + domain + ": " + strings); + if(strings.isEmpty() || !strings.getFirst().startsWith(BITCOIN_URI_PREFIX)) { + continue; + } + if(strings.getFirst().startsWith(BITCOIN_URI_PREFIX) && !uriBuilder.isEmpty()) { + throw new DnsPaymentValidationException("Multiple TXT records found starting with " + BITCOIN_URI_PREFIX); + } + for(String s : strings) { + uriBuilder.append(s); + } + } + } + + return uriBuilder.toString(); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentValidationException.java b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentValidationException.java new file mode 100644 index 0000000..eaf45bf --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/DnsPaymentValidationException.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.drongo.dns; + +public class DnsPaymentValidationException extends Exception { + public DnsPaymentValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/OfflineResolver.java b/src/main/java/com/sparrowwallet/drongo/dns/OfflineResolver.java new file mode 100644 index 0000000..ce7e113 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/OfflineResolver.java @@ -0,0 +1,137 @@ +package com.sparrowwallet.drongo.dns; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +import org.xbill.DNS.*; +import org.xbill.DNS.Record; + +import static com.sparrowwallet.drongo.dns.RecordUtils.fromWire; + +public class OfflineResolver implements Resolver { + private final List cachedRrs = new ArrayList<>(); + private final List cachedSigs = new ArrayList<>(); + + public OfflineResolver(byte[] chain) throws WireParseException { + DNSInput in = new DNSInput(chain); + while(in.remaining() > 0) { + Record record = fromWire(in, Section.ANSWER, false); + if(record instanceof RRSIGRecord rrsig) { + cachedSigs.add(rrsig); + } else { + cachedRrs.add(record); + } + } + } + + @Override + public void setPort(int port) { + throw new UnsupportedOperationException("Unsupported"); + } + + @Override + public void setTCP(boolean flag) { + throw new UnsupportedOperationException("Unsupported"); + } + + // No-op + @Override + public void setIgnoreTruncation(boolean flag) {} + + // No-op + @Override + public void setEDNS(int level, int payloadSize, int flags, List options) {} + + @Override + public void setTSIGKey(TSIG key) { + throw new UnsupportedOperationException("Unsupported"); + } + + @Override + public void setTimeout(Duration timeout) { + throw new UnsupportedOperationException("Unsupported"); + } + + @Override + public CompletionStage sendAsync(Message query, Executor executor) { + Record question = query.getQuestion(); + List records = new ArrayList<>(); + + 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); + } + + return CompletableFuture.completedFuture(response); + } + + private Message makeEmptyResponse(Message query) { + Header messageHeader = new Header(); + messageHeader.setID(query.getHeader().getID()); + messageHeader.setRcode(Rcode.NXDOMAIN); + messageHeader.setFlag(Flags.QR); + messageHeader.setFlag(Flags.CD); + messageHeader.setFlag(Flags.RD); + messageHeader.setFlag(Flags.RA); + + Message answerMessage = new Message(); + answerMessage.setHeader(messageHeader); + + return answerMessage; + } + + private Message makeResponseForRecords(List records, Message query) { + Message answerMessage = new Message(); + + Header messageHeader = new Header(); + messageHeader.setID(query.getHeader().getID()); + messageHeader.setRcode(Rcode.NOERROR); + messageHeader.setFlag(Flags.QR); + messageHeader.setFlag(Flags.CD); + messageHeader.setFlag(Flags.RD); + messageHeader.setFlag(Flags.RA); + 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; + } + + public List getCachedRrs() { + return cachedRrs; + } + + public List getCachedSigs() { + return cachedSigs; + } + + public List getRecords() { + List records = new ArrayList<>(cachedRrs); + records.addAll(cachedSigs); + return records; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/PersistingResolver.java b/src/main/java/com/sparrowwallet/drongo/dns/PersistingResolver.java new file mode 100644 index 0000000..9e9f399 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/PersistingResolver.java @@ -0,0 +1,60 @@ +package com.sparrowwallet.drongo.dns; + +import org.xbill.DNS.*; +import org.xbill.DNS.Record; + +import java.io.ByteArrayOutputStream; +import java.net.UnknownHostException; +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +public class PersistingResolver extends SimpleResolver { + private final Set chain = new LinkedHashSet<>(); + + public PersistingResolver(String hostname) throws UnknownHostException { + super(hostname); + } + + @Override + public CompletionStage sendAsync(Message query, Executor executor) { + CompletionStage result = super.sendAsync(query, executor); + return result.thenApply(response -> { + addAnswerSectionToChain(response.getSection(Section.ANSWER)); + addAuthoritySectionToChain(response.getSection(Section.AUTHORITY)); + return response; + }); + } + + private void addAnswerSectionToChain(List section) { + if(section != null) { + chain.addAll(section); + } + } + + private void addAuthoritySectionToChain(List 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) { + chain.add(r); + } + } + } + } + + public Set getChain() { + return chain; + } + + public byte[] chainToWire() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + List sorted = new ArrayList<>(chain); + Collections.sort(sorted); + for(Record record : sorted) { + baos.writeBytes(record.toWireCanonical()); + } + + return baos.toByteArray(); + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/dns/RecordUtils.java b/src/main/java/com/sparrowwallet/drongo/dns/RecordUtils.java new file mode 100644 index 0000000..0b0fcf2 --- /dev/null +++ b/src/main/java/com/sparrowwallet/drongo/dns/RecordUtils.java @@ -0,0 +1,66 @@ +package com.sparrowwallet.drongo.dns; + +import org.xbill.DNS.*; +import org.xbill.DNS.Record; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class RecordUtils { + public static org.xbill.DNS.Record fromWire(DNSInput in, int section, boolean isUpdate) throws WireParseException { + int type; + int dclass; + long ttl; + int length; + Name name; + + name = new Name(in); + type = in.readU16(); + dclass = in.readU16(); + + if(section == Section.QUESTION) { + return org.xbill.DNS.Record.newRecord(name, type, dclass); + } + + ttl = in.readU32(); + length = in.readU16(); + if(length == 0 && isUpdate && (section == Section.PREREQ || section == Section.UPDATE)) { + return org.xbill.DNS.Record.newRecord(name, type, dclass, ttl); + } + + return Record.newRecord(name, type, dclass, ttl, length, in.readByteArray(length)); + } + + public static Map checkSecurityConstraints(List section) { + Map warnings = new HashMap<>(); + if(section != null) { + for(Record record : section) { + if(record.getType() == Type.RRSIG) { + RRSIGRecord rrsig = (RRSIGRecord)record; + if(rrsig.getAlgorithm() == DNSSEC.Algorithm.RSASHA1 || rrsig.getAlgorithm() == DNSSEC.Algorithm.RSA_NSEC3_SHA1) { + warnings.put(record, "Record contains weak SHA-1 based signature"); + } + } else if(record.getType() == Type.DNSKEY) { + DNSKEYRecord dnskey = (DNSKEYRecord)record; + if(dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA1 || dnskey.getAlgorithm() == DNSSEC.Algorithm.RSA_NSEC3_SHA1 || + dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA256 || dnskey.getAlgorithm() == DNSSEC.Algorithm.RSASHA512) { + try { + java.security.PublicKey publicKey = dnskey.getPublicKey(); + if(publicKey instanceof java.security.interfaces.RSAPublicKey rsaKey) { + int keyLength = rsaKey.getModulus().bitLength(); + if(keyLength < 1024) { + warnings.put(record, "Record contains weak RSA public key with key length of " + keyLength + " bits"); + } + } + } catch(DNSSEC.DNSSECException e) { + warnings.put(record, "Record contains invalid public key"); + } + } + } + } + } + + return warnings; + } +} diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java index f75fb63..d0e546c 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBT.java @@ -144,6 +144,7 @@ public class PSBT { } List outputNodes = new ArrayList<>(); + List> dnsProofs = new ArrayList<>(); for(TransactionOutput txOutput : transaction.getOutputs()) { try { Address address = txOutput.getScript().getToAddresses()[0]; @@ -152,16 +153,18 @@ public class PSBT { } else if(walletTransaction.getChangeMap().keySet().stream().anyMatch(changeNode -> changeNode.getAddress().equals(address))) { outputNodes.add(walletTransaction.getChangeMap().keySet().stream().filter(changeNode -> changeNode.getAddress().equals(address)).findFirst().orElse(null)); } + dnsProofs.add(walletTransaction.getAddressDnsProofMap().get(address)); } catch(NonStandardScriptException e) { //Ignore, likely OP_RETURN output outputNodes.add(null); + dnsProofs.add(null); } } for(int outputIndex = 0; outputIndex < outputNodes.size(); outputIndex++) { WalletNode outputNode = outputNodes.get(outputIndex); if(outputNode == null) { - PSBTOutput externalRecipientOutput = new PSBTOutput(this, outputIndex, null, null, null, Collections.emptyMap(), Collections.emptyMap(), null); + PSBTOutput externalRecipientOutput = new PSBTOutput(this, outputIndex, null, null, null, Collections.emptyMap(), Collections.emptyMap(), null, dnsProofs.get(outputIndex)); psbtOutputs.add(externalRecipientOutput); } else { TransactionOutput txOutput = transaction.getOutputs().get(outputIndex); @@ -192,7 +195,7 @@ public class PSBT { } } - PSBTOutput walletOutput = new PSBTOutput(this, outputIndex, recipientWallet.getScriptType(), redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey); + PSBTOutput walletOutput = new PSBTOutput(this, outputIndex, recipientWallet.getScriptType(), redeemScript, witnessScript, derivedPublicKeys, Collections.emptyMap(), tapInternalKey, null); psbtOutputs.add(walletOutput); } } diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java index e0f6080..60bd884 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTEntry.java @@ -9,6 +9,7 @@ import com.sparrowwallet.drongo.protocol.VarInt; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -144,6 +145,39 @@ public class PSBTEntry { return baos.toByteArray(); } + public static Map parseDnssecProof(byte[] data) throws PSBTParseException { + if(data.length == 0) { + throw new PSBTParseException("No data provided for DNSSEC proof"); + } + + ByteBuffer bb = ByteBuffer.wrap(data); + int strLen = bb.get(); + if(data.length < strLen + 1) { + throw new PSBTParseException("Invalid string length of " + strLen + " provided for DNSSEC proof"); + } + + byte[] strBytes = new byte[strLen]; + bb.get(strBytes); + String hrn = new String(strBytes, StandardCharsets.US_ASCII); + byte[] proof = new byte[bb.remaining()]; + bb.get(proof); + return Map.of(hrn, proof); + } + + public static byte[] serializeDnssecProof(Map dnssecProof) { + if(dnssecProof.isEmpty()) { + throw new IllegalArgumentException("No DNSSEC proof provided"); + } + + String hrn = dnssecProof.keySet().iterator().next(); + byte[] proof = dnssecProof.get(hrn); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write(hrn.length()); + baos.writeBytes(hrn.getBytes(StandardCharsets.US_ASCII)); + baos.writeBytes(proof); + return baos.toByteArray(); + } + static PSBTEntry populateEntry(int type, byte[] keyData, byte[] data) { ByteArrayOutputStream baos = new ByteArrayOutputStream(1 + (keyData == null ? 0 : keyData.length)); baos.writeBytes(writeCompactInt(type)); diff --git a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java index f0e07df..94a49c0 100644 --- a/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java +++ b/src/main/java/com/sparrowwallet/drongo/psbt/PSBTOutput.java @@ -3,10 +3,15 @@ package com.sparrowwallet.drongo.psbt; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.dns.DnsPayment; +import com.sparrowwallet.drongo.dns.DnsPaymentResolver; +import com.sparrowwallet.drongo.dns.DnsPaymentValidationException; import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.uri.BitcoinURIParseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.util.*; import static com.sparrowwallet.drongo.protocol.ScriptType.*; @@ -20,6 +25,7 @@ public class PSBTOutput { public static final byte PSBT_OUT_SCRIPT = 0x04; public static final byte PSBT_OUT_TAP_INTERNAL_KEY = 0x05; public static final byte PSBT_OUT_TAP_BIP32_DERIVATION = 0x07; + public static final byte PSBT_OUT_DNSSEC_PROOF = 0x35; public static final byte PSBT_OUT_PROPRIETARY = (byte)0xfc; private Script redeemScript; @@ -28,6 +34,7 @@ public class PSBTOutput { private final Map proprietary = new LinkedHashMap<>(); private Map>> tapDerivedPublicKeys = new LinkedHashMap<>(); private ECKey tapInternalKey; + private Map dnssecProof; //PSBTv2 fields private Long amount; @@ -43,7 +50,8 @@ public class PSBTOutput { this.index = index; } - PSBTOutput(PSBT psbt, int index, ScriptType scriptType, Script redeemScript, Script witnessScript, Map derivedPublicKeys, Map proprietary, ECKey tapInternalKey) { + PSBTOutput(PSBT psbt, int index, ScriptType scriptType, Script redeemScript, Script witnessScript, Map derivedPublicKeys, + Map proprietary, ECKey tapInternalKey, Map dnssecProof) { this(psbt, index); this.redeemScript = redeemScript; @@ -61,6 +69,8 @@ public class PSBTOutput { KeyDerivation tapKeyDerivation = derivedPublicKeys.values().iterator().next(); tapDerivedPublicKeys.put(this.tapInternalKey, Map.of(tapKeyDerivation, Collections.emptyList())); } + + this.dnssecProof = dnssecProof; } PSBTOutput(PSBT psbt, List outputEntries, int index) throws PSBTParseException { @@ -119,6 +129,10 @@ public class PSBTOutput { } } break; + case PSBT_OUT_DNSSEC_PROOF: + entry.checkOneByteKey(); + this.dnssecProof = parseDnssecProof(entry.getData()); + break; default: log.warn("PSBT output not recognized key type: " + entry.getKeyType()); } @@ -165,6 +179,10 @@ public class PSBTOutput { entries.add(populateEntry(PSBT_OUT_TAP_INTERNAL_KEY, null, tapInternalKey.getPubKeyXCoord())); } + if(dnssecProof != null) { + entries.add(populateEntry(PSBT_OUT_DNSSEC_PROOF, null, serializeDnssecProof(dnssecProof))); + } + return entries; } @@ -272,6 +290,24 @@ public class PSBTOutput { this.tapInternalKey = tapInternalKey; } + public Map getDnssecProof() { + return dnssecProof; + } + + public Optional getDnsPayment() throws DnsPaymentValidationException, IOException, BitcoinURIParseException { + if(dnssecProof == null || dnssecProof.isEmpty()) { + return Optional.empty(); + } + + String hrn = dnssecProof.keySet().iterator().next(); + DnsPaymentResolver resolver = new DnsPaymentResolver(hrn); + return resolver.resolve(dnssecProof.get(hrn)); + } + + public void setDnssecProof(Map dnssecProof) { + this.dnssecProof = dnssecProof; + } + public TransactionOutput getOutput() { return psbt.getTransaction().getOutputs().get(index); } diff --git a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java index 800d0b8..466b310 100644 --- a/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java +++ b/src/main/java/com/sparrowwallet/drongo/uri/BitcoinURI.java @@ -75,6 +75,8 @@ public class BitcoinURI { public static final DecimalFormat BTC_FORMAT = new DecimalFormat("0", DecimalFormatSymbols.getInstance(Locale.ENGLISH)); public static final int SMALLEST_UNIT_EXPONENT = 8; + private final String uriString; + /** * Contains all the parameters in the order in which they were processed */ @@ -135,9 +137,7 @@ public class BitcoinURI { } } - if(addressToken.isEmpty() && getPaymentRequestUrl() == null) { - throw new BitcoinURIParseException("No address and no r= parameter found"); - } + this.uriString = input; } /** @@ -315,6 +315,10 @@ public class BitcoinURI { return builder.toString(); } + public String toURIString() { + return uriString; + } + public Payment toPayment() { long amount = getAmount() == null ? -1 : getAmount(); return new Payment(getAddress(), getLabel(), amount, false); diff --git a/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java index 5483442..1ad2c7c 100644 --- a/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java +++ b/src/main/java/com/sparrowwallet/drongo/wallet/WalletTransaction.java @@ -1,6 +1,8 @@ package com.sparrowwallet.drongo.wallet; import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.dns.DnsPayment; +import com.sparrowwallet.drongo.dns.DnsPaymentCache; import com.sparrowwallet.drongo.protocol.*; import com.sparrowwallet.drongo.psbt.PSBT; @@ -22,6 +24,7 @@ public class WalletTransaction { private final List outputs; private Map> addressNodeMap = new HashMap<>(); + private Map> addressDnsProofMap = new HashMap<>(); public WalletTransaction(Wallet wallet, Transaction transaction, List utxoSelectors, List> selectedUtxoSets, List payments, long fee) { this(wallet, transaction, utxoSelectors, selectedUtxoSets, payments, Collections.emptyMap(), fee); @@ -234,6 +237,21 @@ public class WalletTransaction { return walletAddressNodeMap; } + public Map> getAddressDnsProofMap() { + for(Payment payment : payments) { + if(addressDnsProofMap.containsKey(payment.getAddress())) { + continue; + } + + DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment.getAddress()); + if(dnsPayment != null && dnsPayment.bitcoinURI().getAddress() != null) { + addressDnsProofMap.put(dnsPayment.bitcoinURI().getAddress(), Map.of(dnsPayment.hrn(), dnsPayment.proofChain())); + } + } + + return addressDnsProofMap; + } + private List calculateOutputs() { List outputs = new ArrayList<>(); diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 236b7bc..1447377 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -6,6 +6,9 @@ open module com.sparrowwallet.drongo { requires org.slf4j; requires ch.qos.logback.core; requires ch.qos.logback.classic; + requires org.dnsjava; + requires com.github.benmanes.caffeine; + requires org.checkerframework.checker.qual; exports com.sparrowwallet.drongo; exports com.sparrowwallet.drongo.psbt; exports com.sparrowwallet.drongo.protocol; @@ -16,6 +19,7 @@ open module com.sparrowwallet.drongo { exports com.sparrowwallet.drongo.policy; exports com.sparrowwallet.drongo.uri; exports com.sparrowwallet.drongo.bip47; + exports com.sparrowwallet.drongo.dns; exports com.sparrowwallet.drongo.wallet.slip39; exports org.bitcoin; } \ No newline at end of file diff --git a/src/test/java/com/sparrowwallet/drongo/dns/DnsPaymentResolverTest.java b/src/test/java/com/sparrowwallet/drongo/dns/DnsPaymentResolverTest.java new file mode 100644 index 0000000..d3401c5 --- /dev/null +++ b/src/test/java/com/sparrowwallet/drongo/dns/DnsPaymentResolverTest.java @@ -0,0 +1,23 @@ +package com.sparrowwallet.drongo.dns; + +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.uri.BitcoinURIParseException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Optional; + +public class DnsPaymentResolverTest { + @Test + public void resolverTest() throws DnsPaymentValidationException, IOException, BitcoinURIParseException { + byte[] proofChain = Utils.hexToBytes("00002e000100013ccb0113003008000002a30068993280687d83004f66005a48a604886288cc78c2a35e48816b7a182a349f397f2cd4c1bfa6de634acc9b9b0d2236fd8f257fa8641ae46da7ca17a697c965beabb5477ea6d0cc198b77c8cb9398f8f6fd36c7dc32439409625209b7c3d12108f2c973ea735f764ee629135ed67f016e63949a84e1f120b5146a27221180a0fbd0d632cc900c488b709260f2d479e6d787f2f9fa31222cacdbb696ddc3789744c691d27a8be4486fd7a74b51e417dfb9a9ba8f148f468c536debb4a7dc3803ea6213c55c3efd19cbf29059e5e460803e9656bdac7feacc38bf2bb8a9a3cbc5025841c1b71a58246cab007209bf2f22d4fdd4b80fe6d3bce9e5d2bb80df1949d62f09feb3a5bffe2a1bc6ab000030000100013ccb01080100030803010001b11b182a464c3adc6535aa59613bda7a61cac86945c20b773095941194f4b9f516e8bd924b1e50e3fe83918b51e54529d4e5a1e45303df8462241d5e05979979ae5bf9c6c598c08a496e17f3bd3732d5aebe62667b61db1bbe178f27ac99408165a230d6aee78348e6c67789541f845b2ada96667f8dd16ae44f9e260c4a138b3bb1015965ebe609434a06464bd7d29bac47c3017e83c0f89bca1a9e3bdd0813715f3484292df589bc632e27d37efc02837cb85d770d5bd53a36edc99a8294771aa93cf22406f5506c8cf850ed85c1a475dee5c2d3700b3f5631d903524b849995c20cb407ed411f70b428ae3d642716fe239335aa961a752e67fb6dca0bf729000030000100013ccb01080100030803010001b6aec4b48567e2925a2d9c4fa4c96e6dddf86215a9bd8dd579c38ccb1199ed1be89946a7f72fc2633909a2792d0eed1b5afb2ee4c78d865a76d6cd9369d999c96af6be0a2274b8f2e9e0a0065bd20257570f08bc14c16f5616426881a83dbce6926e391c138a2ec317efa7349264de2e791c9b7d4a6048ee6eedf27bf1ece398ff0d229f18377cb1f6b98d1228ef217b8146c0c73851b89a6fc37c621ca187e16428a743ffea0072e185ef93e39525cee3ad01e0c94d2e511c8c313322c29ab91631e1856049a36898684c3056e5997473816fb547acb0be6e660bdfa89a5cb28b3669d8625f3f018c7b3b8a4860e774ee8261811ce7f96c461bc162c1a374f3000030000100013ccb01080101030803010001acffb409bcc939f831f7a1e5ec88f7a59255ec53040be432027390a4ce896d6f9086f3c5e177fbfe118163aaec7af1462c47945944c4e2c026be5e98bbcded25978272e1e3e079c5094d573f0e83c92f02b32d3513b1550b826929c80dd0f92cac966d17769fd5867b647c3f38029abdc48152eb8f207159ecc5d232c7c1537c79f4b7ac28ff11682f21681bf6d6aba555032bf6f9f036beb2aaa5b3778d6eebfba6bf9ea191be4ab0caea759e2f773a1f9029c73ecb8d5735b9321db085f1b8e2d8038fe2941992548cee0d67dd4547e11dd63af9c9fc1c5466fb684cf009d7197c2cf79e792ab501e6a8a1ca519af2cb9b5f6367e94c0d47502451357be1b5000030000100013ccb01080101030803010001af7a8deba49d995a792aefc80263e991efdbc86138a931deb2c65d5682eab5d3b03738e3dfdc89d96da64c86c0224d9ce02514d285da3068b19054e5e787b2969058e98e12566c8c808c40c0b769e1db1a24a1bd9b31e303184a31fc7bb56b85bbba8abc02cd5040a444a36d47695969849e16ad856bb58e8fac8855224400319bdab224d83fc0e66aab32ff74bfeaf0f91c454e6850a1295207bbd4cdde8f6ffb08faa9755c2e3284efa01f99393e18786cb132f1e66ebc6517318e1ce8a3b7337ebb54d035ab57d9706ecd9350d4afacd825e43c8668eece89819caf6817af62dc4fbd82f0e33f6647b2b6bda175f14607f59f4635451e6b27df282ef73d8703636f6d00002b00010000ba3800244d060d028acbb0cd28f41250a80a491389424d341522d946b0da0c0291f2d3d771d7805a03636f6d00002e00010000ba380113002b08010001518068911140687fdfb0b569006748fcc8daa42fbdbe3e9103f4c3ae041ac72e5a1ffd161004fbf2f1b2b30f82fe31e927371b70866108bd55acab8a5afa84fa3a232518b0e952af3dfad9559bcb4e82b63fbb4f8542a5ebd5d047dad5b2024bde85c4fc4205a0984735ae96cba6d6f7bace837d40b0bf7351ead906105c9459fba5e9c6cad5068b4f91908f601eabea293be0de72f2c06e9392ba60c652ebdb3ce701d547f8c31fb23a2bc44a733dbeb1af210f758cdd4c8d748522c5f074615b750ec657c168f47e42af3292325506ed16c8020f83670891c3ca7bbbdd8c467f25e0e4644a3f9e1674922819726823ca7f183d1346840899cdbe7314a2336926940ea036f6b278b7a7ada7b003636f6d00002e000100002424005700300d010001518068920efb687e474f4d0603636f6d00b2e671a909bab6910567084b8347cb199b924a4acf9e1a2602ba0adaa3b056890609bd88ee767161672bbe89466e2c035c0bce3a755f33b910047fa27a90b9c203636f6d00003000010000242400440100030df17b60fb56d522f8634153e785c0a978532ea76de39d34bb356d3d042e07f29f7b992176cc83acef7b78ea750425203b18f2b8228b3cdd2bc7eb13c3e3035a7e03636f6d00003000010000242400440101030db71f0465101ddbe2bf0c9455d12fa16c1cda44f4bf1ba2553418ad1f3aa9b06973f21b84eb532cf4035ee8d4832ca26d89306a7d32560c0cb0129d450ac108350d73706172726f7777616c6c657403636f6d00002b00010000546000243dc40d0256040d991c1075c4a8555445f9a5ce52ce6801aaf45d3e87663e7fbd68bc312b0d73706172726f7777616c6c657403636f6d00002b0001000054600024cf3b0d02656da59836422f5e198e73fc35e6a89bc0838deaac565e71a19804fc1250e4ce0d73706172726f7777616c6c657403636f6d00002e0001000054600057002b0d020001518068876196687e16ae504103636f6d00c23f50cb0c009ffaf23dfb105a24110c95a41de00c100bc35305c870fcfa6d6ed94581f64199c9328f748a1f04889c92c7122f4e0d6611e8653a608705348fc80d73706172726f7777616c6c657403636f6d00002e000100000a80006500300d0200000e106891efe0687e29603dc40d73706172726f7777616c6c657403636f6d002b96ad4cdc8619f89d74317373ff0b40b9de3132cf957ee57c653c204d1d3611d6264d6baefb1c45c1fe2d499cc77587183f4900a1f0512b0478a60e4944c0410d73706172726f7777616c6c657403636f6d000030000100000a8000440100030d24c8364b3f942b0062f1c63880b959b2e7827f1cffff8d5e38f7fde1b22d621d1c4a0cd9a9b0c6c70b1c94543ccdc5502481aebd6e2b44656c9ea339ac81e83b0d73706172726f7777616c6c657403636f6d000030000100000a8000440100030d95676c7b25e7794a8a7e4b19ed638e47aca735d02ce2dd08b2886c20c31a2cb9e7cc8b85023a46eeb637020119dcaa6bbc0747e12340fa813199799de579de8a0d73706172726f7777616c6c657403636f6d000030000100000a8000440101030db0372521337fd56d8b62e917b7866b7faa753d25322e12b52a3eb5ff9f4c9f66227f508fe33ba139f2f1354fe3ded6d3da76d49be926198dc2940f2c5282c7fe0d73706172726f7777616c6c657403636f6d000030000100000a8000440101030dddf917743f320a49f6218d706218b6cae574f1db7688555e0d5f0455405d6865993f0147fb4b33baa207b28d232c9e70419ddcae72050311098cd4cfaa07969b0563726169670475736572105f626974636f696e2d7061796d656e740d73706172726f7777616c6c657403636f6d0000100001000002fe003332626974636f696e3a6263317177746865343378657561736b6c636c71346b766872656c75763368753932727a656a34326a730563726169670475736572105f626974636f696e2d7061796d656e740d73706172726f7777616c6c657403636f6d00002e0001000002fe006500100d0500000e106891efe0687e2960bb260d73706172726f7777616c6c657403636f6d00e7ae93d23b747737554c4d52dd1ec0f58c411c6a474da46c3c24d0db970d86e91bf91b5eabeb1ed59121678ef534a25a6f75ce0588e6524439c11d208f301d46"); + DnsPaymentResolver resolver = new DnsPaymentResolver("craig@sparrowwallet.com"); + Optional dnsPayment = resolver.resolve(proofChain); + if(dnsPayment.isPresent()) { + Assertions.assertEquals(dnsPayment.get().bitcoinURI().getAddress().toString(), "bc1qwthe43xeuasklclq4kvhreluv3hu92rzej42js"); + } else { + Assertions.fail("Could not resolve proof chain"); + } + } +}