Add ability to change aes password (#2254)

* Add ability to change aes password

* Add docs

* Rename, add logs + tests
This commit is contained in:
Ben Carman 2020-11-15 08:07:49 -06:00 committed by GitHub
parent 3effd6601f
commit c29b787ab5
12 changed files with 356 additions and 8 deletions

View File

@ -57,6 +57,9 @@ object Picklers {
implicit val instantPickler: ReadWriter[Instant] =
readwriter[Long].bimap(_.getEpochSecond, Instant.ofEpochSecond)
implicit val aesPasswordPickler: ReadWriter[AesPassword] =
readwriter[String].bimap(_.toStringSensitive, AesPassword.fromString)
implicit val sha256DigestBEPickler: ReadWriter[Sha256DigestBE] =
readwriter[String].bimap(_.hex, Sha256DigestBE.fromHex)

View File

@ -17,6 +17,7 @@ import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
import org.bitcoins.core.wallet.utxo.AddressLabelTag
import org.bitcoins.crypto.{
AesPassword,
SchnorrDigitalSignature,
SchnorrNonce,
Sha256DigestBE
@ -99,6 +100,12 @@ object CliReaders {
str => Instant.ofEpochSecond(str.toLong)
}
implicit val aesPasswordReads: Read[AesPassword] = new Read[AesPassword] {
override def arity: Int = 1
override def reads: String => AesPassword = AesPassword.fromString
}
implicit val bitcoinAddressReads: Read[BitcoinAddress] =
new Read[BitcoinAddress] {
val arity: Int = 1

View File

@ -21,7 +21,11 @@ import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
import org.bitcoins.core.wallet.utxo.AddressLabelTag
import org.bitcoins.crypto.{SchnorrDigitalSignature, Sha256DigestBE}
import org.bitcoins.crypto.{
AesPassword,
SchnorrDigitalSignature,
Sha256DigestBE
}
import scopt.OParser
import ujson._
import upickle.{default => up}
@ -892,6 +896,44 @@ object ConsoleCli {
case other => other
}))
),
cmd("keymanagerpassphrasechange")
.action((_, conf) =>
conf.copy(command = KeyManagerPassphraseChange(null, null)))
.text("Changes the wallet passphrase")
.children(
arg[AesPassword]("oldpassphrase")
.text("The current passphrase")
.required()
.action((oldPass, conf) =>
conf.copy(command = conf.command match {
case wpc: KeyManagerPassphraseChange =>
wpc.copy(oldPassword = oldPass)
case other => other
})),
arg[AesPassword]("newpassphrase")
.text("The new passphrase")
.required()
.action((newPass, conf) =>
conf.copy(command = conf.command match {
case wpc: KeyManagerPassphraseChange =>
wpc.copy(newPassword = newPass)
case other => other
}))
),
cmd("keymanagerpassphraseset")
.action((_, conf) => conf.copy(command = KeyManagerPassphraseSet(null)))
.text("Encrypts the wallet with the given passphrase")
.children(
arg[AesPassword]("passphrase")
.text("The passphrase to encrypt the wallet with")
.required()
.action((pass, conf) =>
conf.copy(command = conf.command match {
case wps: KeyManagerPassphraseSet =>
wps.copy(password = pass)
case other => other
}))
),
note(sys.props("line.separator") + "=== Network ==="),
cmd("getpeers")
.action((_, conf) => conf.copy(command = GetPeers))
@ -1474,6 +1516,14 @@ object ConsoleCli {
up.writeJs(satoshisPerVirtualByte)))
case SignPSBT(psbt) =>
RequestParam("signpsbt", Seq(up.writeJs(psbt)))
case KeyManagerPassphraseChange(oldPassword, newPassword) =>
RequestParam("keymanagerpassphrasechange",
Seq(up.writeJs(oldPassword), up.writeJs(newPassword)))
case KeyManagerPassphraseSet(password) =>
RequestParam("keymanagerpassphraseset", Seq(up.writeJs(password)))
// height
case GetBlockCount => RequestParam("getblockcount")
// filter count
@ -1623,10 +1673,12 @@ object ConsoleCli {
(getKey("result"), getKey("error")) match {
case (Some(result), None) =>
Success(jsValueToString(result))
case (None, None) =>
Success("")
case (None, Some(err)) =>
val msg = jsValueToString(err)
error(msg)
case (None, None) | (Some(_), Some(_)) =>
case (Some(_), Some(_)) =>
error(s"Got unexpected response: $rawBody")
}
}.flatten
@ -1806,6 +1858,12 @@ object CliCommand {
case class GetUnconfirmedBalance(isSats: Boolean) extends CliCommand
case class GetAddressInfo(address: BitcoinAddress) extends CliCommand
case class KeyManagerPassphraseChange(
oldPassword: AesPassword,
newPassword: AesPassword)
extends CliCommand
case class KeyManagerPassphraseSet(password: AesPassword) extends CliCommand
// Node
case object GetPeers extends CliCommand
case object Stop extends CliCommand

View File

@ -6,12 +6,16 @@ import akka.http.scaladsl.server._
import org.bitcoins.core.number._
import org.bitcoins.core.protocol.tlv._
import org.bitcoins.dlc.oracle._
import org.bitcoins.dlc.oracle.config.DLCOracleAppConfig
import org.bitcoins.keymanager.WalletStorage
import org.bitcoins.server._
import ujson._
import scala.util.{Failure, Success}
case class OracleRoutes(oracle: DLCOracle)(implicit system: ActorSystem)
case class OracleRoutes(oracle: DLCOracle)(implicit
system: ActorSystem,
conf: DLCOracleAppConfig)
extends ServerRoute {
import system.dispatcher
@ -241,5 +245,33 @@ case class OracleRoutes(oracle: DLCOracle)(implicit system: ActorSystem)
}
}
}
case ServerCommand("keymanagerpassphrasechange", arr) =>
KeyManagerPassphraseChange.fromJsArr(arr) match {
case Failure(err) =>
reject(ValidationRejection("failure", Some(err)))
case Success(KeyManagerPassphraseChange(oldPassword, newPassword)) =>
complete {
val path = conf.seedPath
WalletStorage.changeAesPassword(path,
Some(oldPassword),
Some(newPassword))
Server.httpSuccess(ujson.Null)
}
}
case ServerCommand("keymanagerpassphraseset", arr) =>
KeyManagerPassphraseSet.fromJsArr(arr) match {
case Failure(err) =>
reject(ValidationRejection("failure", Some(err)))
case Success(KeyManagerPassphraseSet(password)) =>
complete {
val path = conf.seedPath
WalletStorage.changeAesPassword(path, None, Some(password))
Server.httpSuccess(ujson.Null)
}
}
}
}

View File

@ -33,6 +33,8 @@ import org.bitcoins.crypto.{
Sha256Hash160Digest
}
import org.bitcoins.node.Node
import org.bitcoins.server.BitcoinSAppConfig.implicitToWalletConf
import org.bitcoins.testkit.BitcoinSTestAppConfig
import org.bitcoins.wallet.MockWalletApi
import org.scalamock.scalatest.MockFactory
import org.scalatest.wordspec.AnyWordSpec
@ -45,6 +47,9 @@ import scala.concurrent.{ExecutionContext, Future}
class RoutesSpec extends AnyWordSpec with ScalatestRouteTest with MockFactory {
implicit val conf: BitcoinSAppConfig =
BitcoinSTestAppConfig.getSpvTestConfig()
// the genesis address
val testAddressStr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val testAddress = BitcoinAddress.fromString(testAddressStr)

View File

@ -270,6 +270,8 @@ class BitcoinSServerMain(override val args: Array[String])
system: ActorSystem,
conf: BitcoinSAppConfig): Future[Http.ServerBinding] = {
implicit val nodeConf: NodeAppConfig = conf.nodeConf
implicit val walletConf: WalletAppConfig = conf.walletConf
val walletRoutes = WalletRoutes(wallet)
val nodeRoutes = NodeRoutes(nodeApi)
val chainRoutes = ChainRoutes(chainApi)

View File

@ -12,6 +12,7 @@ import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
import org.bitcoins.core.wallet.utxo.AddressLabelTag
import org.bitcoins.crypto.AesPassword
import ujson._
import upickle.default._
@ -209,6 +210,55 @@ object SendRawTransaction extends ServerJsonModels {
}
}
case class KeyManagerPassphraseChange(
oldPassword: AesPassword,
newPassword: AesPassword)
object KeyManagerPassphraseChange extends ServerJsonModels {
def fromJsArr(jsArr: ujson.Arr): Try[KeyManagerPassphraseChange] = {
jsArr.arr.toList match {
case oldPassJs :: newPassJs :: Nil =>
Try {
val oldPass = AesPassword.fromString(oldPassJs.str)
val newPass = AesPassword.fromString(newPassJs.str)
KeyManagerPassphraseChange(oldPass, newPass)
}
case Nil =>
Failure(
new IllegalArgumentException(
"Missing old password and new password arguments"))
case other =>
Failure(
new IllegalArgumentException(
s"Bad number of arguments: ${other.length}. Expected: 2"))
}
}
}
case class KeyManagerPassphraseSet(password: AesPassword)
object KeyManagerPassphraseSet extends ServerJsonModels {
def fromJsArr(jsArr: ujson.Arr): Try[KeyManagerPassphraseSet] = {
jsArr.arr.toList match {
case passJs :: Nil =>
Try {
val pass = AesPassword.fromString(passJs.str)
KeyManagerPassphraseSet(pass)
}
case Nil =>
Failure(new IllegalArgumentException("Missing password argument"))
case other =>
Failure(
new IllegalArgumentException(
s"Bad number of arguments: ${other.length}. Expected: 1"))
}
}
}
case class CombinePSBTs(psbts: Seq[PSBT])
object CombinePSBTs extends ServerJsonModels {

View File

@ -10,12 +10,16 @@ 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.wallet.config.WalletAppConfig
import ujson._
import scala.concurrent.Future
import scala.util.{Failure, Success}
case class WalletRoutes(wallet: AnyHDWalletApi)(implicit system: ActorSystem)
case class WalletRoutes(wallet: AnyHDWalletApi)(implicit
system: ActorSystem,
walletConf: WalletAppConfig)
extends ServerRoute {
import system.dispatcher
@ -426,5 +430,32 @@ case class WalletRoutes(wallet: AnyHDWalletApi)(implicit system: ActorSystem)
}
}
case ServerCommand("keymanagerpassphrasechange", arr) =>
KeyManagerPassphraseChange.fromJsArr(arr) match {
case Failure(err) =>
reject(ValidationRejection("failure", Some(err)))
case Success(KeyManagerPassphraseChange(oldPassword, newPassword)) =>
complete {
val path = walletConf.seedPath
WalletStorage.changeAesPassword(path,
Some(oldPassword),
Some(newPassword))
Server.httpSuccess(ujson.Null)
}
}
case ServerCommand("keymanagerpassphraseset", arr) =>
KeyManagerPassphraseSet.fromJsArr(arr) match {
case Failure(err) =>
reject(ValidationRejection("failure", Some(err)))
case Success(KeyManagerPassphraseSet(password)) =>
complete {
val path = walletConf.seedPath
WalletStorage.changeAesPassword(path, None, Some(password))
Server.httpSuccess(ujson.Null)
}
}
}
}

View File

@ -126,9 +126,7 @@ final case class AesPassword private (private val value: String)
key
}
override def toStringSensitive: String = {
ByteVector.encodeUtf8(value).toString
}
override def toStringSensitive: String = value
}
object AesPassword extends StringFactory[AesPassword] {

View File

@ -187,7 +187,11 @@ 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
- `oldpassphrase` - The current passphrase
- `newpassphrase` - The new passphrase
- `walletpassphraseset` `passphrase` - Encrypts the wallet with the given passphrase
- `passphrase` - The passphrase to encrypt the wallet with
#### Network
- `getpeers` - List the connected peers

View File

@ -89,6 +89,131 @@ class WalletStorageTest extends BitcoinSWalletTest with BeforeAndAfterEach {
}
}
it must "change the password of an encrypted mnemonic" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
val writtenMnemonic = getAndWriteMnemonic(walletConf)
assert(walletConf.seedExists())
val seedPath = getSeedPath(walletConf)
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = passphrase,
newPasswordOpt = badPassphrase)
val read =
WalletStorage.decryptMnemonicFromDisk(seedPath, badPassphrase)
read match {
case Right(readMnemonic) =>
assert(writtenMnemonic.mnemonicCode == readMnemonic.mnemonicCode)
// Need to compare using getEpochSecond because when reading an epoch second
// it will not include the milliseconds that writtenMnemonic will have
assert(
writtenMnemonic.creationTime.getEpochSecond == readMnemonic.creationTime.getEpochSecond)
case Left(err) => fail(err.toString)
}
}
it must "change the password of an unencrypted mnemonic" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
val mnemonicCode = CryptoGenerators.mnemonicCode.sampleSome
val writtenMnemonic = DecryptedMnemonic(mnemonicCode, TimeUtil.now)
val seedPath = getSeedPath(walletConf)
WalletStorage.writeMnemonicToDisk(seedPath, writtenMnemonic)
assert(walletConf.seedExists())
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = None,
newPasswordOpt = badPassphrase)
val read =
WalletStorage.decryptMnemonicFromDisk(seedPath, badPassphrase)
read match {
case Right(readMnemonic) =>
assert(writtenMnemonic.mnemonicCode == readMnemonic.mnemonicCode)
// Need to compare using getEpochSecond because when reading an epoch second
// it will not include the milliseconds that writtenMnemonic will have
assert(
writtenMnemonic.creationTime.getEpochSecond == readMnemonic.creationTime.getEpochSecond)
case Left(err) => fail(err.toString)
}
}
it must "remove the password from an encrypted mnemonic" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
val writtenMnemonic = getAndWriteMnemonic(walletConf)
assert(walletConf.seedExists())
val seedPath = getSeedPath(walletConf)
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = passphrase,
newPasswordOpt = None)
val read =
WalletStorage.decryptMnemonicFromDisk(seedPath, None)
read match {
case Right(readMnemonic) =>
assert(writtenMnemonic.mnemonicCode == readMnemonic.mnemonicCode)
// Need to compare using getEpochSecond because when reading an epoch second
// it will not include the milliseconds that writtenMnemonic will have
assert(
writtenMnemonic.creationTime.getEpochSecond == readMnemonic.creationTime.getEpochSecond)
case Left(err) => fail(err.toString)
}
}
it must "fail to change the aes password when given the wrong password" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
getAndWriteMnemonic(walletConf)
assert(walletConf.seedExists())
val seedPath = getSeedPath(walletConf)
assertThrows[RuntimeException](
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = badPassphrase,
newPasswordOpt = badPassphrase))
}
it must "fail to change the aes password when given no password" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
getAndWriteMnemonic(walletConf)
assert(walletConf.seedExists())
val seedPath = getSeedPath(walletConf)
assertThrows[RuntimeException](
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = None,
newPasswordOpt = badPassphrase))
}
it must "fail to set the aes password when given an oldPassword" in {
walletConf: WalletAppConfig =>
assert(!walletConf.seedExists())
val mnemonicCode = CryptoGenerators.mnemonicCode.sampleSome
val writtenMnemonic = DecryptedMnemonic(mnemonicCode, TimeUtil.now)
val seedPath = getSeedPath(walletConf)
WalletStorage.writeMnemonicToDisk(seedPath, writtenMnemonic)
assert(walletConf.seedExists())
assertThrows[RuntimeException](
WalletStorage.changeAesPassword(seedPath = seedPath,
oldPasswordOpt = passphrase,
newPasswordOpt = badPassphrase))
}
it must "read an encrypted mnemonic without a creation time" in {
walletConf =>
val badJson =

View File

@ -304,6 +304,39 @@ object WalletStorage {
}
}
def changeAesPassword(
seedPath: Path,
oldPasswordOpt: Option[AesPassword],
newPasswordOpt: Option[AesPassword]): MnemonicState = {
logger.info("Changing encryption password for seed")
decryptMnemonicFromDisk(seedPath, oldPasswordOpt) match {
case Left(err) => sys.error(err.toString)
case Right(decrypted) =>
val fileName = seedPath.getFileName.toString
logger.info("Creating backup file...")
val backup = seedPath.getParent.resolve(fileName + ".backup")
Files.move(seedPath, backup)
val toWrite = newPasswordOpt match {
case Some(pass) =>
decrypted.encrypt(pass)
case None =>
decrypted
}
Try(writeMnemonicToDisk(seedPath, toWrite)) match {
case Failure(exception) =>
logger.error(
s"Failed to write new seed, backup of previous seed file can be found at $backup")
throw exception
case Success(_) =>
logger.info("Successfully wrote to disk, deleting backup file")
Files.delete(backup)
toWrite
}
}
}
def getPrivateKeyFromDisk(
seedPath: Path,
privKeyVersion: ExtKeyPrivVersion,