From 559bebbdbe9d43d838462d376b10f4a820943152 Mon Sep 17 00:00:00 2001 From: Ben Carman Date: Mon, 21 Dec 2020 06:53:20 -0600 Subject: [PATCH] Import Seed cli commands (#2376) * Import Seed cli commands * Respond to review --- .../commons/serializers/Picklers.scala | 15 +++- .../scala/org/bitcoins/cli/CliReaders.scala | 19 +++- .../scala/org/bitcoins/cli/ConsoleCli.scala | 87 +++++++++++++++++++ .../bitcoins/server/ServerJsonModels.scala | 86 ++++++++++++++++++ .../org/bitcoins/server/WalletRoutes.scala | 54 +++++++++++- docs/applications/server.md | 12 ++- .../config/KeyManagerAppConfig.scala | 11 +-- 7 files changed, 274 insertions(+), 10 deletions(-) diff --git a/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala b/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala index d511a5a7ae..73587dba83 100644 --- a/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala +++ b/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala @@ -2,7 +2,12 @@ package org.bitcoins.commons.serializers import org.bitcoins.commons.jsonmodels.bitcoind.RpcOpts.LockUnspentOutputParameter import org.bitcoins.core.api.wallet.CoinSelectionAlgo -import org.bitcoins.core.crypto.ExtPublicKey +import org.bitcoins.core.crypto.{ + ExtKey, + ExtPrivateKey, + ExtPublicKey, + MnemonicCode +} import org.bitcoins.core.currency.{Bitcoins, Satoshis} import org.bitcoins.core.number.UInt32 import org.bitcoins.core.protocol.dlc.DLCMessage._ @@ -507,4 +512,12 @@ object Picklers { ) } } + + implicit val mnemonicCodePickler: ReadWriter[MnemonicCode] = + readwriter[String].bimap( + _.words.mkString(" "), + str => MnemonicCode.fromWords(str.split(' ').toVector)) + + implicit val extPrivateKeyPickler: ReadWriter[ExtPrivateKey] = + readwriter[String].bimap(ExtKey.toString, ExtPrivateKey.fromString) } diff --git a/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala b/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala index 6ee1009365..24c9ee5001 100644 --- a/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala +++ b/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala @@ -3,11 +3,11 @@ package org.bitcoins.cli import java.io.File import java.nio.file.Path import java.time.{Instant, ZoneId, ZonedDateTime} - import org.bitcoins.commons.jsonmodels.bitcoind.RpcOpts.LockUnspentOutputParameter import org.bitcoins.core.protocol.dlc.DLCMessage._ import org.bitcoins.core.api.wallet.CoinSelectionAlgo import org.bitcoins.core.config.{NetworkParameters, Networks} +import org.bitcoins.core.crypto.{ExtPrivateKey, MnemonicCode} import org.bitcoins.core.currency._ import org.bitcoins.core.number.UInt32 import org.bitcoins.core.protocol.BlockStamp.BlockTime @@ -317,4 +317,21 @@ object CliReaders { override def reads: String => LnMessage[DLCSignTLV] = LnMessageFactory(DLCSignTLV).fromHex } + + implicit val extPrivKeyReads: Read[ExtPrivateKey] = new Read[ExtPrivateKey] { + override def arity: Int = 1 + + override def reads: String => ExtPrivateKey = ExtPrivateKey.fromString + } + + implicit val mnemonicCodeReads: Read[MnemonicCode] = new Read[MnemonicCode] { + override def arity: Int = 1 + + override def reads: String => MnemonicCode = + str => { + val words = str.split(' ') + + MnemonicCode.fromWords(words.toVector) + } + } } diff --git a/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala b/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala index c68baa3e36..fc49fea9c5 100644 --- a/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala +++ b/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala @@ -10,6 +10,7 @@ import org.bitcoins.core.protocol.dlc.DLCMessage._ import org.bitcoins.commons.serializers.Picklers._ import org.bitcoins.core.api.wallet.CoinSelectionAlgo import org.bitcoins.core.config.NetworkParameters +import org.bitcoins.core.crypto.{ExtPrivateKey, MnemonicCode} import org.bitcoins.core.currency._ import org.bitcoins.core.number.UInt32 import org.bitcoins.core.protocol.tlv._ @@ -757,6 +758,68 @@ object ConsoleCli { case other => other })) ), + cmd("importseed") + .action((_, conf) => conf.copy(command = ImportSeed("", null, None))) + .text("Imports a mnemonic seed as a new seed file") + .children( + arg[String]("walletname") + .text("Name to associate with this seed") + .required() + .action((walletName, conf) => + conf.copy(command = conf.command match { + case is: ImportSeed => + is.copy(walletName = walletName) + case other => other + })), + arg[MnemonicCode]("words") + .text("Mnemonic seed words, space separated") + .required() + .action((mnemonic, conf) => + conf.copy(command = conf.command match { + case is: ImportSeed => + is.copy(mnemonic = mnemonic) + case other => other + })), + arg[AesPassword]("passphrase") + .text("Passphrase to encrypt the seed with") + .action((password, conf) => + conf.copy(command = conf.command match { + case is: ImportSeed => + is.copy(passwordOpt = Some(password)) + case other => other + })) + ), + cmd("importxprv") + .action((_, conf) => conf.copy(command = ImportXprv("", null, None))) + .text("Imports a xprv as a new seed file") + .children( + arg[String]("walletname") + .text("What name to associate with this seed") + .required() + .action((walletName, conf) => + conf.copy(command = conf.command match { + case ix: ImportXprv => + ix.copy(walletName = walletName) + case other => other + })), + arg[ExtPrivateKey]("xprv") + .text("base58 encoded extended private key") + .required() + .action((xprv, conf) => + conf.copy(command = conf.command match { + case ix: ImportXprv => + ix.copy(xprv = xprv) + case other => other + })), + arg[AesPassword]("passphrase") + .text("Passphrase to encrypt this seed with") + .action((password, conf) => + conf.copy(command = conf.command match { + case ix: ImportXprv => + ix.copy(passwordOpt = Some(password)) + case other => other + })) + ), cmd("keymanagerpassphrasechange") .action((_, conf) => conf.copy(command = KeyManagerPassphraseChange(null, null))) @@ -1370,6 +1433,18 @@ object ConsoleCli { case KeyManagerPassphraseSet(password) => RequestParam("keymanagerpassphraseset", Seq(up.writeJs(password))) + case ImportSeed(walletName, mnemonic, passwordOpt) => + RequestParam("importseed", + Seq(up.writeJs(walletName), + up.writeJs(mnemonic), + up.writeJs(passwordOpt))) + + case ImportXprv(walletName, xprv, passwordOpt) => + RequestParam("importxprv", + Seq(up.writeJs(walletName), + up.writeJs(xprv), + up.writeJs(passwordOpt))) + // height case GetBlockCount => RequestParam("getblockcount") // filter count @@ -1678,6 +1753,18 @@ object CliCommand { extends CliCommand case class KeyManagerPassphraseSet(password: AesPassword) extends CliCommand + case class ImportSeed( + walletName: String, + mnemonic: MnemonicCode, + passwordOpt: Option[AesPassword]) + extends CliCommand + + case class ImportXprv( + walletName: String, + xprv: ExtPrivateKey, + passwordOpt: Option[AesPassword]) + extends CliCommand + // Node case object GetPeers extends CliCommand case object Stop extends CliCommand diff --git a/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala b/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala index 3041564f6d..93c85d616c 100644 --- a/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala +++ b/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala @@ -3,6 +3,7 @@ package org.bitcoins.server import java.time.Instant import org.bitcoins.commons.jsonmodels.bitcoind.RpcOpts.LockUnspentOutputParameter import org.bitcoins.core.api.wallet.CoinSelectionAlgo +import org.bitcoins.core.crypto._ import org.bitcoins.core.currency.{Bitcoins, Satoshis} import org.bitcoins.core.protocol.BlockStamp.BlockHeight import org.bitcoins.core.protocol.tlv._ @@ -258,6 +259,91 @@ object KeyManagerPassphraseSet extends ServerJsonModels { } } +case class ImportSeed( + walletName: String, + mnemonic: MnemonicCode, + passwordOpt: Option[AesPassword]) + +object ImportSeed extends ServerJsonModels { + + def fromJsArr(jsArr: ujson.Arr): Try[ImportSeed] = { + jsArr.arr.toList match { + case walletNameJs :: mnemonicJs :: passJs :: Nil => + Try { + val walletName = walletNameJs.str + + val mnemonicWords = mnemonicJs match { + case Str(str) => str.split(' ').toVector + case Arr(arr) => arr.map(_.str).toVector + case Null | False | True | Num(_) | Obj(_) => + throw new IllegalArgumentException( + "mnemonic must be a string or array of strings") + } + val mnemonic = MnemonicCode.fromWords(mnemonicWords) + + val pass = passJs match { + case Str(str) => + Some(AesPassword.fromString(str)) + case Null => + None + case Arr(_) | False | True | Num(_) | Obj(_) => + throw new IllegalArgumentException( + "password must be a string or null") + } + + ImportSeed(walletName, mnemonic, pass) + } + case Nil => + Failure( + new IllegalArgumentException( + "Missing walletName, mnemonic, and password argument")) + case other => + Failure( + new IllegalArgumentException( + s"Bad number of arguments: ${other.length}. Expected: 3")) + } + } +} + +case class ImportXprv( + walletName: String, + xprv: ExtPrivateKey, + passwordOpt: Option[AesPassword]) + +object ImportXprv extends ServerJsonModels { + + def fromJsArr(jsArr: ujson.Arr): Try[ImportXprv] = { + jsArr.arr.toList match { + case walletNameJs :: xprvJs :: passJs :: Nil => + Try { + val walletName = walletNameJs.str + + val xprv = ExtPrivateKey.fromString(xprvJs.str) + + val pass = passJs match { + case Str(str) => + Some(AesPassword.fromString(str)) + case Null => + None + case Arr(_) | False | True | Bool(_) | Num(_) | Obj(_) => + throw new IllegalArgumentException( + "password must be a string or null") + } + + ImportXprv(walletName, xprv, pass) + } + case Nil => + Failure( + new IllegalArgumentException( + "Missing walletName, xprv, and password argument")) + case other => + Failure( + new IllegalArgumentException( + s"Bad number of arguments: ${other.length}. Expected: 3")) + } + } +} + case class CombinePSBTs(psbts: Seq[PSBT]) object CombinePSBTs extends ServerJsonModels { diff --git a/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala b/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala index 70f8a1e212..eedc234571 100644 --- a/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala +++ b/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala @@ -10,10 +10,12 @@ import org.bitcoins.core.currency._ import org.bitcoins.core.protocol.transaction.Transaction import org.bitcoins.core.wallet.utxo.{AddressLabelTagType, TxoState} import org.bitcoins.crypto.NetworkElement -import org.bitcoins.keymanager.WalletStorage +import org.bitcoins.keymanager._ +import org.bitcoins.keymanager.config.KeyManagerAppConfig import org.bitcoins.wallet.config.WalletAppConfig import ujson._ +import java.time.Instant import scala.concurrent.Future import scala.util.{Failure, Success} @@ -23,6 +25,8 @@ case class WalletRoutes(wallet: AnyHDWalletApi)(implicit extends ServerRoute { import system.dispatcher + implicit val kmConf: KeyManagerAppConfig = walletConf.kmConf + private def spendingInfoDbToJson(spendingInfoDb: SpendingInfoDb): Value = { Obj( "outpoint" -> Str(spendingInfoDb.outPoint.hex), @@ -469,6 +473,54 @@ case class WalletRoutes(wallet: AnyHDWalletApi)(implicit val path = walletConf.seedPath WalletStorage.changeAesPassword(path, None, Some(password)) + Server.httpSuccess(ujson.Null) + } + } + + case ServerCommand("importseed", arr) => + ImportSeed.fromJsArr(arr) match { + case Failure(err) => + reject(ValidationRejection("failure", Some(err))) + case Success(ImportSeed(walletName, mnemonic, passwordOpt)) => + complete { + val seedPath = kmConf.seedFolder.resolve( + s"$walletName-${WalletStorage.ENCRYPTED_SEED_FILE_NAME}") + + val creationTime = Instant.ofEpochSecond(WalletStorage.GENESIS_TIME) + + val mnemonicState = passwordOpt match { + case Some(pass) => + DecryptedMnemonic(mnemonic, creationTime).encrypt(pass) + case None => + DecryptedMnemonic(mnemonic, creationTime) + } + + WalletStorage.writeSeedToDisk(seedPath, mnemonicState) + + Server.httpSuccess(ujson.Null) + } + } + + case ServerCommand("importxprv", arr) => + ImportXprv.fromJsArr(arr) match { + case Failure(err) => + reject(ValidationRejection("failure", Some(err))) + case Success(ImportXprv(walletName, xprv, passwordOpt)) => + complete { + val seedPath = kmConf.seedFolder.resolve( + s"$walletName-${WalletStorage.ENCRYPTED_SEED_FILE_NAME}") + + val creationTime = Instant.ofEpochSecond(WalletStorage.GENESIS_TIME) + + val mnemonicState = passwordOpt match { + case Some(pass) => + DecryptedExtPrivKey(xprv, creationTime).encrypt(pass) + case None => + DecryptedExtPrivKey(xprv, creationTime) + } + + WalletStorage.writeSeedToDisk(seedPath, mnemonicState) + Server.httpSuccess(ujson.Null) } } diff --git a/docs/applications/server.md b/docs/applications/server.md index 67434546f7..37c3e0572b 100644 --- a/docs/applications/server.md +++ b/docs/applications/server.md @@ -189,10 +189,18 @@ For more information on how to use our built in `cli` to interact with the serve - `lockunspent` `unlock` `transactions` - Temporarily lock (unlock=false) or unlock (unlock=true) specified transaction outputs. - `unlock` - Whether to unlock (true) or lock (false) the specified transactions - `transactions` - The transaction outpoints to unlock/lock - - `walletpassphrasechange` `oldpassphrase` `newpassphrase` - Changes the wallet passphrase +- `importseed` `walletname` `words` `passphrase` - Imports a mnemonic seed as a new seed file + - `walletname` - Name to associate with this seed + - `words` - Mnemonic seed words, space separated + - `passphrase` - Passphrase to encrypt this seed with +- `importxprv` `walletname` `xprv` `passphrase` - Imports a mnemonic seed as a new seed file + - `walletname` - Name to associate with this seed + - `xprv` - base58 encoded extended private key + - `passphrase` - Passphrase to encrypt this seed with + - `keymanagerpassphrasechange` `oldpassphrase` `newpassphrase` - Changes the wallet passphrase - `oldpassphrase` - The current passphrase - `newpassphrase` - The new passphrase - - `walletpassphraseset` `passphrase` - Encrypts the wallet with the given passphrase + - `keymanagerpassphraseset` `passphrase` - Encrypts the wallet with the given passphrase - `passphrase` - The passphrase to encrypt the wallet with #### Network diff --git a/key-manager/src/main/scala/org/bitcoins/keymanager/config/KeyManagerAppConfig.scala b/key-manager/src/main/scala/org/bitcoins/keymanager/config/KeyManagerAppConfig.scala index 3f8220c557..8e8a86b77b 100644 --- a/key-manager/src/main/scala/org/bitcoins/keymanager/config/KeyManagerAppConfig.scala +++ b/key-manager/src/main/scala/org/bitcoins/keymanager/config/KeyManagerAppConfig.scala @@ -33,6 +33,9 @@ case class KeyManagerAppConfig( config.getStringOrNone(s"bitcoin-s.wallet.walletName") } + lazy val seedFolder: Path = baseDatadir + .resolve(WalletStorage.SEED_FOLDER_NAME) + /** The path to our encrypted mnemonic seed */ lazy val seedPath: Path = { val prefix = walletNameOpt match { @@ -40,17 +43,15 @@ case class KeyManagerAppConfig( s"$walletName-" case None => "" } - baseDatadir - .resolve(WalletStorage.SEED_FOLDER_NAME) - .resolve(s"$prefix${WalletStorage.ENCRYPTED_SEED_FILE_NAME}") + + seedFolder.resolve(s"$prefix${WalletStorage.ENCRYPTED_SEED_FILE_NAME}") } override def start(): Future[Unit] = { val oldDefaultFile = baseDatadir.resolve(WalletStorage.ENCRYPTED_SEED_FILE_NAME) - val newDefaultFile = baseDatadir - .resolve(WalletStorage.SEED_FOLDER_NAME) + val newDefaultFile = seedFolder .resolve(WalletStorage.ENCRYPTED_SEED_FILE_NAME) if (!Files.exists(newDefaultFile) && Files.exists(oldDefaultFile)) {