From 6ad3f8786e06b75b3841427d79a87918601496ea Mon Sep 17 00:00:00 2001 From: Josh Rickmar Date: Mon, 27 Jan 2014 09:30:42 -0500 Subject: [PATCH] Implement walletpassphrasechange RPC call. Closes #62. --- account.go | 2 +- accountstore.go | 69 ++++++++++++++++++++++++ cmd.go | 17 +++++- cmdmgr.go | 119 ++++++++++++++++++++++++++---------------- disksync.go | 85 +++++++++++++++++++++++++++++- tx/tx.go | 1 + updates.go | 16 ++++++ wallet/wallet.go | 65 +++++++++++++++++++++-- wallet/wallet_test.go | 75 ++++++++++++++++++++++++++ 9 files changed, 398 insertions(+), 51 deletions(-) diff --git a/account.go b/account.go index 1b51609..dec21b9 100644 --- a/account.go +++ b/account.go @@ -130,7 +130,7 @@ func (a *Account) Lock() error { } // Unlock unlocks the underlying wallet for an account. -func (a *Account) Unlock(passphrase []byte, timeout int64) error { +func (a *Account) Unlock(passphrase []byte) error { a.mtx.Lock() defer a.mtx.Unlock() diff --git a/accountstore.go b/accountstore.go index 0a7a9eb..d7b4a72 100644 --- a/accountstore.go +++ b/accountstore.go @@ -226,6 +226,75 @@ func (store *AccountStore) CreateEncryptedWallet(name, desc string, passphrase [ return nil } +// ChangePassphrase unlocks all account wallets with the old +// passphrase, and re-encrypts each using the new passphrase. +func (store *AccountStore) ChangePassphrase(old, new []byte) error { + store.RLock() + defer store.RUnlock() + + // Check that each account can be unlocked with the old passphrase. + // Each's account's wallet mutex is unlocked with a defer so they + // will be held for the duration of this function. This prevents + // a wallet from being locked after some timeout after a RPC call + // to walletpassphrase. + for _, a := range store.accounts { + a.mtx.Lock() + defer a.mtx.Unlock() + + if locked := a.Wallet.IsLocked(); !locked { + if err := a.Wallet.Lock(); err != nil { + return err + } + } + + if err := a.Wallet.Unlock(old); err != nil { + return err + } + defer a.Wallet.Lock() + } + + // Change passphrase for each unlocked wallet. + for _, a := range store.accounts { + if err := a.Wallet.ChangePassphrase(new); err != nil { + return err + } + a.dirty = true + } + + // Immediately write out to disk. Create a new temporary network + // directory to write to, write all account files there, then move + // to the real network directory. This provides an safe + // replacement of all account files and ensures that all wallets + // are using either the old or new passphrase, but never two wallets + // with different passphrases. + netDir := networkDir(cfg.Net()) + tmpNetDir := tmpNetworkDir(cfg.Net()) + for _, a := range store.accounts { + // Writer locks must be held for the tx and utxo stores as well, + // to unset the dirty flag. + a.UtxoStore.Lock() + defer a.UtxoStore.Unlock() + a.TxStore.Lock() + defer a.TxStore.Unlock() + + if err := a.writeAllToFreshDir(tmpNetDir); err != nil { + return err + } + } + + // This is technically NOT an atomic operation, but at startup, if the + // network directory is missing but the temporary network directory + // exists, the temporary is moved before accounts are opened. + if err := os.RemoveAll(netDir); err != nil { + return err + } + if err := Rename(tmpNetDir, netDir); err != nil { + return err + } + + return nil +} + // DumpKeys returns all WIF-encoded private keys associated with all // accounts. All wallets must be unlocked for this operation to succeed. func (store *AccountStore) DumpKeys() ([]string, error) { diff --git a/cmd.go b/cmd.go index e815e3f..3f38543 100644 --- a/cmd.go +++ b/cmd.go @@ -248,6 +248,21 @@ func main() { // OpenAccounts attempts to open all saved accounts. func OpenAccounts() { + // If the network (account) directory is missing, but the temporary + // directory exists, move it. This is unlikely to happen, but possible, + // if writing out every account file at once to a tmp directory (as is + // done for changing a wallet passphrase) and btcwallet closes after + // removing the network directory but before renaming the temporary + // directory. + netDir := networkDir(cfg.Net()) + tmpNetDir := tmpNetworkDir(cfg.Net()) + if !fileExists(netDir) && fileExists(tmpNetDir) { + if err := Rename(tmpNetDir, netDir); err != nil { + log.Errorf("Cannot move temporary network dir: %v", err) + return + } + } + // The default account must exist, or btcwallet acts as if no // wallets/accounts have been created yet. if err := accountstore.OpenAccount("", cfg); err != nil { @@ -264,7 +279,7 @@ func OpenAccounts() { // Read all filenames in the account directory, and look for any // filenames matching '*-wallet.bin'. These are wallets for // additional saved accounts. - accountDir, err := os.Open(networkDir(cfg.Net())) + accountDir, err := os.Open(netDir) if err != nil { // Can't continue. log.Errorf("Unable to open account directory: %v", err) diff --git a/cmdmgr.go b/cmdmgr.go index 38002c4..f124997 100644 --- a/cmdmgr.go +++ b/cmdmgr.go @@ -32,51 +32,51 @@ type cmdHandler func(btcjson.Cmd) (interface{}, *btcjson.Error) var rpcHandlers = map[string]cmdHandler{ // Standard bitcoind methods (implemented) - "dumpprivkey": DumpPrivKey, - "getaccount": GetAccount, - "getaccountaddress": GetAccountAddress, - "getaddressesbyaccount": GetAddressesByAccount, - "getbalance": GetBalance, - "getnewaddress": GetNewAddress, - "importprivkey": ImportPrivKey, - "keypoolrefill": KeypoolRefill, - "listaccounts": ListAccounts, - "listtransactions": ListTransactions, - "sendfrom": SendFrom, - "sendmany": SendMany, - "settxfee": SetTxFee, - "walletlock": WalletLock, - "walletpassphrase": WalletPassphrase, + "dumpprivkey": DumpPrivKey, + "getaccount": GetAccount, + "getaccountaddress": GetAccountAddress, + "getaddressesbyaccount": GetAddressesByAccount, + "getbalance": GetBalance, + "getnewaddress": GetNewAddress, + "importprivkey": ImportPrivKey, + "keypoolrefill": KeypoolRefill, + "listaccounts": ListAccounts, + "listtransactions": ListTransactions, + "sendfrom": SendFrom, + "sendmany": SendMany, + "settxfee": SetTxFee, + "walletlock": WalletLock, + "walletpassphrase": WalletPassphrase, + "walletpassphrasechange": WalletPassphraseChange, // Standard bitcoind methods (currently unimplemented) - "addmultisigaddress": Unimplemented, - "backupwallet": Unimplemented, - "createmultisig": Unimplemented, - "dumpwallet": Unimplemented, - "getblocktemplate": Unimplemented, - "getrawchangeaddress": Unimplemented, - "getreceivedbyaccount": Unimplemented, - "getreceivedbyaddress": Unimplemented, - "gettransaction": Unimplemented, - "gettxout": Unimplemented, - "gettxoutsetinfo": Unimplemented, - "getwork": Unimplemented, - "importwallet": Unimplemented, - "listaddressgroupings": Unimplemented, - "listlockunspent": Unimplemented, - "listreceivedbyaccount": Unimplemented, - "listsinceblock": Unimplemented, - "listreceivedbyaddress": Unimplemented, - "listunspent": Unimplemented, - "lockunspent": Unimplemented, - "move": Unimplemented, - "sendtoaddress": Unimplemented, - "setaccount": Unimplemented, - "signmessage": Unimplemented, - "signrawtransaction": Unimplemented, - "validateaddress": Unimplemented, - "verifymessage": Unimplemented, - "walletpassphrasechange": Unimplemented, + "addmultisigaddress": Unimplemented, + "backupwallet": Unimplemented, + "createmultisig": Unimplemented, + "dumpwallet": Unimplemented, + "getblocktemplate": Unimplemented, + "getrawchangeaddress": Unimplemented, + "getreceivedbyaccount": Unimplemented, + "getreceivedbyaddress": Unimplemented, + "gettransaction": Unimplemented, + "gettxout": Unimplemented, + "gettxoutsetinfo": Unimplemented, + "getwork": Unimplemented, + "importwallet": Unimplemented, + "listaddressgroupings": Unimplemented, + "listlockunspent": Unimplemented, + "listreceivedbyaccount": Unimplemented, + "listsinceblock": Unimplemented, + "listreceivedbyaddress": Unimplemented, + "listunspent": Unimplemented, + "lockunspent": Unimplemented, + "move": Unimplemented, + "sendtoaddress": Unimplemented, + "setaccount": Unimplemented, + "signmessage": Unimplemented, + "signrawtransaction": Unimplemented, + "validateaddress": Unimplemented, + "verifymessage": Unimplemented, // Standard bitcoind methods which won't be implemented by btcwallet. "encryptwallet": Unsupported, @@ -1277,7 +1277,7 @@ func WalletPassphrase(icmd btcjson.Cmd) (interface{}, *btcjson.Error) { return nil, &e } - switch err := a.Unlock([]byte(cmd.Passphrase), cmd.Timeout); err { + switch err := a.Unlock([]byte(cmd.Passphrase)); err { case nil: go func(timeout int64) { time.Sleep(time.Second * time.Duration(timeout)) @@ -1293,6 +1293,37 @@ func WalletPassphrase(icmd btcjson.Cmd) (interface{}, *btcjson.Error) { } } +// WalletPassphraseChange responds to the walletpassphrasechange request +// by unlocking all accounts with the provided old passphrase, and +// re-encrypting each private key with an AES key derived from the new +// passphrase. +// +// If the old passphrase is correct and the passphrase is changed, all +// wallets will be immediately locked. +func WalletPassphraseChange(icmd btcjson.Cmd) (interface{}, *btcjson.Error) { + cmd, ok := icmd.(*btcjson.WalletPassphraseChangeCmd) + if !ok { + return nil, &btcjson.ErrInternal + } + + err := accountstore.ChangePassphrase([]byte(cmd.OldPassphrase), + []byte(cmd.NewPassphrase)) + switch err { + case nil: + return nil, nil + + case wallet.ErrWrongPassphrase: + return nil, &btcjson.ErrWalletPassphraseIncorrect + + default: // all other non-nil errors + e := btcjson.Error{ + Code: btcjson.ErrWallet.Code, + Message: err.Error(), + } + return nil, &e + } +} + // AccountNtfn is a struct for marshalling any generic notification // about a account for a wallet frontend. // diff --git a/disksync.go b/disksync.go index 573e4fd..b8c03d8 100644 --- a/disksync.go +++ b/disksync.go @@ -36,8 +36,8 @@ var ( } ) -// networkDir returns the base directory name for the bitcoin network -// net. +// networkDir returns the directory name of a network directory to hold account +// files. func networkDir(net btcwire.BitcoinNet) string { var netname string if net == btcwire.MainNet { @@ -48,6 +48,11 @@ func networkDir(net btcwire.BitcoinNet) string { return filepath.Join(cfg.DataDir, netname) } +// tmpNetworkDir returns the temporary directory name for a given network. +func tmpNetworkDir(net btcwire.BitcoinNet) string { + return networkDir(net) + "_tmp" +} + // checkCreateDir checks that the path exists and is a directory. // If path does not exist, it is created. func checkCreateDir(path string) error { @@ -109,6 +114,82 @@ func DirtyAccountSyncer() { } } +// freshDir creates a new directory specified by path if it does not +// exist. If the directory already exists, all files contained in the +// directory are removed. +func freshDir(path string) error { + if err := checkCreateDir(path); err != nil { + return err + } + + // Remove all files in the directory. + fd, err := os.Open(path) + if err != nil { + return err + } + defer fd.Close() + names, err := fd.Readdirnames(0) + if err != nil { + return err + } + for _, name := range names { + if err := os.RemoveAll(name); err != nil { + return err + } + } + + return nil +} + +// writeAllToFreshDir writes all account files to the specified directory. +// If dir already exists, any old files are removed. If dir does not +// exist, it is created. +// +// It is a runtime error to call this function while not holding each +// wallet, tx store, and utxo store writer lock. +func (a *Account) writeAllToFreshDir(dir string) error { + if err := freshDir(dir); err != nil { + return err + } + + wfilepath := accountFilename("wallet.bin", a.name, dir) + txfilepath := accountFilename("tx.bin", a.name, dir) + utxofilepath := accountFilename("utxo.bin", a.name, dir) + + wfile, err := os.Create(wfilepath) + if err != nil { + return err + } + defer wfile.Close() + txfile, err := os.Create(txfilepath) + if err != nil { + return err + } + defer txfile.Close() + utxofile, err := os.Create(utxofilepath) + if err != nil { + return err + } + defer utxofile.Close() + + if _, err := a.Wallet.WriteTo(wfile); err != nil { + return err + } + a.dirty = false + + if _, err := a.TxStore.s.WriteTo(txfile); err != nil { + return err + } + a.TxStore.dirty = false + + if _, err := a.UtxoStore.s.WriteTo(utxofile); err != nil { + return err + } + a.UtxoStore.dirty = false + + return nil +} + // writeDirtyToDisk checks for the dirty flag on an account's wallet, // txstore, and utxostore, writing them to disk if any are dirty. func (a *Account) writeDirtyToDisk() error { diff --git a/tx/tx.go b/tx/tx.go index f609622..acab561 100644 --- a/tx/tx.go +++ b/tx/tx.go @@ -109,6 +109,7 @@ type Tx interface { ReadFromVersion(uint32, io.Reader) (int64, error) TxInfo(string, int32, btcwire.BitcoinNet) []map[string]interface{} } + // TxStore is a slice holding RecvTx and SendTx pointers. type TxStore []Tx diff --git a/updates.go b/updates.go index 9014d03..f9f3d7e 100644 --- a/updates.go +++ b/updates.go @@ -75,6 +75,22 @@ func updateOldFileLocations() { os.Exit(1) } + acctsExist := false + for i := range fi { + // Ignore non-directories. + if !fi[i].IsDir() { + continue + } + + if strings.HasPrefix(fi[i].Name(), "btcwallet") { + acctsExist = true + break + } + } + if !acctsExist { + return + } + // Create testnet directory, if it doesn't already exist. netdir := filepath.Join(cfg.DataDir, "testnet") if err := checkCreateDir(netdir); err != nil { diff --git a/wallet/wallet.go b/wallet/wallet.go index 58411e4..e94d984 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -61,6 +61,7 @@ var ( ErrWalletDoesNotExist = errors.New("non-existant wallet") ErrWalletIsWatchingOnly = errors.New("wallet is watching-only") ErrWalletLocked = errors.New("wallet is locked") + ErrWrongPassphrase = errors.New("wrong passphrase") ) var ( @@ -845,6 +846,37 @@ func (w *Wallet) Passphrase() ([]byte, error) { return nil, ErrWalletLocked } +// ChangePassphrase creates a new AES key from a new passphrase and +// re-encrypts all encrypted private keys with the new key. +func (w *Wallet) ChangePassphrase(new []byte) error { + if w.flags.watchingOnly { + return ErrWalletIsWatchingOnly + } + + if len(w.secret) != 32 { + return ErrWalletLocked + } + + oldkey := w.secret + newkey := Key(new, &w.kdfParams) + + for _, a := range w.addrMap { + if err := a.changeEncryptionKey(oldkey, newkey); err != nil { + return err + } + } + + // zero old secrets. + zero(w.passphrase) + zero(w.secret) + + // Save new secrets. + w.passphrase = new + w.secret = newkey + + return nil +} + func zero(b []byte) { for i := range b { b[i] = 0 @@ -2151,7 +2183,7 @@ func (a *btcAddress) unlock(key []byte) (privKeyCT []byte, err error) { } x, y := btcec.S256().ScalarBaseMult(privkey) if x.Cmp(pubKey.X) != 0 || y.Cmp(pubKey.Y) != 0 { - return nil, errors.New("decryption failed") + return nil, ErrWrongPassphrase } privkeyCopy := make([]byte, 32) @@ -2160,9 +2192,36 @@ func (a *btcAddress) unlock(key []byte) (privKeyCT []byte, err error) { return privkeyCopy, nil } -// TODO(jrick) +// changeEncryptionKey re-encrypts the private keys for an address +// with a new AES encryption key. oldkey must be the old AES encryption key +// and is used to decrypt the private key. func (a *btcAddress) changeEncryptionKey(oldkey, newkey []byte) error { - return errors.New("unimplemented") + // Address must have a private key and be encrypted to continue. + if !a.flags.hasPrivKey { + return errors.New("no private key") + } + if !a.flags.encrypted { + return errors.New("address is not encrypted") + } + + privKeyCT, err := a.unlock(oldkey) + if err != nil { + return err + } + + aesBlockEncrypter, err := aes.NewCipher(newkey) + if err != nil { + return err + } + newIV := make([]byte, len(a.initVector)) + if _, err := rand.Read(newIV); err != nil { + return err + } + copy(a.initVector[:], newIV) + aesEncrypter := cipher.NewCFBEncrypter(aesBlockEncrypter, a.initVector[:]) + aesEncrypter.XORKeyStream(a.privKey[:], privKeyCT) + + return nil } // address returns a btcutil.AddressPubKeyHash for a btcAddress. diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index cb71e1b..1721594 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -667,3 +667,78 @@ func TestWatchingWalletExport(t *testing.T) { return } } + +func TestChangePassphrase(t *testing.T) { + const keypoolSize = 10 + createdAt := &BlockStamp{} + w, err := NewWallet("banana wallet", "A wallet for testing.", + []byte("banana"), btcwire.MainNet, createdAt, keypoolSize) + if err != nil { + t.Error("Error creating new wallet: " + err.Error()) + return + } + + // Changing the passphrase with a locked wallet must fail with ErrWalletLocked. + if err := w.ChangePassphrase([]byte("potato")); err != ErrWalletLocked { + t.Errorf("Changing passphrase on a locked wallet did not fail correctly: %v", err) + return + } + + // Unlock wallet so the passphrase can be changed. + if err := w.Unlock([]byte("banana")); err != nil { + t.Errorf("Cannot unlock: %v", err) + return + } + + // Get root address and its private key. This is compared to the private + // key post passphrase change. + rootAddr := w.LastChainedAddress() + rootPrivKey, err := w.AddressKey(rootAddr) + if err != nil { + t.Errorf("Cannot get root address' private key: %v", err) + return + } + + // Change passphrase. + if err := w.ChangePassphrase([]byte("potato")); err != nil { + t.Errorf("Changing passhprase failed: %v", err) + return + } + + // Wallet should still be unlocked. + if w.IsLocked() { + t.Errorf("Wallet should be unlocked after passphrase change.") + return + } + + // Lock it. + if err := w.Lock(); err != nil { + t.Errorf("Cannot lock wallet after passphrase change: %v", err) + return + } + + // Unlock with old passphrase. This must fail with ErrWrongPassphrase. + if err := w.Unlock([]byte("banana")); err != ErrWrongPassphrase { + t.Errorf("Unlocking with old passphrases did not fail correctly: %v", err) + return + } + + // Unlock with new passphrase. This must succeed. + if err := w.Unlock([]byte("potato")); err != nil { + t.Errorf("Unlocking with new passphrase failed: %v", err) + return + } + + // Get root address' private key again. + rootPrivKey2, err := w.AddressKey(rootAddr) + if err != nil { + t.Errorf("Cannot get root address' private key after passphrase change: %v", err) + return + } + + // Private keys must match. + if !reflect.DeepEqual(rootPrivKey, rootPrivKey2) { + t.Errorf("Private keys before and after unlock differ.") + return + } +}