Merge pull request #4214 from ghubstan/rpc-wallet-protection

Add rpc wallet protection endpoints
This commit is contained in:
Chris Beams 2020-05-18 14:18:05 +02:00
commit 2e33c2c87a
No known key found for this signature in database
GPG Key ID: 3D214F8F5BC5ED73
7 changed files with 443 additions and 121 deletions

View File

@ -17,15 +17,18 @@
package bisq.cli;
import bisq.proto.grpc.GetBalanceGrpc;
import bisq.proto.grpc.GetBalanceRequest;
import bisq.proto.grpc.GetVersionGrpc;
import bisq.proto.grpc.GetVersionRequest;
import bisq.proto.grpc.LockWalletRequest;
import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.WalletGrpc;
import io.grpc.ManagedChannelBuilder;
import io.grpc.StatusRuntimeException;
import joptsimple.OptionException;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
@ -41,6 +44,7 @@ import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import static java.lang.String.format;
import static java.lang.System.err;
import static java.lang.System.exit;
import static java.lang.System.out;
@ -51,15 +55,25 @@ import static java.lang.System.out;
@Slf4j
public class CliMain {
private static final int EXIT_SUCCESS = 0;
private static final int EXIT_FAILURE = 1;
private enum Method {
getversion,
getbalance
getbalance,
lockwallet,
unlockwallet,
removewalletpassword,
setwalletpassword
}
public static void main(String[] args) {
try {
run(args);
} catch (Throwable t) {
err.println("Error: " + t.getMessage());
exit(1);
}
}
public static void run(String[] args) {
var parser = new OptionParser();
var helpOpt = parser.accepts("help", "Print this help text")
@ -77,43 +91,33 @@ public class CliMain {
var passwordOpt = parser.accepts("password", "rpc server password")
.withRequiredArg();
OptionSet options = null;
try {
options = parser.parse(args);
} catch (OptionException ex) {
err.println("Error: " + ex.getMessage());
exit(EXIT_FAILURE);
}
OptionSet options = parser.parse(args);
if (options.has(helpOpt)) {
printHelp(parser, out);
exit(EXIT_SUCCESS);
return;
}
@SuppressWarnings("unchecked")
var nonOptionArgs = (List<String>) options.nonOptionArguments();
if (nonOptionArgs.isEmpty()) {
printHelp(parser, err);
err.println("Error: no method specified");
exit(EXIT_FAILURE);
throw new IllegalArgumentException("no method specified");
}
var methodName = nonOptionArgs.get(0);
Method method = null;
final Method method;
try {
method = Method.valueOf(methodName);
} catch (IllegalArgumentException ex) {
err.printf("Error: '%s' is not a supported method\n", methodName);
exit(EXIT_FAILURE);
throw new IllegalArgumentException(format("'%s' is not a supported method", methodName));
}
var host = options.valueOf(hostOpt);
var port = options.valueOf(portOpt);
var password = options.valueOf(passwordOpt);
if (password == null) {
err.println("Error: missing required 'password' option");
exit(EXIT_FAILURE);
}
if (password == null)
throw new IllegalArgumentException("missing required 'password' option");
var credentials = new PasswordCallCredentials(password);
@ -122,43 +126,87 @@ public class CliMain {
try {
channel.shutdown().awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException ex) {
ex.printStackTrace(err);
exit(EXIT_FAILURE);
throw new RuntimeException(ex);
}
}));
var versionService = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials);
var walletService = WalletGrpc.newBlockingStub(channel).withCallCredentials(credentials);
try {
switch (method) {
case getversion: {
var stub = GetVersionGrpc.newBlockingStub(channel).withCallCredentials(credentials);
var request = GetVersionRequest.newBuilder().build();
var version = stub.getVersion(request).getVersion();
var version = versionService.getVersion(request).getVersion();
out.println(version);
exit(EXIT_SUCCESS);
return;
}
case getbalance: {
var stub = GetBalanceGrpc.newBlockingStub(channel).withCallCredentials(credentials);
var request = GetBalanceRequest.newBuilder().build();
var balance = stub.getBalance(request).getBalance();
if (balance == -1) {
err.println("Error: server is still initializing");
exit(EXIT_FAILURE);
var reply = walletService.getBalance(request);
var satoshiBalance = reply.getBalance();
var satoshiDivisor = new BigDecimal(100000000);
var btcFormat = new DecimalFormat("###,##0.00000000");
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
var btcBalance = btcFormat.format(BigDecimal.valueOf(satoshiBalance).divide(satoshiDivisor));
out.println(btcBalance);
return;
}
case lockwallet: {
var request = LockWalletRequest.newBuilder().build();
walletService.lockWallet(request);
out.println("wallet locked");
return;
}
case unlockwallet: {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no password specified");
if (nonOptionArgs.size() < 3)
throw new IllegalArgumentException("no unlock timeout specified");
long timeout;
try {
timeout = Long.parseLong(nonOptionArgs.get(2));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(format("'%s' is not a number", nonOptionArgs.get(2)));
}
out.println(formatBalance(balance));
exit(EXIT_SUCCESS);
var request = UnlockWalletRequest.newBuilder()
.setPassword(nonOptionArgs.get(1))
.setTimeout(timeout).build();
walletService.unlockWallet(request);
out.println("wallet unlocked");
return;
}
case removewalletpassword: {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no password specified");
var request = RemoveWalletPasswordRequest.newBuilder().setPassword(nonOptionArgs.get(1)).build();
walletService.removeWalletPassword(request);
out.println("wallet decrypted");
return;
}
case setwalletpassword: {
if (nonOptionArgs.size() < 2)
throw new IllegalArgumentException("no password specified");
var requestBuilder = SetWalletPasswordRequest.newBuilder().setPassword(nonOptionArgs.get(1));
var hasNewPassword = nonOptionArgs.size() == 3;
if (hasNewPassword)
requestBuilder.setNewPassword(nonOptionArgs.get(2));
walletService.setWalletPassword(requestBuilder.build());
out.println("wallet encrypted" + (hasNewPassword ? " with new password" : ""));
return;
}
default: {
err.printf("Error: unhandled method '%s'\n", method);
exit(EXIT_FAILURE);
}
throw new RuntimeException(format("unhandled method '%s'", method));
}
}
} catch (StatusRuntimeException ex) {
// This exception is thrown if the client-provided password credentials do not
// match the value set on the server. The actual error message is in a nested
// exception and we clean it up a bit to make it more presentable.
Throwable t = ex.getCause() == null ? ex : ex.getCause();
err.println("Error: " + t.getMessage().replace("UNAUTHENTICATED: ", ""));
exit(EXIT_FAILURE);
// Remove the leading gRPC status code (e.g. "UNKNOWN: ") from the message
String message = ex.getMessage().replaceFirst("^[A-Z_]+: ", "");
throw new RuntimeException(message, ex);
}
}
@ -166,24 +214,22 @@ public class CliMain {
try {
stream.println("Bisq RPC Client");
stream.println();
stream.println("Usage: bisq-cli [options] <method>");
stream.println("Usage: bisq-cli [options] <method> [params]");
stream.println();
parser.printHelpOn(stream);
stream.println();
stream.println("Method Description");
stream.println("------ -----------");
stream.println("getversion Get server version");
stream.println("getbalance Get server wallet balance");
stream.format("%-19s%-30s%s%n", "Method", "Params", "Description");
stream.format("%-19s%-30s%s%n", "------", "------", "------------");
stream.format("%-19s%-30s%s%n", "getversion", "", "Get server version");
stream.format("%-19s%-30s%s%n", "getbalance", "", "Get server wallet balance");
stream.format("%-19s%-30s%s%n", "lockwallet", "", "Remove wallet password from memory, locking the wallet");
stream.format("%-19s%-30s%s%n", "unlockwallet", "password timeout",
"Store wallet password in memory for timeout seconds");
stream.format("%-19s%-30s%s%n", "setwalletpassword", "password [newpassword]",
"Encrypt wallet with password, or set new password on encrypted wallet");
stream.println();
} catch (IOException ex) {
ex.printStackTrace(stream);
}
}
@SuppressWarnings("BigDecimalMethodWithoutRoundingCalled")
private static String formatBalance(long satoshis) {
var btcFormat = new DecimalFormat("###,##0.00000000");
var satoshiDivisor = new BigDecimal(100000000);
return btcFormat.format(BigDecimal.valueOf(satoshis).divide(satoshiDivisor));
}
}

