waddrmgr: add new DeriveFromKeyPathCache method for faster key retrieval

In this commit, we add a new method `DeriveFromKeyPathCache` that gives
callers a way to more quickly obtain a private key they know they'll be
using frequently. This method lets a caller avoid the write database
transaction as well as the EC operations to derive the key itself (BIP
32).
This commit is contained in:
Olaoluwa Osuntokun 2021-08-13 16:13:06 -07:00 committed by Roy Lee
parent 4a75796117
commit e0c5ce72cf
4 changed files with 198 additions and 10 deletions

View file

@ -139,6 +139,10 @@ const (
// ErrBlockNotFound is returned when we attempt to retrieve the hash for // ErrBlockNotFound is returned when we attempt to retrieve the hash for
// a block that we do not know of. // a block that we do not know of.
ErrBlockNotFound ErrBlockNotFound
// ErrAccountNotCached is returned when we attempt to perform an
// operation that relies on an account begin cached but it isn't.
ErrAccountNotCached
) )
// Map of ErrorCode values back to their constant names for pretty printing. // Map of ErrorCode values back to their constant names for pretty printing.
@ -165,6 +169,7 @@ var errorCodeStrings = map[ErrorCode]string{
ErrCallBackBreak: "ErrCallBackBreak", ErrCallBackBreak: "ErrCallBackBreak",
ErrEmptyPassphrase: "ErrEmptyPassphrase", ErrEmptyPassphrase: "ErrEmptyPassphrase",
ErrScopeNotFound: "ErrScopeNotFound", ErrScopeNotFound: "ErrScopeNotFound",
ErrAccountNotCached: "ErrAccountNotCached",
} }
// String returns the ErrorCode as a human-readable name. // String returns the ErrorCode as a human-readable name.

View file

@ -17,6 +17,7 @@ import (
"github.com/btcsuite/btcwallet/internal/zero" "github.com/btcsuite/btcwallet/internal/zero"
"github.com/btcsuite/btcwallet/snacl" "github.com/btcsuite/btcwallet/snacl"
"github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb"
"github.com/lightninglabs/neutrino/cache/lru"
) )
const ( const (
@ -602,11 +603,12 @@ func (m *Manager) NewScopedKeyManager(ns walletdb.ReadWriteBucket,
// Finally, we'll register this new scoped manager with the root // Finally, we'll register this new scoped manager with the root
// manager. // manager.
m.scopedManagers[scope] = &ScopedKeyManager{ m.scopedManagers[scope] = &ScopedKeyManager{
scope: scope, scope: scope,
addrSchema: addrSchema, addrSchema: addrSchema,
rootManager: m, rootManager: m,
addrs: make(map[addrKey]ManagedAddress), addrs: make(map[addrKey]ManagedAddress),
acctInfo: make(map[uint32]*accountInfo), acctInfo: make(map[uint32]*accountInfo),
privKeyCache: lru.NewCache(defaultPrivKeyCacheSize),
} }
m.externalAddrSchemas[addrSchema.ExternalAddrType] = append( m.externalAddrSchemas[addrSchema.ExternalAddrType] = append(
m.externalAddrSchemas[addrSchema.ExternalAddrType], scope, m.externalAddrSchemas[addrSchema.ExternalAddrType], scope,
@ -1620,10 +1622,11 @@ func loadManager(ns walletdb.ReadBucket, pubPassphrase []byte,
} }
scopedManagers[scope] = &ScopedKeyManager{ scopedManagers[scope] = &ScopedKeyManager{
scope: scope, scope: scope,
addrSchema: *scopeSchema, addrSchema: *scopeSchema,
addrs: make(map[addrKey]ManagedAddress), addrs: make(map[addrKey]ManagedAddress),
acctInfo: make(map[uint32]*accountInfo), acctInfo: make(map[uint32]*accountInfo),
privKeyCache: lru.NewCache(defaultPrivKeyCacheSize),
} }
return nil return nil

View file

@ -14,6 +14,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/btcsuite/btcd/btcec"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcutil" "github.com/btcsuite/btcutil"
@ -21,6 +22,7 @@ import (
"github.com/btcsuite/btcwallet/snacl" "github.com/btcsuite/btcwallet/snacl"
"github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb"
"github.com/davecgh/go-spew/spew" "github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require"
) )
// failingCryptoKey is an implementation of the EncryptorDecryptor interface // failingCryptoKey is an implementation of the EncryptorDecryptor interface
@ -2764,3 +2766,96 @@ func testNewRawAccount(t *testing.T, _ *Manager, db walletdb.DB,
accountTargetAddr.AddrHash()) accountTargetAddr.AddrHash())
} }
} }
// TestDeriveFromKeyPathCache tests that the DeriveFromKeyPathCache method will
// properly cache items in the cache, and return corresponding errors if the
// account isn't properly cached.
func TestDeriveFromKeyPathCache(t *testing.T) {
t.Parallel()
teardown, db := emptyDB(t)
defer teardown()
// We'll start the test by creating a new root manager that will be
// used for the duration of the test.
var mgr *Manager
err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error {
ns, err := tx.CreateTopLevelBucket(waddrmgrNamespaceKey)
if err != nil {
return err
}
err = Create(
ns, seed, pubPassphrase, privPassphrase,
&chaincfg.MainNetParams, fastScrypt, time.Time{},
)
if err != nil {
return err
}
mgr, err = Open(ns, pubPassphrase, &chaincfg.MainNetParams)
if err != nil {
return err
}
return mgr.Unlock(ns, privPassphrase)
})
require.NoError(t, err, "create/open: unexpected error: %v", err)
defer mgr.Close()
// Now that we have the manager created, we'll fetch one of the default
// scopes for usage within this test.
scopedMgr, err := mgr.FetchScopedKeyManager(KeyScopeBIP0044)
require.NoError(
t, err, "unable to fetch scope %v: %v", KeyScopeBIP0044, err,
)
keyPath := DerivationPath{
InternalAccount: 0,
Account: hdkeychain.HardenedKeyStart,
Branch: 10,
Index: 1,
}
// Our test starts here, we'll attempt to derive a new key using the
// cached method. This should fail at first since the account itself
// isn't cached.
_, err = scopedMgr.DeriveFromKeyPathCache(keyPath)
if !IsError(err, ErrAccountNotCached) {
t.Fatalf("didn't get account not cached error: %v", err)
}
// Now we'll attempt to derive the key using the normal method that
// requires a database transaction.
var derivedKey *btcec.PrivateKey
err = walletdb.Update(db, func(tx walletdb.ReadWriteTx) error {
ns := tx.ReadWriteBucket(waddrmgrNamespaceKey)
managedAddr, err := scopedMgr.DeriveFromKeyPath(
ns, keyPath,
)
if err != nil {
return err
}
derivedKey, err = managedAddr.(ManagedPubKeyAddress).PrivKey()
if err != nil {
return err
}
return nil
})
require.NoError(t, err, "unable to derive addr: %v", err)
// Next attempt to read the key again from the cache, it should succeed
// this time.
cachedKey, err := scopedMgr.DeriveFromKeyPathCache(keyPath)
require.NoError(t, err, "account wasn't cached")
// We should be able to read the key again.
cachedKey2, err := scopedMgr.DeriveFromKeyPathCache(keyPath)
require.NoError(t, err, "account wasn't cached")
// All three keys we have now should match exactly.
require.Equal(t, cachedKey.Serialize(), cachedKey2.Serialize())
require.Equal(t, derivedKey.Serialize(), cachedKey2.Serialize())
}

View file

@ -14,6 +14,7 @@ import (
"github.com/btcsuite/btcwallet/internal/zero" "github.com/btcsuite/btcwallet/internal/zero"
"github.com/btcsuite/btcwallet/netparams" "github.com/btcsuite/btcwallet/netparams"
"github.com/btcsuite/btcwallet/walletdb" "github.com/btcsuite/btcwallet/walletdb"
"github.com/lightninglabs/neutrino/cache/lru"
) )
// HDVersion represents the different supported schemes of hierarchical // HDVersion represents the different supported schemes of hierarchical
@ -51,6 +52,14 @@ const (
HDVersionSimNetBIP0044 HDVersion = 0x0420bd3a // spub HDVersionSimNetBIP0044 HDVersion = 0x0420bd3a // spub
) )
const (
// privKeyCacheSize is the default size of the LRU cache that we'll use
// to cache private keys to avoid DB and EC operations within the
// wallet. With the default sisize, we'll allocate up to 320 KB to
// caching private keys (ignoring pointer overhead, etc).
defaultPrivKeyCacheSize = 10_000
)
// DerivationPath represents a derivation path from a particular key manager's // DerivationPath represents a derivation path from a particular key manager's
// scope. Each ScopedKeyManager starts key derivation from the end of their // scope. Each ScopedKeyManager starts key derivation from the end of their
// cointype hardened key: m/purpose'/cointype'. The fields in this struct allow // cointype hardened key: m/purpose'/cointype'. The fields in this struct allow
@ -220,7 +229,7 @@ type ScopedKeyManager struct {
rootManager *Manager rootManager *Manager
// addrs is a cached map of all the addresses that we currently // addrs is a cached map of all the addresses that we currently
// manager. // manage.
addrs map[addrKey]ManagedAddress addrs map[addrKey]ManagedAddress
// acctInfo houses information about accounts including what is needed // acctInfo houses information about accounts including what is needed
@ -234,6 +243,11 @@ type ScopedKeyManager struct {
// order to encrypt it. // order to encrypt it.
deriveOnUnlock []*unlockDeriveInfo deriveOnUnlock []*unlockDeriveInfo
// privKeyCache stores the set of private keys that have been marked as
// items to be cached to allow us to avoid the database and EC
// operations each time a key need to be obtained.
privKeyCache *lru.Cache
mtx sync.RWMutex mtx sync.RWMutex
} }
@ -579,6 +593,77 @@ func (s *ScopedKeyManager) AccountProperties(ns walletdb.ReadBucket,
return props, nil return props, nil
} }
// cachedKey is an entry within the LRU map that stores private keys that are
// to be used frequently. We use this wrapper struct to be able too report the
// size of a given element to the cache.
type cachedKey struct {
key *btcec.PrivateKey
}
// Size returns the size of this element. Rather than have the cache limit
// based on bytes, we simply report that each element is of size 1, meaning we
// can set our cached based on the amount of keys we want to store, rather than
// the total size of all the keys.
func (c *cachedKey) Size() (uint64, error) {
return 1, nil
}
// DeriveFromKeyPathCache is identical to DeriveFromKeyPath, however it'll fail
// if the account refracted in the DerivationPath isn't already in the
// in-memory cache. Callers looking for faster private key retrieval can opt to
// call this method, which may fail if things aren't in the cache, then fall
// back to the normal variant. The account can information can be drawn into
// the cache if the normal DeriveFromKeyPath method is used, or the account is
// looked up via any other means.
func (s *ScopedKeyManager) DeriveFromKeyPathCache(
kp DerivationPath) (*btcec.PrivateKey, error) {
s.mtx.Lock()
defer s.mtx.Unlock()
// First, try to look up the key itself in the proper cache, if the key
// is here, then we don't need to do anything further.
privKeyVal, err := s.privKeyCache.Get(kp)
if err == nil {
return privKeyVal.(*cachedKey).key, nil
}
// If the key isn't already in the cache, then we'll try to look up the
// account info in the cache, if this fails, then we exit here as we
// can't move forward without creating a DB transaction, and the point
// of this method is to avoid that.
acctInfo, ok := s.acctInfo[kp.InternalAccount]
if !ok {
return nil, managerError(
ErrAccountNotCached,
"", fmt.Errorf("acct %v not cached", kp.InternalAccount),
)
}
watchOnly := s.rootManager.WatchOnly()
private := !s.rootManager.IsLocked() && !watchOnly
// Now that we have the account information, we can derive the key
// directly.
addrKey, err := s.deriveKey(acctInfo, kp.Branch, kp.Index, private)
if err != nil {
return nil, err
}
// Now that we have the key, we'll attempt to insert it into the cache,
// and return it as is.
privKey, err := addrKey.ECPrivKey()
if err != nil {
return nil, err
}
_, err = s.privKeyCache.Put(kp, &cachedKey{key: privKey})
if err != nil {
return nil, err
}
return privKey, nil
}
// DeriveFromKeyPath attempts to derive a maximal child key (under the BIP0044 // DeriveFromKeyPath attempts to derive a maximal child key (under the BIP0044
// scheme) from a given key path. If key derivation isn't possible, then an // scheme) from a given key path. If key derivation isn't possible, then an
// error will be returned. // error will be returned.