mirror of
https://github.com/sparrowwallet/drongo.git
synced 2025-11-05 11:56:38 +00:00
add dnssec resolver for bip353 names and associated psbt output field for dnssec proof
This commit is contained in:
parent
2a456dd602
commit
58cc096f8e
15 changed files with 651 additions and 7 deletions
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
28
src/main/java/com/sparrowwallet/drongo/dns/DnsPayment.java
Normal file
28
src/main/java/com/sparrowwallet/drongo/dns/DnsPayment.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DnsPayment> 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<DnsPayment> 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<DnsPayment> 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<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 void validateResponse(Message response, List<Record> 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<Record, String> securityWarnings = RecordUtils.checkSecurityConstraints(records);
|
||||
if(!securityWarnings.isEmpty()) {
|
||||
Optional<String> 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<Record> answers) throws DnsPaymentValidationException {
|
||||
StringBuilder uriBuilder = new StringBuilder();
|
||||
for(Record record : answers) {
|
||||
if(record.getType() == Type.TXT) {
|
||||
TXTRecord txt = (TXTRecord)record;
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.sparrowwallet.drongo.dns;
|
||||
|
||||
public class DnsPaymentValidationException extends Exception {
|
||||
public DnsPaymentValidationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
137
src/main/java/com/sparrowwallet/drongo/dns/OfflineResolver.java
Normal file
137
src/main/java/com/sparrowwallet/drongo/dns/OfflineResolver.java
Normal file
|
|
@ -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<Record> cachedRrs = new ArrayList<>();
|
||||
private final List<RRSIGRecord> 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<EDNSOption> options) {}
|
||||
|
||||
@Override
|
||||
public void setTSIGKey(TSIG key) {
|
||||
throw new UnsupportedOperationException("Unsupported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTimeout(Duration timeout) {
|
||||
throw new UnsupportedOperationException("Unsupported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
|
||||
Record question = query.getQuestion();
|
||||
List<Record> 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<Record> 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<Record> getCachedRrs() {
|
||||
return cachedRrs;
|
||||
}
|
||||
|
||||
public List<RRSIGRecord> getCachedSigs() {
|
||||
return cachedSigs;
|
||||
}
|
||||
|
||||
public List<Record> getRecords() {
|
||||
List<Record> records = new ArrayList<>(cachedRrs);
|
||||
records.addAll(cachedSigs);
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Record> chain = new LinkedHashSet<>();
|
||||
|
||||
public PersistingResolver(String hostname) throws UnknownHostException {
|
||||
super(hostname);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<Message> sendAsync(Message query, Executor executor) {
|
||||
CompletionStage<Message> 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<org.xbill.DNS.Record> section) {
|
||||
if(section != null) {
|
||||
chain.addAll(section);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
chain.add(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Set<Record> getChain() {
|
||||
return chain;
|
||||
}
|
||||
|
||||
public byte[] chainToWire() {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
List<Record> sorted = new ArrayList<>(chain);
|
||||
Collections.sort(sorted);
|
||||
for(Record record : sorted) {
|
||||
baos.writeBytes(record.toWireCanonical());
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
66
src/main/java/com/sparrowwallet/drongo/dns/RecordUtils.java
Normal file
66
src/main/java/com/sparrowwallet/drongo/dns/RecordUtils.java
Normal file
|
|
@ -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<Record, String> checkSecurityConstraints(List<Record> section) {
|
||||
Map<Record, String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +144,7 @@ public class PSBT {
|
|||
}
|
||||
|
||||
List<WalletNode> outputNodes = new ArrayList<>();
|
||||
List<Map<String, byte[]>> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, byte[]> 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<String, byte[]> 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));
|
||||
|
|
|
|||
|
|
@ -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<String, String> proprietary = new LinkedHashMap<>();
|
||||
private Map<ECKey, Map<KeyDerivation, List<Sha256Hash>>> tapDerivedPublicKeys = new LinkedHashMap<>();
|
||||
private ECKey tapInternalKey;
|
||||
private Map<String, byte[]> 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<ECKey, KeyDerivation> derivedPublicKeys, Map<String, String> proprietary, ECKey tapInternalKey) {
|
||||
PSBTOutput(PSBT psbt, int index, ScriptType scriptType, Script redeemScript, Script witnessScript, Map<ECKey, KeyDerivation> derivedPublicKeys,
|
||||
Map<String, String> proprietary, ECKey tapInternalKey, Map<String, byte[]> 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<PSBTEntry> 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<String, byte[]> getDnssecProof() {
|
||||
return dnssecProof;
|
||||
}
|
||||
|
||||
public Optional<DnsPayment> 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<String, byte[]> dnssecProof) {
|
||||
this.dnssecProof = dnssecProof;
|
||||
}
|
||||
|
||||
public TransactionOutput getOutput() {
|
||||
return psbt.getTransaction().getOutputs().get(index);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Output> outputs;
|
||||
|
||||
private Map<Wallet, Map<Address, WalletNode>> addressNodeMap = new HashMap<>();
|
||||
private Map<Address, Map<String, byte[]>> addressDnsProofMap = new HashMap<>();
|
||||
|
||||
public WalletTransaction(Wallet wallet, Transaction transaction, List<UtxoSelector> utxoSelectors, List<Map<BlockTransactionHashIndex, WalletNode>> selectedUtxoSets, List<Payment> payments, long fee) {
|
||||
this(wallet, transaction, utxoSelectors, selectedUtxoSets, payments, Collections.emptyMap(), fee);
|
||||
|
|
@ -234,6 +237,21 @@ public class WalletTransaction {
|
|||
return walletAddressNodeMap;
|
||||
}
|
||||
|
||||
public Map<Address, Map<String, byte[]>> 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<Output> calculateOutputs() {
|
||||
List<Output> outputs = new ArrayList<>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue