diff --git a/core/src/main/java/bisq/core/api/CoreAccountService.java b/core/src/main/java/bisq/core/api/CoreAccountService.java new file mode 100644 index 00000000..bb5187e8 --- /dev/null +++ b/core/src/main/java/bisq/core/api/CoreAccountService.java @@ -0,0 +1,50 @@ +package bisq.core.api; + +import javax.inject.Singleton; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * @deprecated Should be replaced by actual implementation once it is available + */ +@Singleton +@Deprecated +public class CoreAccountService { + + + private static final String DEFAULT_PASSWORD = "abctesting123"; + + private String password = DEFAULT_PASSWORD; + + private final List listeners = new CopyOnWriteArrayList<>(); + + + public String getPassword() { + return password; + } + + public void setPassword(String newPassword) { + String oldPassword = password; + password = newPassword; + notifyListenerAboutPasswordChange(oldPassword, newPassword); + } + + public void addPasswordChangeListener(PasswordChangeListener listener) { + Objects.requireNonNull(listener, "listener"); + listeners.add(listener); + } + + private void notifyListenerAboutPasswordChange(String oldPassword, String newPassword) { + for (PasswordChangeListener listener : listeners) { + listener.onPasswordChange(oldPassword, newPassword); + } + } + + public interface PasswordChangeListener { + + void onPasswordChange(String oldPassword, String newPassword); + + } +} diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index 944046a3..7c0ff3af 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -59,6 +59,7 @@ import lombok.extern.slf4j.Slf4j; +import monero.common.MoneroRpcConnection; import monero.wallet.model.MoneroDestination; import monero.wallet.model.MoneroTxWallet; @@ -81,6 +82,7 @@ public class CoreApi { private final CoreWalletsService walletsService; private final TradeStatisticsManager tradeStatisticsManager; private final CoreNotificationService notificationService; + private final CoreMoneroConnectionsService coreMoneroConnectionsService; @Inject public CoreApi(Config config, @@ -92,7 +94,8 @@ public class CoreApi { CoreTradesService coreTradesService, CoreWalletsService walletsService, TradeStatisticsManager tradeStatisticsManager, - CoreNotificationService notificationService) { + CoreNotificationService notificationService, + CoreMoneroConnectionsService coreMoneroConnectionsService) { this.config = config; this.coreDisputeAgentsService = coreDisputeAgentsService; this.coreHelpService = coreHelpService; @@ -103,6 +106,7 @@ public class CoreApi { this.walletsService = walletsService; this.tradeStatisticsManager = tradeStatisticsManager; this.notificationService = notificationService; + this.coreMoneroConnectionsService = coreMoneroConnectionsService; } @SuppressWarnings("SameReturnValue") @@ -398,4 +402,56 @@ public class CoreApi { public int getNumConfirmationsForMostRecentTransaction(String addressString) { return walletsService.getNumConfirmationsForMostRecentTransaction(addressString); } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Monero Connections + /////////////////////////////////////////////////////////////////////////////////////////// + + public void addMoneroConnection(MoneroRpcConnection connection) { + coreMoneroConnectionsService.addConnection(connection); + } + + public void removeMoneroConnection(String connectionUri) { + coreMoneroConnectionsService.removeConnection(connectionUri); + } + + public MoneroRpcConnection getMoneroConnection() { + return coreMoneroConnectionsService.getConnection(); + } + + public List getMoneroConnections() { + return coreMoneroConnectionsService.getConnections(); + } + + public void setMoneroConnection(String connectionUri) { + coreMoneroConnectionsService.setConnection(connectionUri); + } + + public void setMoneroConnection(MoneroRpcConnection connection) { + coreMoneroConnectionsService.setConnection(connection); + } + + public MoneroRpcConnection checkMoneroConnection() { + return coreMoneroConnectionsService.checkConnection(); + } + + public List checkMoneroConnections() { + return coreMoneroConnectionsService.checkConnections(); + } + + public void startCheckingMoneroConnection(Long refreshPeriod) { + coreMoneroConnectionsService.startCheckingConnection(refreshPeriod); + } + + public void stopCheckingMoneroConnection() { + coreMoneroConnectionsService.stopCheckingConnection(); + } + + public MoneroRpcConnection getBestAvailableMoneroConnection() { + return coreMoneroConnectionsService.getBestAvailableConnection(); + } + + public void setMoneroConnectionAutoSwitch(boolean autoSwitch) { + coreMoneroConnectionsService.setAutoSwitch(autoSwitch); + } } diff --git a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java new file mode 100644 index 00000000..bfc5fbea --- /dev/null +++ b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java @@ -0,0 +1,162 @@ +package bisq.core.api; + +import bisq.core.btc.model.EncryptedConnectionList; +import java.util.Arrays; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import monero.common.MoneroConnectionManager; +import monero.common.MoneroConnectionManagerListener; +import monero.common.MoneroRpcConnection; + +@Slf4j +@Singleton +public class CoreMoneroConnectionsService { + + // TODO: this connection manager should update app status, don't poll in WalletsSetup every 30 seconds + private static final long DEFAULT_REFRESH_PERIOD = 15_000L; // check the connection every 15 seconds per default + + // TODO (woodser): support each network type, move to config, remove localhost authentication + private static final List DEFAULT_CONNECTIONS = Arrays.asList( + new MoneroRpcConnection("http://localhost:38081", "superuser", "abctesting123").setPriority(1), // localhost is first priority + new MoneroRpcConnection("http://haveno.exchange:38081", "", "").setPriority(2) + ); + + private final Object lock = new Object(); + private final MoneroConnectionManager connectionManager; + private final EncryptedConnectionList connectionList; + + @Inject + public CoreMoneroConnectionsService(MoneroConnectionManager connectionManager, + EncryptedConnectionList connectionList) { + this.connectionManager = connectionManager; + this.connectionList = connectionList; + } + + public void initialize() { + synchronized (lock) { + + // load connections + connectionList.getConnections().forEach(connectionManager::addConnection); + + // add default connections + for (MoneroRpcConnection connection : DEFAULT_CONNECTIONS) { + if (connectionList.hasConnection(connection.getUri())) continue; + addConnection(connection); + } + + // restore last used connection + connectionList.getCurrentConnectionUri().ifPresentOrElse(connectionManager::setConnection, () -> { + connectionManager.setConnection(DEFAULT_CONNECTIONS.get(0).getUri()); // default to localhost + }); + + // restore configuration + connectionManager.setAutoSwitch(connectionList.getAutoSwitch()); + long refreshPeriod = connectionList.getRefreshPeriod(); + if (refreshPeriod > 0) connectionManager.startCheckingConnection(refreshPeriod); + else if (refreshPeriod == 0) connectionManager.startCheckingConnection(DEFAULT_REFRESH_PERIOD); + else checkConnection(); + + // register connection change listener + connectionManager.addListener(this::onConnectionChanged); + } + } + + private void onConnectionChanged(MoneroRpcConnection currentConnection) { + synchronized (lock) { + if (currentConnection == null) { + connectionList.setCurrentConnectionUri(null); + } else { + connectionList.removeConnection(currentConnection.getUri()); + connectionList.addConnection(currentConnection); + connectionList.setCurrentConnectionUri(currentConnection.getUri()); + } + } + } + + public void addConnectionListener(MoneroConnectionManagerListener listener) { + synchronized (lock) { + connectionManager.addListener(listener); + } + } + + public void addConnection(MoneroRpcConnection connection) { + synchronized (lock) { + connectionList.addConnection(connection); + connectionManager.addConnection(connection); + } + } + + public void removeConnection(String uri) { + synchronized (lock) { + connectionList.removeConnection(uri); + connectionManager.removeConnection(uri); + } + } + + public MoneroRpcConnection getConnection() { + synchronized (lock) { + return connectionManager.getConnection(); + } + } + + public List getConnections() { + synchronized (lock) { + return connectionManager.getConnections(); + } + } + + public void setConnection(String connectionUri) { + synchronized (lock) { + connectionManager.setConnection(connectionUri); // listener will update connection list + } + } + + public void setConnection(MoneroRpcConnection connection) { + synchronized (lock) { + connectionManager.setConnection(connection); // listener will update connection list + } + } + + public MoneroRpcConnection checkConnection() { + synchronized (lock) { + connectionManager.checkConnection(); + return getConnection(); + } + } + + public List checkConnections() { + synchronized (lock) { + connectionManager.checkConnections(); + return getConnections(); + } + } + + public void startCheckingConnection(Long refreshPeriod) { + synchronized (lock) { + connectionManager.startCheckingConnection(refreshPeriod == null ? DEFAULT_REFRESH_PERIOD : refreshPeriod); + connectionList.setRefreshPeriod(refreshPeriod); + } + } + + public void stopCheckingConnection() { + synchronized (lock) { + connectionManager.stopCheckingConnection(); + connectionList.setRefreshPeriod(-1L); + } + } + + public MoneroRpcConnection getBestAvailableConnection() { + synchronized (lock) { + return connectionManager.getBestAvailableConnection(); + } + } + + public void setAutoSwitch(boolean autoSwitch) { + synchronized (lock) { + connectionManager.setAutoSwitch(autoSwitch); + connectionList.setAutoSwitch(autoSwitch); + } + } +} diff --git a/core/src/main/java/bisq/core/api/model/EncryptedConnection.java b/core/src/main/java/bisq/core/api/model/EncryptedConnection.java new file mode 100644 index 00000000..96775064 --- /dev/null +++ b/core/src/main/java/bisq/core/api/model/EncryptedConnection.java @@ -0,0 +1,40 @@ +package bisq.core.api.model; + +import bisq.common.proto.persistable.PersistablePayload; + +import com.google.protobuf.ByteString; + +import lombok.Builder; +import lombok.Value; + + +@Value +@Builder(toBuilder = true) +public class EncryptedConnection implements PersistablePayload { + + String uri; + String username; + byte[] encryptedPassword; + byte[] encryptionSalt; + int priority; + + @Override + public protobuf.EncryptedConnection toProtoMessage() { + return protobuf.EncryptedConnection.newBuilder() + .setUri(uri) + .setUsername(username) + .setEncryptedPassword(ByteString.copyFrom(encryptedPassword)) + .setEncryptionSalt(ByteString.copyFrom(encryptionSalt)) + .setPriority(priority) + .build(); + } + + public static EncryptedConnection fromProto(protobuf.EncryptedConnection encryptedConnection) { + return new EncryptedConnection( + encryptedConnection.getUri(), + encryptedConnection.getUsername(), + encryptedConnection.getEncryptedPassword().toByteArray(), + encryptedConnection.getEncryptionSalt().toByteArray(), + encryptedConnection.getPriority()); + } +} diff --git a/core/src/main/java/bisq/core/app/CoreModule.java b/core/src/main/java/bisq/core/app/CoreModule.java index 1b7d705e..fa7b983c 100644 --- a/core/src/main/java/bisq/core/app/CoreModule.java +++ b/core/src/main/java/bisq/core/app/CoreModule.java @@ -31,6 +31,7 @@ import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; import bisq.core.util.coin.CoinFormatter; import bisq.core.util.coin.ImmutableCoinFormatter; +import bisq.core.xmr.connection.MoneroConnectionModule; import bisq.network.crypto.EncryptionServiceModule; import bisq.network.p2p.P2PModule; @@ -91,6 +92,7 @@ public class CoreModule extends AppModule { install(new AlertModule(config)); install(new FilterModule(config)); install(new CorePresentationModule(config)); + install(new MoneroConnectionModule(config)); bind(PubKeyRing.class).toProvider(PubKeyRingProvider.class); } } diff --git a/core/src/main/java/bisq/core/btc/BitcoinModule.java b/core/src/main/java/bisq/core/btc/BitcoinModule.java index e70605e0..1303011c 100644 --- a/core/src/main/java/bisq/core/btc/BitcoinModule.java +++ b/core/src/main/java/bisq/core/btc/BitcoinModule.java @@ -18,6 +18,7 @@ package bisq.core.btc; import bisq.core.btc.model.AddressEntryList; +import bisq.core.btc.model.EncryptedConnectionList; import bisq.core.btc.model.XmrAddressEntryList; import bisq.core.btc.nodes.BtcNodes; import bisq.core.btc.setup.RegTestHost; @@ -83,6 +84,7 @@ public class BitcoinModule extends AppModule { bind(AddressEntryList.class).in(Singleton.class); bind(XmrAddressEntryList.class).in(Singleton.class); + bind(EncryptedConnectionList.class).in(Singleton.class); bind(WalletsSetup.class).in(Singleton.class); bind(XmrWalletService.class).in(Singleton.class); bind(BtcWalletService.class).in(Singleton.class); diff --git a/core/src/main/java/bisq/core/btc/model/EncryptedConnectionList.java b/core/src/main/java/bisq/core/btc/model/EncryptedConnectionList.java new file mode 100644 index 00000000..57668f2f --- /dev/null +++ b/core/src/main/java/bisq/core/btc/model/EncryptedConnectionList.java @@ -0,0 +1,395 @@ +package bisq.core.btc.model; + +import bisq.common.crypto.CryptoException; +import bisq.common.crypto.Encryption; +import bisq.common.persistence.PersistenceManager; +import bisq.common.proto.persistable.PersistableEnvelope; +import bisq.common.proto.persistable.PersistedDataHost; +import bisq.core.api.CoreAccountService; +import bisq.core.api.model.EncryptedConnection; +import bisq.core.crypto.ScryptUtil; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import javax.inject.Inject; +import com.google.protobuf.ByteString; +import com.google.protobuf.Message; +import lombok.NonNull; +import monero.common.MoneroRpcConnection; +import org.bitcoinj.crypto.KeyCrypterScrypt; + + +/** + * Store for {@link EncryptedConnection}s. + *

+ * Passwords are encrypted when stored onto disk, using the account password. + * If a connection has no password, this is "hidden" by using some random value as fake password. + * + * @implNote The password encryption mechanism is handled as follows. + * A random salt is generated and stored for each connection. If the connection has no password, + * the salt is used as prefix and some random data is attached as fake password. If the connection has a password, + * the salt is used as suffix to the actual password. When the password gets decrypted, it is checked whether the + * salt is a prefix of the decrypted value. If it is a prefix, the connection has no password. + * Otherwise, it is removed (from the end) and the remaining value is the actual password. + */ +public class EncryptedConnectionList implements PersistableEnvelope, PersistedDataHost { + + private static final int MIN_FAKE_PASSWORD_LENGTH = 5; + private static final int MAX_FAKE_PASSWORD_LENGTH = 32; + private static final int SALT_LENGTH = 16; + + transient private final ReadWriteLock lock = new ReentrantReadWriteLock(); + transient private final Lock readLock = lock.readLock(); + transient private final Lock writeLock = lock.writeLock(); + transient private final SecureRandom random = new SecureRandom(); + + transient private KeyCrypterScrypt keyCrypterScrypt; + transient private SecretKey encryptionKey; + + transient private CoreAccountService accountService; + transient private PersistenceManager persistenceManager; + + private final Map items = new HashMap<>(); + private @NonNull String currentConnectionUri = ""; + private long refreshPeriod; + private boolean autoSwitch; + + @Inject + public EncryptedConnectionList(PersistenceManager persistenceManager, + CoreAccountService accountService) { + this.accountService = accountService; + this.persistenceManager = persistenceManager; + this.persistenceManager.initialize(this, "EncryptedConnectionList", PersistenceManager.Source.PRIVATE); + this.accountService.addPasswordChangeListener(this::onPasswordChange); + } + + private EncryptedConnectionList(byte[] salt, + List items, + @NonNull String currentConnectionUri, + long refreshPeriod, + boolean autoSwitch) { + this.keyCrypterScrypt = ScryptUtil.getKeyCrypterScrypt(salt); + this.items.putAll(items.stream().collect(Collectors.toMap(EncryptedConnection::getUri, Function.identity()))); + this.currentConnectionUri = currentConnectionUri; + this.refreshPeriod = refreshPeriod; + this.autoSwitch = autoSwitch; + } + + @Override + public void readPersisted(Runnable completeHandler) { + persistenceManager.readPersisted(persistedEncryptedConnectionList -> { + writeLock.lock(); + try { + initializeEncryption(persistedEncryptedConnectionList.keyCrypterScrypt); + items.clear(); + items.putAll(persistedEncryptedConnectionList.items); + currentConnectionUri = persistedEncryptedConnectionList.currentConnectionUri; + refreshPeriod = persistedEncryptedConnectionList.refreshPeriod; + autoSwitch = persistedEncryptedConnectionList.autoSwitch; + } finally { + writeLock.unlock(); + } + completeHandler.run(); + }, () -> { + writeLock.lock(); + try { + initializeEncryption(ScryptUtil.getKeyCrypterScrypt()); + } finally { + writeLock.unlock(); + } + completeHandler.run(); + }); + } + + private void initializeEncryption(KeyCrypterScrypt keyCrypterScrypt) { + this.keyCrypterScrypt = keyCrypterScrypt; + encryptionKey = toSecretKey(accountService.getPassword()); + } + + public List getConnections() { + readLock.lock(); + try { + return items.values().stream().map(this::toMoneroRpcConnection).collect(Collectors.toList()); + } finally { + readLock.unlock(); + } + } + + public boolean hasConnection(String connection) { + readLock.lock(); + try { + return items.containsKey(connection); + } finally { + readLock.unlock(); + } + } + + public void addConnection(MoneroRpcConnection connection) { + EncryptedConnection currentValue; + writeLock.lock(); + try { + EncryptedConnection encryptedConnection = toEncryptedConnection(connection); + currentValue = items.putIfAbsent(connection.getUri(), encryptedConnection); + } finally { + writeLock.unlock(); + } + if (currentValue != null) { + throw new IllegalStateException(String.format("There exists already an connection for \"%s\"", connection.getUri())); + } + requestPersistence(); + } + + public void removeConnection(String connection) { + writeLock.lock(); + try { + items.remove(connection); + } finally { + writeLock.unlock(); + } + requestPersistence(); + } + + public void setAutoSwitch(boolean autoSwitch) { + boolean changed; + writeLock.lock(); + try { + changed = this.autoSwitch != (this.autoSwitch = autoSwitch); + } finally { + writeLock.unlock(); + } + if (changed) { + requestPersistence(); + } + } + + public boolean getAutoSwitch() { + readLock.lock(); + try { + return autoSwitch; + } finally { + readLock.unlock(); + } + } + + public void setRefreshPeriod(Long refreshPeriod) { + boolean changed; + writeLock.lock(); + try { + changed = this.refreshPeriod != (this.refreshPeriod = refreshPeriod == null ? 0L : refreshPeriod); + } finally { + writeLock.unlock(); + } + if (changed) { + requestPersistence(); + } + } + + public long getRefreshPeriod() { + readLock.lock(); + try { + return refreshPeriod; + } finally { + readLock.unlock(); + } + } + + public void setCurrentConnectionUri(String currentConnectionUri) { + boolean changed; + writeLock.lock(); + try { + changed = !this.currentConnectionUri.equals(this.currentConnectionUri = currentConnectionUri == null ? "" : currentConnectionUri); + } finally { + writeLock.unlock(); + } + if (changed) { + requestPersistence(); + } + } + + public Optional getCurrentConnectionUri() { + readLock.lock(); + try { + return Optional.of(currentConnectionUri).filter(s -> !s.isEmpty()); + } finally { + readLock.unlock(); + } + } + + private void requestPersistence() { + persistenceManager.requestPersistence(); + } + + private void onPasswordChange(String oldPassword, String newPassword) { + writeLock.lock(); + try { + SecretKey oldSecret = encryptionKey; + assert Objects.equals(oldSecret, toSecretKey(oldPassword)) : "Old secret does not match old password"; + encryptionKey = toSecretKey(newPassword); + items.replaceAll((key, connection) -> reEncrypt(connection, oldSecret, encryptionKey)); + } finally { + writeLock.unlock(); + } + requestPersistence(); + } + + private SecretKey toSecretKey(String password) { + if (password == null) { + return null; + } + return Encryption.getSecretKeyFromBytes(keyCrypterScrypt.deriveKey(password).getKey()); + } + + private static EncryptedConnection reEncrypt(EncryptedConnection connection, + SecretKey oldSecret, SecretKey newSecret) { + return connection.toBuilder() + .encryptedPassword(reEncrypt(connection.getEncryptedPassword(), oldSecret, newSecret)) + .build(); + } + + private static byte[] reEncrypt(byte[] value, + SecretKey oldSecret, SecretKey newSecret) { + // was previously not encrypted if null + byte[] decrypted = oldSecret == null ? value : decrypt(value, oldSecret); + // should not be encrypted if null + return newSecret == null ? decrypted : encrypt(decrypted, newSecret); + } + + private static byte[] decrypt(byte[] encrypted, SecretKey secret) { + try { + return Encryption.decrypt(encrypted, secret); + } catch (CryptoException e) { + throw new IllegalArgumentException("Illegal old password", e); + } + } + + private static byte[] encrypt(byte[] unencrypted, SecretKey secretKey) { + try { + return Encryption.encrypt(unencrypted, secretKey); + } catch (CryptoException e) { + throw new RuntimeException("Could not encrypt data with the provided secret", e); + } + } + + private EncryptedConnection toEncryptedConnection(MoneroRpcConnection connection) { + String password = connection.getPassword(); + byte[] passwordBytes = password == null ? null : password.getBytes(StandardCharsets.UTF_8); + byte[] passwordSalt = generateSalt(passwordBytes); + byte[] encryptedPassword = encryptPassword(passwordBytes, passwordSalt); + return EncryptedConnection.builder() + .uri(connection.getUri()) + .username(connection.getUsername() == null ? "" : connection.getUsername()) + .encryptedPassword(encryptedPassword) + .encryptionSalt(passwordSalt) + .priority(connection.getPriority()) + .build(); + } + + private MoneroRpcConnection toMoneroRpcConnection(EncryptedConnection connection) { + byte[] decryptedPasswordBytes = decryptPassword(connection.getEncryptedPassword(), connection.getEncryptionSalt()); + String password = decryptedPasswordBytes == null ? null : new String(decryptedPasswordBytes, StandardCharsets.UTF_8); + String username = connection.getUsername().isEmpty() ? null : connection.getUsername(); + MoneroRpcConnection moneroRpcConnection = new MoneroRpcConnection(connection.getUri(), username, password); + moneroRpcConnection.setPriority(connection.getPriority()); + return moneroRpcConnection; + } + + + private byte[] encryptPassword(byte[] password, byte[] salt) { + byte[] saltedPassword; + if (password == null) { + // no password given, so use salt as prefix and add some random data, which disguises itself as password + int fakePasswordLength = random.nextInt(MAX_FAKE_PASSWORD_LENGTH - MIN_FAKE_PASSWORD_LENGTH + 1) + + MIN_FAKE_PASSWORD_LENGTH; + byte[] fakePassword = new byte[fakePasswordLength]; + random.nextBytes(fakePassword); + saltedPassword = new byte[salt.length + fakePasswordLength]; + System.arraycopy(salt, 0, saltedPassword, 0, salt.length); + System.arraycopy(fakePassword, 0, saltedPassword, salt.length, fakePassword.length); + } else { + // password given, so append salt to end + saltedPassword = new byte[password.length + salt.length]; + System.arraycopy(password, 0, saltedPassword, 0, password.length); + System.arraycopy(salt, 0, saltedPassword, password.length, salt.length); + } + return encrypt(saltedPassword, encryptionKey); + } + + private byte[] decryptPassword(byte[] encryptedSaltedPassword, byte[] salt) { + byte[] decryptedSaltedPassword = decrypt(encryptedSaltedPassword, encryptionKey); + if (arrayStartsWith(decryptedSaltedPassword, salt)) { + // salt is prefix, so no actual password set + return null; + } else { + // remove salt suffix, the rest is the actual password + byte[] decryptedPassword = new byte[decryptedSaltedPassword.length - salt.length]; + System.arraycopy(decryptedSaltedPassword, 0, decryptedPassword, 0, decryptedPassword.length); + return decryptedPassword; + } + } + + private byte[] generateSalt(byte[] password) { + byte[] salt = new byte[SALT_LENGTH]; + // Generate salt, that is guaranteed to be no prefix of the password + do { + random.nextBytes(salt); + } while (password != null && arrayStartsWith(password, salt)); + return salt; + } + + private static boolean arrayStartsWith(byte[] container, byte[] prefix) { + if (container.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (container[i] != prefix[i]) { + return false; + } + } + return true; + } + + @Override + public Message toProtoMessage() { + List connections; + ByteString saltString; + String currentConnectionUri; + boolean autoSwitchEnabled; + long refreshPeriod; + readLock.lock(); + try { + connections = items.values().stream() + .map(EncryptedConnection::toProtoMessage).collect(Collectors.toList()); + saltString = keyCrypterScrypt.getScryptParameters().getSalt(); + currentConnectionUri = this.currentConnectionUri; + autoSwitchEnabled = this.autoSwitch; + refreshPeriod = this.refreshPeriod; + } finally { + readLock.unlock(); + } + return protobuf.PersistableEnvelope.newBuilder() + .setEncryptedConnectionList(protobuf.EncryptedConnectionList.newBuilder() + .setSalt(saltString) + .addAllItems(connections) + .setCurrentConnectionUri(currentConnectionUri) + .setRefreshPeriod(refreshPeriod) + .setAutoSwitch(autoSwitchEnabled)) + .build(); + } + + public static EncryptedConnectionList fromProto(protobuf.EncryptedConnectionList proto) { + List items = proto.getItemsList().stream() + .map(EncryptedConnection::fromProto) + .collect(Collectors.toList()); + return new EncryptedConnectionList(proto.getSalt().toByteArray(), items, proto.getCurrentConnectionUri(), proto.getRefreshPeriod(), proto.getAutoSwitch()); + } +} diff --git a/core/src/main/java/bisq/core/btc/setup/WalletConfig.java b/core/src/main/java/bisq/core/btc/setup/WalletConfig.java index 4386d769..0066cff4 100644 --- a/core/src/main/java/bisq/core/btc/setup/WalletConfig.java +++ b/core/src/main/java/bisq/core/btc/setup/WalletConfig.java @@ -17,6 +17,7 @@ package bisq.core.btc.setup; +import bisq.core.api.CoreMoneroConnectionsService; import bisq.core.btc.nodes.LocalBitcoinNode; import bisq.core.btc.nodes.ProxySocketFactory; import bisq.core.btc.wallet.HavenoRiskAnalysis; @@ -88,6 +89,7 @@ import static bisq.common.util.Preconditions.checkDir; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; +import monero.common.MoneroRpcConnection; import monero.common.MoneroUtils; import monero.daemon.MoneroDaemon; import monero.daemon.MoneroDaemonRpc; @@ -126,9 +128,6 @@ public class WalletConfig extends AbstractIdleService { // Monero configuration // TODO: don't hard code configuration, inject into classes? private static final MoneroNetworkType MONERO_NETWORK_TYPE = MoneroNetworkType.STAGENET; - private static final String MONERO_DAEMON_URI = "http://localhost:38081"; - private static final String MONERO_DAEMON_USERNAME = "superuser"; - private static final String MONERO_DAEMON_PASSWORD = "abctesting123"; private static final MoneroWalletRpcManager MONERO_WALLET_RPC_MANAGER = new MoneroWalletRpcManager(); private static final String MONERO_WALLET_RPC_DIR = System.getProperty("user.dir") + File.separator + ".localnet"; // .localnet contains monero-wallet-rpc and wallet files private static final String MONERO_WALLET_RPC_PATH = MONERO_WALLET_RPC_DIR + File.separator + "monero-wallet-rpc"; @@ -138,9 +137,10 @@ public class WalletConfig extends AbstractIdleService { protected final NetworkParameters params; protected final String filePrefix; + protected final CoreMoneroConnectionsService moneroConnectionsManager; protected volatile BlockChain vChain; protected volatile SPVBlockStore vStore; - protected volatile MoneroDaemon vXmrDaemon; + protected volatile MoneroDaemonRpc vXmrDaemon; protected volatile MoneroWalletRpc vXmrWallet; protected volatile Wallet vBtcWallet; protected volatile PeerGroup vPeerGroup; @@ -177,18 +177,20 @@ public class WalletConfig extends AbstractIdleService { public WalletConfig(NetworkParameters params, File directory, int rpcBindPort, + CoreMoneroConnectionsService connectionsManager, String filePrefix) { - this(new Context(params), directory, rpcBindPort, filePrefix); + this(new Context(params), directory, rpcBindPort, connectionsManager, filePrefix); } /** * Creates a new WalletConfig, with the given {@link Context}. Files will be stored in the given directory. */ - private WalletConfig(Context context, File directory, int rpcBindPort, String filePrefix) { + private WalletConfig(Context context, File directory, int rpcBindPort, CoreMoneroConnectionsService connectionsManager, String filePrefix) { this.context = context; this.params = checkNotNull(context.getParams()); this.directory = checkDir(directory); this.rpcBindPort = rpcBindPort; + this.moneroConnectionsManager = connectionsManager; this.filePrefix = checkNotNull(filePrefix); } @@ -335,15 +337,21 @@ public class WalletConfig extends AbstractIdleService { // check if monero-wallet-rpc exists if (!new File(MONERO_WALLET_RPC_PATH).exists()) throw new Error("monero-wallet-rpc executable doesn't exist at path " + MONERO_WALLET_RPC_PATH + "; copy monero-wallet-rpc to the project root or set WalletConfig.java MONERO_WALLET_RPC_PATH for your system"); + // get app's current daemon connection + MoneroRpcConnection connection = moneroConnectionsManager.getConnection(); + // start monero-wallet-rpc instance and return connected client List cmd = new ArrayList<>(Arrays.asList( // modifiable list MONERO_WALLET_RPC_PATH, "--" + MONERO_NETWORK_TYPE.toString().toLowerCase(), - "--daemon-address", MONERO_DAEMON_URI, - "--daemon-login", MONERO_DAEMON_USERNAME + ":" + MONERO_DAEMON_PASSWORD, + "--daemon-address", connection.getUri(), "--rpc-login", MONERO_WALLET_RPC_USERNAME + ":" + MONERO_WALLET_RPC_PASSWORD, "--wallet-dir", directory.toString() )); + if (connection.getUsername() != null) { + cmd.add("--daemon-login"); + cmd.add(connection.getUsername() + ":" + connection.getPassword()); + } if (port != null && port > 0) { cmd.add("--rpc-bind-port"); cmd.add(Integer.toString(port)); @@ -372,8 +380,11 @@ public class WalletConfig extends AbstractIdleService { File chainFile = new File(directory, filePrefix + ".spvchain"); boolean chainFileExists = chainFile.exists(); - // XMR daemon - vXmrDaemon = new MoneroDaemonRpc(MONERO_DAEMON_URI, MONERO_DAEMON_USERNAME, MONERO_DAEMON_PASSWORD); + // set XMR daemon and listen for updates + vXmrDaemon = new MoneroDaemonRpc(moneroConnectionsManager.getConnection()); + moneroConnectionsManager.addConnectionListener(newConnection -> { + vXmrDaemon = newConnection == null ? null : new MoneroDaemonRpc(newConnection); + }); // XMR wallet String xmrPrefix = "_XMR"; diff --git a/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java b/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java index 8f330429..c309bad5 100644 --- a/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java +++ b/core/src/main/java/bisq/core/btc/setup/WalletsSetup.java @@ -17,6 +17,7 @@ package bisq.core.btc.setup; +import bisq.core.api.CoreMoneroConnectionsService; import bisq.core.btc.exceptions.InvalidHostException; import bisq.core.btc.exceptions.RejectedTxException; import bisq.core.btc.model.AddressEntry; @@ -127,6 +128,8 @@ public class WalletsSetup { private final Config config; private final LocalBitcoinNode localBitcoinNode; private final BtcNodes btcNodes; + @Getter + private final CoreMoneroConnectionsService moneroConnectionsManager; private final String xmrWalletFileName; private final int numConnectionsForBtc; private final String userAgent; @@ -156,6 +159,7 @@ public class WalletsSetup { Config config, LocalBitcoinNode localBitcoinNode, BtcNodes btcNodes, + CoreMoneroConnectionsService moneroConnectionsManager, @Named(Config.USER_AGENT) String userAgent, @Named(Config.WALLET_DIR) File walletDir, @Named(Config.WALLET_RPC_BIND_PORT) int walletRpcBindPort, @@ -170,6 +174,7 @@ public class WalletsSetup { this.config = config; this.localBitcoinNode = localBitcoinNode; this.btcNodes = btcNodes; + this.moneroConnectionsManager = moneroConnectionsManager; this.numConnectionsForBtc = numConnectionsForBtc; this.useAllProvidedNodes = useAllProvidedNodes; this.userAgent = userAgent; @@ -201,12 +206,15 @@ public class WalletsSetup { exceptionHandler.handleException(new TimeoutException("Wallet did not initialize in " + STARTUP_TIMEOUT + " seconds.")), STARTUP_TIMEOUT); + // initialize Monero connection manager + moneroConnectionsManager.initialize(); + backupWallets(); final Socks5Proxy socks5Proxy = preferences.getUseTorForBitcoinJ() ? socks5ProxyProvider.getSocks5Proxy() : null; log.info("Socks5Proxy for bitcoinj: socks5Proxy=" + socks5Proxy); - walletConfig = new WalletConfig(params, walletDir, walletRpcBindPort, "haveno") { + walletConfig = new WalletConfig(params, walletDir, walletRpcBindPort, moneroConnectionsManager, "haveno") { @Override protected void onSetupCompleted() { //We are here in the btcj thread Thread[ STARTING,5,main] @@ -220,9 +228,7 @@ public class WalletsSetup { peerGroup.setAddPeersFromAddressMessage(false); UserThread.runPeriodically(() -> { - peers.set(getPeerConnections()); - numPeers.set(peers.get().size()); - chainHeight.set(vXmrDaemon.getHeight()); + updateDaemonInfo(); }, DAEMON_POLL_INTERVAL_SECONDS); // Need to be Threading.SAME_THREAD executor otherwise BitcoinJ will skip that listener @@ -240,9 +246,7 @@ public class WalletsSetup { // Map to user thread UserThread.execute(() -> { - peers.set(getPeerConnections()); - numPeers.set(peers.get().size()); - chainHeight.set(vXmrDaemon.getHeight()); + updateDaemonInfo(); addressEntryList.onWalletReady(walletConfig.btcWallet()); xmrAddressEntryList.onWalletReady(walletConfig.getXmrWallet()); timeoutTimer.stop(); @@ -253,7 +257,18 @@ public class WalletsSetup { UserThread.runAfter(resultHandler::handleResult, 100, TimeUnit.MILLISECONDS); } - private List getPeerConnections() { + private void updateDaemonInfo() { + try { + if (vXmrDaemon == null) throw new RuntimeException("No daemon connection"); + peers.set(getOnlinePeers()); + numPeers.set(peers.get().size()); + chainHeight.set(vXmrDaemon.getHeight()); + } catch (Exception e) { + log.warn("Could not update daemon info: " + e.getMessage()); + } + } + + private List getOnlinePeers() { return vXmrDaemon.getPeers().stream() .filter(peer -> peer.isOnline()) .collect(Collectors.toList()); diff --git a/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java b/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java index 0dcd1331..b1c3129d 100644 --- a/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/XmrWalletService.java @@ -32,6 +32,7 @@ import org.slf4j.LoggerFactory; import lombok.Getter; +import monero.common.MoneroRpcConnection; import monero.daemon.MoneroDaemon; import monero.wallet.MoneroWallet; import monero.wallet.model.MoneroDestination; @@ -53,8 +54,6 @@ public class XmrWalletService { protected final CopyOnWriteArraySet walletListeners = new CopyOnWriteArraySet<>(); private Map multisigWallets; - @Getter - private MoneroDaemon daemon; @Getter private MoneroWallet wallet; @@ -67,7 +66,6 @@ public class XmrWalletService { this.multisigWallets = new HashMap(); walletsSetup.addSetupCompletedHandler(() -> { - daemon = walletsSetup.getXmrDaemon(); wallet = walletsSetup.getXmrWallet(); wallet.addListener(new MoneroWalletListener() { @Override @@ -81,12 +79,20 @@ public class XmrWalletService { notifyBalanceListeners(); } }); + + walletsSetup.getMoneroConnectionsManager().addConnectionListener(newConnection -> { + updateDaemonConnections(newConnection); + }); }); } + public MoneroDaemon getDaemon() { + return walletsSetup.getXmrDaemon(); + } + // TODO (woodser): wallet has single password which is passed here? // TODO (woodser): test retaking failed trade. create new multisig wallet or replace? cannot reuse - + public synchronized MoneroWallet createMultisigWallet(String tradeId) { if (multisigWallets.containsKey(tradeId)) return multisigWallets.get(tradeId); String path = "xmr_multisig_trade_" + tradeId; @@ -99,7 +105,7 @@ public class XmrWalletService { multisigWallet.startSyncing(5000l); return multisigWallet; } - + public synchronized MoneroWallet getMultisigWallet(String tradeId) { if (multisigWallets.containsKey(tradeId)) return multisigWallets.get(tradeId); String path = "xmr_multisig_trade_" + tradeId; @@ -112,7 +118,7 @@ public class XmrWalletService { multisigWallet.startSyncing(5000l); // TODO (woodser): use sync period from config. apps stall if too many multisig wallets and too short sync period return multisigWallet; } - + public synchronized boolean deleteMultisigWallet(String tradeId) { String walletName = "xmr_multisig_trade_" + tradeId; if (!walletsSetup.getWalletConfig().walletExists(walletName)) return false; @@ -404,9 +410,15 @@ public class XmrWalletService { } } + private void updateDaemonConnections(MoneroRpcConnection connection) { + log.info("Setting wallet daemon connections: " + (connection == null ? null : connection.getUri())); + walletsSetup.getXmrWallet().setDaemonConnection(connection); + for (MoneroWallet multisigWallet : multisigWallets.values()) multisigWallet.setDaemonConnection(connection); + } + /** * Wraps a MoneroWalletListener to notify the Haveno application. - * + * * TODO (woodser): this is no longer necessary since not syncing to thread? */ public class HavenoWalletListener extends MoneroWalletListener { diff --git a/core/src/main/java/bisq/core/crypto/ScryptUtil.java b/core/src/main/java/bisq/core/crypto/ScryptUtil.java index 5c7735d3..4e0f7ef5 100644 --- a/core/src/main/java/bisq/core/crypto/ScryptUtil.java +++ b/core/src/main/java/bisq/core/crypto/ScryptUtil.java @@ -39,11 +39,15 @@ public class ScryptUtil { } public static KeyCrypterScrypt getKeyCrypterScrypt() { + return getKeyCrypterScrypt(KeyCrypterScrypt.randomSalt()); + } + + public static KeyCrypterScrypt getKeyCrypterScrypt(byte[] salt) { Protos.ScryptParameters scryptParameters = Protos.ScryptParameters.newBuilder() .setP(6) .setR(8) .setN(32768) - .setSalt(ByteString.copyFrom(KeyCrypterScrypt.randomSalt())) + .setSalt(ByteString.copyFrom(salt)) .build(); return new KeyCrypterScrypt(scryptParameters); } diff --git a/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java b/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java index 0671f4b5..4af4a7f8 100644 --- a/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java +++ b/core/src/main/java/bisq/core/proto/persistable/CorePersistenceProtoResolver.java @@ -20,6 +20,7 @@ package bisq.core.proto.persistable; import bisq.core.account.sign.SignedWitnessStore; import bisq.core.account.witness.AccountAgeWitnessStore; import bisq.core.btc.model.AddressEntryList; +import bisq.core.btc.model.EncryptedConnectionList; import bisq.core.btc.model.XmrAddressEntryList; import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.XmrWalletService; @@ -34,7 +35,6 @@ import bisq.core.trade.statistics.TradeStatistics2Store; import bisq.core.trade.statistics.TradeStatistics3Store; import bisq.core.user.PreferencesPayload; import bisq.core.user.UserPayload; - import bisq.network.p2p.mailbox.IgnoredMailboxMap; import bisq.network.p2p.mailbox.MailboxMessageList; import bisq.network.p2p.peers.peerexchange.PeerList; @@ -84,7 +84,9 @@ public class CorePersistenceProtoResolver extends CoreProtoResolver implements P case ADDRESS_ENTRY_LIST: return AddressEntryList.fromProto(proto.getAddressEntryList()); case XMR_ADDRESS_ENTRY_LIST: - return XmrAddressEntryList.fromProto(proto.getXmrAddressEntryList()); + return XmrAddressEntryList.fromProto(proto.getXmrAddressEntryList()); + case ENCRYPTED_CONNECTION_LIST: + return EncryptedConnectionList.fromProto(proto.getEncryptedConnectionList()); case TRADABLE_LIST: return TradableList.fromProto(proto.getTradableList(), this, xmrWalletService.get()); case ARBITRATION_DISPUTE_LIST: diff --git a/core/src/main/java/bisq/core/setup/CorePersistedDataHost.java b/core/src/main/java/bisq/core/setup/CorePersistedDataHost.java index 61440c6c..88f5efeb 100644 --- a/core/src/main/java/bisq/core/setup/CorePersistedDataHost.java +++ b/core/src/main/java/bisq/core/setup/CorePersistedDataHost.java @@ -18,6 +18,7 @@ package bisq.core.setup; import bisq.core.btc.model.AddressEntryList; +import bisq.core.btc.model.EncryptedConnectionList; import bisq.core.btc.model.XmrAddressEntryList; import bisq.core.offer.OpenOfferManager; import bisq.core.support.dispute.arbitration.ArbitrationDisputeListService; @@ -28,14 +29,12 @@ import bisq.core.trade.closed.ClosedTradableManager; import bisq.core.trade.failed.FailedTradesManager; import bisq.core.user.Preferences; import bisq.core.user.User; - import bisq.network.p2p.mailbox.IgnoredMailboxService; import bisq.network.p2p.mailbox.MailboxMessageService; import bisq.network.p2p.peers.PeerManager; import bisq.network.p2p.storage.P2PDataStorage; import bisq.network.p2p.storage.persistence.RemovedPayloadsService; -import bisq.common.config.Config; import bisq.common.proto.persistable.PersistedDataHost; import com.google.inject.Injector; @@ -55,6 +54,7 @@ public class CorePersistedDataHost { persistedDataHosts.add(injector.getInstance(User.class)); persistedDataHosts.add(injector.getInstance(AddressEntryList.class)); persistedDataHosts.add(injector.getInstance(XmrAddressEntryList.class)); + persistedDataHosts.add(injector.getInstance(EncryptedConnectionList.class)); persistedDataHosts.add(injector.getInstance(OpenOfferManager.class)); persistedDataHosts.add(injector.getInstance(TradeManager.class)); persistedDataHosts.add(injector.getInstance(ClosedTradableManager.class)); diff --git a/core/src/main/java/bisq/core/xmr/connection/MoneroConnectionModule.java b/core/src/main/java/bisq/core/xmr/connection/MoneroConnectionModule.java new file mode 100644 index 00000000..682840c4 --- /dev/null +++ b/core/src/main/java/bisq/core/xmr/connection/MoneroConnectionModule.java @@ -0,0 +1,23 @@ +package bisq.core.xmr.connection; + +import bisq.common.app.AppModule; +import bisq.common.config.Config; +import bisq.core.api.CoreMoneroConnectionsService; +import bisq.core.btc.model.EncryptedConnectionList; +import com.google.inject.Singleton; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MoneroConnectionModule extends AppModule { + + public MoneroConnectionModule(Config config) { + super(config); + } + + @Override + protected final void configure() { + bind(EncryptedConnectionList.class).in(Singleton.class); + bind(CoreMoneroConnectionsService.class).in(Singleton.class); + } +} diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroConnectionsService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroConnectionsService.java new file mode 100644 index 00000000..b8b42ea6 --- /dev/null +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroConnectionsService.java @@ -0,0 +1,286 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.daemon.grpc; + +import bisq.core.api.CoreApi; +import bisq.daemon.grpc.interceptor.CallRateMeteringInterceptor; +import bisq.daemon.grpc.interceptor.GrpcCallRateMeter; +import bisq.proto.grpc.AddConnectionReply; +import bisq.proto.grpc.AddConnectionRequest; +import bisq.proto.grpc.CheckConnectionReply; +import bisq.proto.grpc.CheckConnectionRequest; +import bisq.proto.grpc.CheckConnectionsReply; +import bisq.proto.grpc.CheckConnectionsRequest; +import bisq.proto.grpc.GetBestAvailableConnectionReply; +import bisq.proto.grpc.GetBestAvailableConnectionRequest; +import bisq.proto.grpc.GetConnectionReply; +import bisq.proto.grpc.GetConnectionRequest; +import bisq.proto.grpc.GetConnectionsReply; +import bisq.proto.grpc.GetConnectionsRequest; +import bisq.proto.grpc.RemoveConnectionReply; +import bisq.proto.grpc.RemoveConnectionRequest; +import bisq.proto.grpc.SetAutoSwitchReply; +import bisq.proto.grpc.SetAutoSwitchRequest; +import bisq.proto.grpc.SetConnectionReply; +import bisq.proto.grpc.SetConnectionRequest; +import bisq.proto.grpc.StartCheckingConnectionsReply; +import bisq.proto.grpc.StartCheckingConnectionsRequest; +import bisq.proto.grpc.StopCheckingConnectionsReply; +import bisq.proto.grpc.StopCheckingConnectionsRequest; +import bisq.proto.grpc.UriConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import javax.inject.Inject; +import io.grpc.ServerInterceptor; +import io.grpc.stub.StreamObserver; +import lombok.extern.slf4j.Slf4j; +import monero.common.MoneroRpcConnection; + +import static bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig.getCustomRateMeteringInterceptor; +import static bisq.proto.grpc.MoneroConnectionsGrpc.*; +import static java.util.concurrent.TimeUnit.SECONDS; + +@Slf4j +class GrpcMoneroConnectionsService extends MoneroConnectionsImplBase { + + private final CoreApi coreApi; + private final GrpcExceptionHandler exceptionHandler; + + @Inject + public GrpcMoneroConnectionsService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) { + this.coreApi = coreApi; + this.exceptionHandler = exceptionHandler; + } + + @Override + public void addConnection(AddConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + coreApi.addMoneroConnection(toMoneroRpcConnection(request.getConnection())); + return AddConnectionReply.newBuilder().build(); + }); + } + + @Override + public void removeConnection(RemoveConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + coreApi.removeMoneroConnection(validateUri(request.getUri())); + return RemoveConnectionReply.newBuilder().build(); + }); + } + + @Override + public void getConnection(GetConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + UriConnection replyConnection = toUriConnection(coreApi.getMoneroConnection()); + GetConnectionReply.Builder builder = GetConnectionReply.newBuilder(); + if (replyConnection != null) { + builder.setConnection(replyConnection); + } + return builder.build(); + }); + } + + @Override + public void getConnections(GetConnectionsRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + List connections = coreApi.getMoneroConnections(); + List replyConnections = connections.stream() + .map(GrpcMoneroConnectionsService::toUriConnection).collect(Collectors.toList()); + return GetConnectionsReply.newBuilder().addAllConnections(replyConnections).build(); + }); + } + + @Override + public void setConnection(SetConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + if (request.getUri() != null && !request.getUri().isEmpty()) + coreApi.setMoneroConnection(validateUri(request.getUri())); + else if (request.hasConnection()) + coreApi.setMoneroConnection(toMoneroRpcConnection(request.getConnection())); + else coreApi.setMoneroConnection((MoneroRpcConnection) null); // disconnect from client + return SetConnectionReply.newBuilder().build(); + }); + } + + @Override + public void checkConnection(CheckConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + MoneroRpcConnection connection = coreApi.checkMoneroConnection(); + UriConnection replyConnection = toUriConnection(connection); + CheckConnectionReply.Builder builder = CheckConnectionReply.newBuilder(); + if (replyConnection != null) { + builder.setConnection(replyConnection); + } + return builder.build(); + }); + } + + @Override + public void checkConnections(CheckConnectionsRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + List connections = coreApi.checkMoneroConnections(); + List replyConnections = connections.stream() + .map(GrpcMoneroConnectionsService::toUriConnection).collect(Collectors.toList()); + return CheckConnectionsReply.newBuilder().addAllConnections(replyConnections).build(); + }); + } + + @Override + public void startCheckingConnections(StartCheckingConnectionsRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + int refreshMillis = request.getRefreshPeriod(); + Long refreshPeriod = refreshMillis == 0 ? null : (long) refreshMillis; + coreApi.startCheckingMoneroConnection(refreshPeriod); + return StartCheckingConnectionsReply.newBuilder().build(); + }); + } + + @Override + public void stopCheckingConnections(StopCheckingConnectionsRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + coreApi.stopCheckingMoneroConnection(); + return StopCheckingConnectionsReply.newBuilder().build(); + }); + } + + @Override + public void getBestAvailableConnection(GetBestAvailableConnectionRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + MoneroRpcConnection connection = coreApi.getBestAvailableMoneroConnection(); + UriConnection replyConnection = toUriConnection(connection); + GetBestAvailableConnectionReply.Builder builder = GetBestAvailableConnectionReply.newBuilder(); + if (replyConnection != null) { + builder.setConnection(replyConnection); + } + return builder.build(); + }); + } + + @Override + public void setAutoSwitch(SetAutoSwitchRequest request, + StreamObserver responseObserver) { + handleRequest(responseObserver, () -> { + coreApi.setMoneroConnectionAutoSwitch(request.getAutoSwitch()); + return SetAutoSwitchReply.newBuilder().build(); + }); + } + + private <_Reply> void handleRequest(StreamObserver<_Reply> responseObserver, + RpcRequestHandler<_Reply> handler) { + try { + _Reply reply = handler.handleRequest(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + @FunctionalInterface + private interface RpcRequestHandler<_Reply> { + _Reply handleRequest() throws Exception; + } + + + private static UriConnection toUriConnection(MoneroRpcConnection rpcConnection) { + if (rpcConnection == null) return null; + return UriConnection.newBuilder() + .setUri(rpcConnection.getUri()) + .setPriority(rpcConnection.getPriority()) + .setOnlineStatus(toOnlineStatus(rpcConnection.isOnline())) + .setAuthenticationStatus(toAuthenticationStatus(rpcConnection.isAuthenticated())) + .build(); + } + + private static UriConnection.AuthenticationStatus toAuthenticationStatus(Boolean authenticated) { + if (authenticated == null) return UriConnection.AuthenticationStatus.NO_AUTHENTICATION; + else if (authenticated) return UriConnection.AuthenticationStatus.AUTHENTICATED; + else return UriConnection.AuthenticationStatus.NOT_AUTHENTICATED; + } + + private static UriConnection.OnlineStatus toOnlineStatus(Boolean online) { + if (online == null) return UriConnection.OnlineStatus.UNKNOWN; + else if (online) return UriConnection.OnlineStatus.ONLINE; + else return UriConnection.OnlineStatus.OFFLINE; + } + + private static MoneroRpcConnection toMoneroRpcConnection(UriConnection uriConnection) throws URISyntaxException { + if (uriConnection == null) return null; + return new MoneroRpcConnection( + validateUri(uriConnection.getUri()), + nullIfEmpty(uriConnection.getUsername()), + nullIfEmpty(uriConnection.getPassword())) + .setPriority(uriConnection.getPriority()); + } + + private static String validateUri(String uri) throws URISyntaxException { + if (uri.isEmpty()) { + throw new IllegalArgumentException("URI is required"); + } + // Create new URI for validation, internally String is used again + return new URI(uri).toString(); + } + + private static String nullIfEmpty(String value) { + if (value == null || value.isEmpty()) { + return null; + } + return value; + } + + + final ServerInterceptor[] interceptors() { + Optional rateMeteringInterceptor = rateMeteringInterceptor(); + return rateMeteringInterceptor.map(serverInterceptor -> + new ServerInterceptor[]{serverInterceptor}).orElseGet(() -> new ServerInterceptor[0]); + } + + private Optional rateMeteringInterceptor() { + return getCustomRateMeteringInterceptor(coreApi.getConfig().appDataDir, this.getClass()) + .or(() -> Optional.of(CallRateMeteringInterceptor.valueOf( + new HashMap<>() {{ + int allowedCallsPerTimeWindow = 10; + put(getAddConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getRemoveConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getGetConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getGetConnectionsMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getSetConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getCheckConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getCheckConnectionsMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getStartCheckingConnectionsMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getStopCheckingConnectionsMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getGetBestAvailableConnectionMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getSetAutoSwitchMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + }} + ))); + } +} diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java index 8bde9587..0eac7f93 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java @@ -59,7 +59,8 @@ public class GrpcServer { GrpcGetTradeStatisticsService tradeStatisticsService, GrpcTradesService tradesService, GrpcWalletsService walletsService, - GrpcNotificationsService notificationsService) { + GrpcNotificationsService notificationsService, + GrpcMoneroConnectionsService moneroConnectionsService) { this.server = ServerBuilder.forPort(config.apiPort) .executor(UserThread.getExecutor()) .addService(interceptForward(disputeAgentsService, disputeAgentsService.interceptors())) @@ -73,6 +74,7 @@ public class GrpcServer { .addService(interceptForward(versionService, versionService.interceptors())) .addService(interceptForward(walletsService, walletsService.interceptors())) .addService(interceptForward(notificationsService, notificationsService.interceptors())) + .addService(interceptForward(moneroConnectionsService, moneroConnectionsService.interceptors())) .intercept(passwordAuthInterceptor) .build(); coreContext.setApiUser(true); diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index b4aa2fcb..ea6c28ba 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -532,7 +532,7 @@ message GetNewDepositSubaddressRequest { } message GetNewDepositSubaddressReply { - string subaddress = 1; + string subaddress = 1; } message GetXmrTxsRequest { @@ -705,6 +705,120 @@ message AddressBalanceInfo { bool is_address_unused = 4; } +/////////////////////////////////////////////////////////////////////////////////////////// +// MoneroConnections +/////////////////////////////////////////////////////////////////////////////////////////// + +service MoneroConnections { + rpc AddConnection (AddConnectionRequest) returns (AddConnectionReply) { + } + rpc RemoveConnection(RemoveConnectionRequest) returns (RemoveConnectionReply) { + } + rpc GetConnection(GetConnectionRequest) returns (GetConnectionReply) { + } + rpc GetConnections(GetConnectionsRequest) returns (GetConnectionsReply) { + } + rpc SetConnection(SetConnectionRequest) returns (SetConnectionReply) { + } + rpc CheckConnection(CheckConnectionRequest) returns (CheckConnectionReply) { + } + rpc CheckConnections(CheckConnectionsRequest) returns (CheckConnectionsReply) { + } + rpc StartCheckingConnections(StartCheckingConnectionsRequest) returns (StartCheckingConnectionsReply) { + } + rpc StopCheckingConnections(StopCheckingConnectionsRequest) returns (StopCheckingConnectionsReply) { + } + rpc GetBestAvailableConnection(GetBestAvailableConnectionRequest) returns (GetBestAvailableConnectionReply) { + } + rpc SetAutoSwitch(SetAutoSwitchRequest) returns (SetAutoSwitchReply) { + } +} + +message UriConnection { + enum OnlineStatus { + UNKNOWN = 0; + ONLINE = 1; + OFFLINE = 2; + } + enum AuthenticationStatus { + NO_AUTHENTICATION = 0; + AUTHENTICATED = 1; + NOT_AUTHENTICATED = 2; + } + + string uri = 1; + string username = 2; // request only + string password = 3; // request only + int32 priority = 4; + OnlineStatus online_status = 5; // reply only + AuthenticationStatus authentication_status = 6; // reply only +} + +message AddConnectionRequest { + UriConnection connection = 1; +} + +message AddConnectionReply {} + +message RemoveConnectionRequest { + string uri = 1; +} + +message RemoveConnectionReply {} + +message GetConnectionRequest {} + +message GetConnectionReply { + UriConnection connection = 1; +} + +message GetConnectionsRequest {} + +message GetConnectionsReply { + repeated UriConnection connections = 1; +} + +message SetConnectionRequest { + string uri = 1; + UriConnection connection = 2; +} + +message SetConnectionReply {} + +message CheckConnectionRequest {} + +message CheckConnectionReply { + UriConnection connection = 1; +} + +message CheckConnectionsRequest {} + +message CheckConnectionsReply { + repeated UriConnection connections = 1; +} + +message StartCheckingConnectionsRequest { + int32 refresh_period = 1; // milliseconds +} + +message StartCheckingConnectionsReply {} + +message StopCheckingConnectionsRequest {} + +message StopCheckingConnectionsReply {} + +message GetBestAvailableConnectionRequest {} + +message GetBestAvailableConnectionReply { + UriConnection connection = 1; +} + +message SetAutoSwitchRequest { + bool auto_switch = 1; +} + +message SetAutoSwitchReply {} + /////////////////////////////////////////////////////////////////////////////////////////// // Version /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 563e3e0b..474f5e24 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1321,6 +1321,7 @@ message PersistableEnvelope { XmrAddressEntryList xmr_address_entry_list = 1001; SignedOfferList signed_offer_list = 1002; + EncryptedConnectionList encrypted_connection_list = 1003; } } @@ -1690,6 +1691,26 @@ message TradingPeer { string deposit_tx_key = 1010; } +/////////////////////////////////////////////////////////////////////////////////////////// +// Connections +/////////////////////////////////////////////////////////////////////////////////////////// + +message EncryptedConnection { + string uri = 1; + string username = 2; + bytes encrypted_password = 3; + bytes encryption_salt = 4; + int32 priority = 5; +} + +message EncryptedConnectionList { + bytes salt = 1; + repeated EncryptedConnection items = 2; + string current_connection_uri = 3; + int64 refresh_period = 4; // negative: no automated refresh is activated, zero: automated refresh with default period, positive: automated refresh with configured period (value) + bool auto_switch = 5; +} + /////////////////////////////////////////////////////////////////////////////////////////// // Dispute ///////////////////////////////////////////////////////////////////////////////////////////