View File

@ -17,7 +17,6 @@
package bisq.core.grpc;
import bisq.core.btc.Balances;
import bisq.core.monetary.Price;
import bisq.core.offer.CreateOfferService;
import bisq.core.offer.Offer;
@ -25,7 +24,6 @@ import bisq.core.offer.OfferBookService;
import bisq.core.offer.OfferPayload;
import bisq.core.offer.OpenOfferManager;
import bisq.core.payment.PaymentAccount;
import bisq.core.presentation.BalancePresentation;
import bisq.core.trade.handlers.TransactionResultHandler;
import bisq.core.trade.statistics.TradeStatistics2;
import bisq.core.trade.statistics.TradeStatisticsManager;
@ -49,8 +47,6 @@ import lombok.extern.slf4j.Slf4j;
*/
@Slf4j
public class CoreApi {
private final Balances balances;
private final BalancePresentation balancePresentation;
private final OfferBookService offerBookService;
private final TradeStatisticsManager tradeStatisticsManager;
private final CreateOfferService createOfferService;
@ -58,15 +54,11 @@ public class CoreApi {
private final User user;
@Inject
public CoreApi(Balances balances,
BalancePresentation balancePresentation,
OfferBookService offerBookService,
public CoreApi(OfferBookService offerBookService,
TradeStatisticsManager tradeStatisticsManager,
CreateOfferService createOfferService,
OpenOfferManager openOfferManager,
User user) {
this.balances = balances;
this.balancePresentation = balancePresentation;
this.offerBookService = offerBookService;
this.tradeStatisticsManager = tradeStatisticsManager;
this.createOfferService = createOfferService;
@ -78,14 +70,6 @@ public class CoreApi {
return Version.VERSION;
}
public long getAvailableBalance() {
return balances.getAvailableBalance().get().getValue();
}
public String getAvailableBalanceAsString() {
return balancePresentation.getAvailableBalance().get();
}
public List<TradeStatistics2> getTradeStatistics() {
return new ArrayList<>(tradeStatisticsManager.getObservableTradeStatisticsSet());
}
@ -160,4 +144,5 @@ public class CoreApi {
resultHandler,
log::error);
}
}

View File

@ -0,0 +1,159 @@
package bisq.core.grpc;
import bisq.core.btc.Balances;
import bisq.core.btc.wallet.WalletsManager;
import org.bitcoinj.crypto.KeyCrypterScrypt;
import javax.inject.Inject;
import org.spongycastle.crypto.params.KeyParameter;
import java.util.Timer;
import java.util.TimerTask;
import lombok.extern.slf4j.Slf4j;
import javax.annotation.Nullable;
import static java.util.concurrent.TimeUnit.SECONDS;
@Slf4j
class CoreWalletService {
private final Balances balances;
private final WalletsManager walletsManager;
@Nullable
private TimerTask lockTask;
@Nullable
private KeyParameter tempAesKey;
@Inject
public CoreWalletService(Balances balances, WalletsManager walletsManager) {
this.balances = balances;
this.walletsManager = walletsManager;
}
public long getAvailableBalance() {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
if (walletsManager.areWalletsEncrypted() && tempAesKey == null)
throw new IllegalStateException("wallet is locked");
var balance = balances.getAvailableBalance().get();
if (balance == null)
throw new IllegalStateException("balance is not yet available");
return balance.getValue();
}
public void setWalletPassword(String password, String newPassword) {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
if (newPassword != null && !newPassword.isEmpty()) {
// TODO Validate new password before replacing old password.
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(aesKey))
throw new IllegalStateException("incorrect old password");
walletsManager.decryptWallets(aesKey);
aesKey = keyCrypterScrypt.deriveKey(newPassword);
walletsManager.encryptWallets(keyCrypterScrypt, aesKey);
walletsManager.backupWallets();
return;
}
if (walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is encrypted with a password");
// TODO Validate new password.
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
walletsManager.encryptWallets(keyCrypterScrypt, aesKey);
walletsManager.backupWallets();
}
public void lockWallet() {
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
if (tempAesKey == null)
throw new IllegalStateException("wallet is already locked");
tempAesKey = null;
}
public void unlockWallet(String password, long timeout) {
verifyWalletIsAvailableAndEncrypted();
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
// The aesKey is also cached for timeout (secs) after being used to decrypt the
// wallet, in case the user wants to manually lock the wallet before the timeout.
tempAesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(tempAesKey))
throw new IllegalStateException("incorrect password");
if (lockTask != null) {
// The user is overriding a prior unlock timeout. Cancel the existing
// lock TimerTask to prevent it from calling lockWallet() before or after the
// new timer task does.
lockTask.cancel();
// Avoid the synchronized(lock) overhead of an unnecessary lockTask.cancel()
// call the next time 'unlockwallet' is called.
lockTask = null;
}
lockTask = new TimerTask() {
@Override
public void run() {
if (tempAesKey != null) {
// Do not try to lock wallet after timeout if the user has already
// done so via 'lockwallet'
log.info("Locking wallet after {} second timeout expired.", timeout);
tempAesKey = null;
}
}
};
Timer timer = new Timer("Lock Wallet Timer");
timer.schedule(lockTask, SECONDS.toMillis(timeout));
}
// Provided for automated wallet protection method testing, despite the
// security risks exposed by providing users the ability to decrypt their wallets.
public void removeWalletPassword(String password) {
verifyWalletIsAvailableAndEncrypted();
KeyCrypterScrypt keyCrypterScrypt = getKeyCrypterScrypt();
KeyParameter aesKey = keyCrypterScrypt.deriveKey(password);
if (!walletsManager.checkAESKey(aesKey))
throw new IllegalStateException("incorrect password");
walletsManager.decryptWallets(aesKey);
walletsManager.backupWallets();
}
// Throws a RuntimeException if wallets are not available or not encrypted.
private void verifyWalletIsAvailableAndEncrypted() {
if (!walletsManager.areWalletsAvailable())
throw new IllegalStateException("wallet is not yet available");
if (!walletsManager.areWalletsEncrypted())
throw new IllegalStateException("wallet is not encrypted with a password");
}
private KeyCrypterScrypt getKeyCrypterScrypt() {
KeyCrypterScrypt keyCrypterScrypt = walletsManager.getKeyCrypterScrypt();
if (keyCrypterScrypt == null)
throw new IllegalStateException("wallet encrypter is not available");
return keyCrypterScrypt;
}
}

View File

@ -24,9 +24,6 @@ import bisq.core.trade.statistics.TradeStatistics2;
import bisq.common.config.Config;
import bisq.proto.grpc.GetBalanceGrpc;
import bisq.proto.grpc.GetBalanceReply;
import bisq.proto.grpc.GetBalanceRequest;
import bisq.proto.grpc.GetOffersGrpc;
import bisq.proto.grpc.GetOffersReply;
import bisq.proto.grpc.GetOffersRequest;
@ -43,10 +40,14 @@ import bisq.proto.grpc.PlaceOfferGrpc;
import bisq.proto.grpc.PlaceOfferReply;
import bisq.proto.grpc.PlaceOfferRequest;
import io.grpc.Server;
import io.grpc.ServerBuilder;
import io.grpc.stub.StreamObserver;
import javax.inject.Inject;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.stream.Collectors;
@ -56,32 +57,32 @@ import lombok.extern.slf4j.Slf4j;
public class GrpcServer {
private final CoreApi coreApi;
private final int port;
private final Server server;
public GrpcServer(Config config, CoreApi coreApi) {
@Inject
public GrpcServer(Config config, CoreApi coreApi, GrpcWalletService walletService) {
this.coreApi = coreApi;
this.port = config.apiPort;
this.server = ServerBuilder.forPort(config.apiPort)
.addService(new GetVersionService())
.addService(new GetTradeStatisticsService())
.addService(new GetOffersService())
.addService(new GetPaymentAccountsService())
.addService(new PlaceOfferService())
.addService(walletService)
.intercept(new PasswordAuthInterceptor(config.apiPassword))
.build();
}
public void start() {
try {
var server = ServerBuilder.forPort(port)
.addService(new GetVersionService())
.addService(new GetBalanceService())
.addService(new GetTradeStatisticsService())
.addService(new GetOffersService())
.addService(new GetPaymentAccountsService())
.addService(new PlaceOfferService())
.intercept(new PasswordAuthInterceptor(config.apiPassword))
.build()
.start();
log.info("listening on port {}", port);
server.start();
log.info("listening on port {}", server.getPort());
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
server.shutdown();
log.info("shutdown complete");
}));
} catch (IOException e) {
log.error(e.toString(), e);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
@ -94,14 +95,6 @@ public class GrpcServer {
}
}
class GetBalanceService extends GetBalanceGrpc.GetBalanceImplBase {
@Override
public void getBalance(GetBalanceRequest req, StreamObserver<GetBalanceReply> responseObserver) {
var reply = GetBalanceReply.newBuilder().setBalance(coreApi.getAvailableBalance()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
class GetTradeStatisticsService extends GetTradeStatisticsGrpc.GetTradeStatisticsImplBase {
@Override

View File

@ -0,0 +1,103 @@
package bisq.core.grpc;
import bisq.proto.grpc.GetBalanceReply;
import bisq.proto.grpc.GetBalanceRequest;
import bisq.proto.grpc.LockWalletReply;
import bisq.proto.grpc.LockWalletRequest;
import bisq.proto.grpc.RemoveWalletPasswordReply;
import bisq.proto.grpc.RemoveWalletPasswordRequest;
import bisq.proto.grpc.SetWalletPasswordReply;
import bisq.proto.grpc.SetWalletPasswordRequest;
import bisq.proto.grpc.UnlockWalletReply;
import bisq.proto.grpc.UnlockWalletRequest;
import bisq.proto.grpc.WalletGrpc;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import javax.inject.Inject;
class GrpcWalletService extends WalletGrpc.WalletImplBase {
private final CoreWalletService walletService;
@Inject
public GrpcWalletService(CoreWalletService walletService) {
this.walletService = walletService;
}
@Override
public void getBalance(GetBalanceRequest req, StreamObserver<GetBalanceReply> responseObserver) {
try {
long result = walletService.getAvailableBalance();
var reply = GetBalanceReply.newBuilder().setBalance(result).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
}
}
@Override
public void setWalletPassword(SetWalletPasswordRequest req,
StreamObserver<SetWalletPasswordReply> responseObserver) {
try {
walletService.setWalletPassword(req.getPassword(), req.getNewPassword());
var reply = SetWalletPasswordReply.newBuilder().build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
}
}
@Override
public void removeWalletPassword(RemoveWalletPasswordRequest req,
StreamObserver<RemoveWalletPasswordReply> responseObserver) {
try {
walletService.removeWalletPassword(req.getPassword());
var reply = RemoveWalletPasswordReply.newBuilder().build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
}
}
@Override
public void lockWallet(LockWalletRequest req,
StreamObserver<LockWalletReply> responseObserver) {
try {
walletService.lockWallet();
var reply = LockWalletReply.newBuilder().build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
}
}
@Override
public void unlockWallet(UnlockWalletRequest req,
StreamObserver<UnlockWalletReply> responseObserver) {
try {
walletService.unlockWallet(req.getPassword(), req.getTimeout());
var reply = UnlockWalletReply.newBuilder().build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
} catch (IllegalStateException cause) {
var ex = new StatusRuntimeException(Status.UNKNOWN.withDescription(cause.getMessage()));
responseObserver.onError(ex);
throw ex;
}
}
}

View File

@ -21,7 +21,6 @@ import bisq.core.app.BisqHeadlessAppMain;
import bisq.core.app.BisqSetup;
import bisq.core.app.CoreModule;
import bisq.core.grpc.GrpcServer;
import bisq.core.grpc.CoreApi;
import bisq.common.UserThread;
import bisq.common.app.AppModule;
@ -98,7 +97,7 @@ public class BisqDaemonMain extends BisqHeadlessAppMain implements BisqSetup.Bis
protected void onApplicationStarted() {
super.onApplicationStarted();
CoreApi coreApi = injector.getInstance(CoreApi.class);
new GrpcServer(config, coreApi);
GrpcServer grpcServer = injector.getInstance(GrpcServer.class);
grpcServer.start();
}
}

View File

@ -39,22 +39,6 @@ message GetVersionReply {
string version = 1;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Balance
///////////////////////////////////////////////////////////////////////////////////////////
service GetBalance {
rpc GetBalance (GetBalanceRequest) returns (GetBalanceReply) {
}
}
message GetBalanceRequest {
}
message GetBalanceReply {
uint64 balance = 1;
}
///////////////////////////////////////////////////////////////////////////////////////////
// TradeStatistics
///////////////////////////////////////////////////////////////////////////////////////////
@ -127,3 +111,56 @@ message PlaceOfferRequest {
message PlaceOfferReply {
bool result = 1;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Wallet
///////////////////////////////////////////////////////////////////////////////////////////
service Wallet {
rpc GetBalance (GetBalanceRequest) returns (GetBalanceReply) {
}
rpc SetWalletPassword (SetWalletPasswordRequest) returns (SetWalletPasswordReply) {
}
rpc RemoveWalletPassword (RemoveWalletPasswordRequest) returns (RemoveWalletPasswordReply) {
}
rpc LockWallet (LockWalletRequest) returns (LockWalletReply) {
}
rpc UnlockWallet (UnlockWalletRequest) returns (UnlockWalletReply) {
}
}
message GetBalanceRequest {
}
message GetBalanceReply {
uint64 balance = 1;
}
message SetWalletPasswordRequest {
string password = 1;
string newPassword = 2;
}
message SetWalletPasswordReply {
}
message RemoveWalletPasswordRequest {
string password = 1;
}
message RemoveWalletPasswordReply {
}
message LockWalletRequest {
}
message LockWalletReply {
}
message UnlockWalletRequest {
string password = 1;
uint64 timeout = 2;
}
message UnlockWalletReply {
}