diff --git a/wallet/cache.go b/wallet/cache.go index f32e1a558c..fdf99c739c 100644 --- a/wallet/cache.go +++ b/wallet/cache.go @@ -21,6 +21,12 @@ type runtimeCache interface { GetAccount(ctx context.Context, query db.GetAccountQuery) (*db.AccountInfo, error) + // GetAccountSecret returns encrypted account-level signing material. + // The result mirrors the underlying db.AccountStore.GetAccountSecret + // contract and never contains plaintext key material. + GetAccountSecret(ctx context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) + // ListAccounts returns accounts matching the given query. The result // mirrors the underlying db.AccountStore.ListAccounts contract. ListAccounts(ctx context.Context, @@ -74,6 +80,20 @@ func (c *storeRuntimeCache) GetAccount(ctx context.Context, return c.store.GetAccount(ctx, query) } +// GetAccountSecret delegates to the underlying db.Store. +// +// NOTE: pass-through today. See storeRuntimeCache's TODO(yy). +// +// TODO(yy): drop the wrapcheck exemption once the cache layer wraps +// store errors with its own typed errors. +// +//nolint:wrapcheck +func (c *storeRuntimeCache) GetAccountSecret(ctx context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) { + + return c.store.GetAccountSecret(ctx, query) +} + // ListAccounts delegates to the underlying db.Store. // // NOTE: pass-through today. See storeRuntimeCache's TODO(yy). diff --git a/wallet/common_test.go b/wallet/common_test.go index 05500053a9..e3f5e22022 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -23,7 +23,6 @@ var ( errMock = errors.New("mock error") errChainMock = errors.New("chain error") errPutMock = errors.New("put error") - errLockMock = errors.New("lock fail") errDBFail = errors.New("db fail") errDeriveFail = errors.New("derive fail") errLoadStateFail = errors.New("load state fail") diff --git a/wallet/controller.go b/wallet/controller.go index 44b0c8a040..020e1666d5 100644 --- a/wallet/controller.go +++ b/wallet/controller.go @@ -700,8 +700,10 @@ func (w *Wallet) handleUnlockReq(req unlockReq) { return } - // Attempt to unlock the underlying address manager. - err = w.DBUnlock(w.lifetimeCtx, req.req.Passphrase) + // Attempt to unlock the key vault. We pass a negative timeout to + // disable the vault's own auto-lock: the controller keeps owning the + // auto-lock schedule through its lockTimer below. + err = w.keyVault.Unlock(w.lifetimeCtx, req.req.Passphrase, -1) if err != nil { req.resp <- err return @@ -752,22 +754,12 @@ func (w *Wallet) handleLockReq(req lockReq) { } } - // Signal the address manager to lock, clearing sensitive data. - err = w.addrStore.Lock() - if err != nil { - log.Errorf("Could not lock wallet: %v", err) - - // If the wallet is already locked, we consider this a success - // (idempotency) and proceed to ensure our state is consistent. - if !waddrmgr.IsError(err, waddrmgr.ErrLocked) { - req.resp <- err - - return - } - } + // Signal the key vault to lock, clearing sensitive data. Lock is void + // and idempotent: the vault swallows an already-locked condition and + // logs any other failure internally. + w.keyVault.Lock() - // Even if an error occurred (e.g. already locked), we ensure the - // wallet's high-level state is synchronized to 'locked'. + // Synchronize the wallet's high-level state to 'locked'. w.state.toLocked() // Report the result back to the caller. @@ -786,8 +778,19 @@ func (w *Wallet) handleChangePassphraseReq(req changePassphraseReq) { return } - // Delegate the cryptographic rotation to the database layer. + // Persist the passphrase rotation to the database. err = w.DBPutPassphrase(w.lifetimeCtx, req.req) + if err != nil { + req.resp <- err + return + } + + // A private passphrase change rotates the secret crypto keys, so let + // the key vault refresh any runtime state it caches under the new + // passphrase. + if req.req.ChangePrivate { + err = w.keyVault.RefreshPrivatePassphrase(req.req.PrivateNew) + } // Report the result back to the caller. req.resp <- err diff --git a/wallet/controller_test.go b/wallet/controller_test.go index f79356dcc0..1bf38ecd69 100644 --- a/wallet/controller_test.go +++ b/wallet/controller_test.go @@ -60,8 +60,10 @@ func TestHandleUnlockReq(t *testing.T) { pass := []byte("password") req := newUnlockReq(UnlockRequest{Passphrase: pass}) - // Setup the expected call to the address manager's Unlock method. - deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + // Setup the expected call to the key vault's Unlock method. + deps.vault.On( + "Unlock", mock.Anything, pass, mock.Anything, + ).Return(nil).Once() // Act: Dispatch the unlock request to the handler. w.handleUnlockReq(req) @@ -107,7 +109,9 @@ func TestHandleUnlockReq_Errors(t *testing.T) { pass := []byte("password") req := newUnlockReq(UnlockRequest{Passphrase: pass}) - deps.addrStore.On("Unlock", mock.Anything, pass).Return( + deps.vault.On( + "Unlock", mock.Anything, pass, mock.Anything, + ).Return( errDBMock, ).Once() @@ -135,8 +139,8 @@ func TestHandleLockReq(t *testing.T) { req := newLockReq() - // Setup the expected call to the address manager's Lock method. - deps.addrStore.On("Lock").Return(nil).Once() + // Setup the expected call to the key vault's Lock method. + deps.vault.On("Lock").Return().Once() // Act: Dispatch the lock request to the handler. w.handleLockReq(req) @@ -148,42 +152,6 @@ func TestHandleLockReq(t *testing.T) { require.False(t, w.state.isUnlocked()) } -// TestHandleLockReq_Idempotency verifies that if the wallet is already locked -// (indicated by waddrmgr.ErrLocked), the lock request treats it as a success -// and ensures the state is consistent. -func TestHandleLockReq_Idempotency(t *testing.T) { - t.Parallel() - - // Arrange: Create a test wallet and transition it to 'Started'. - w, deps := createTestWalletWithMocks(t) - require.NoError(t, w.state.toStarting()) - require.NoError(t, w.state.toStarted()) - - // Transition the wallet to the 'Unlocked' state for testing. - w.state.toUnlocked() - - req := newLockReq() - - // Setup the expected call to the address manager's Lock method - // returning ErrLocked. - errLocked := waddrmgr.ManagerError{ - ErrorCode: waddrmgr.ErrLocked, - Description: "address manager is locked", - } - deps.addrStore.On("Lock").Return(errLocked).Once() - - // Act: Dispatch the lock request to the handler. - w.handleLockReq(req) - - // Assert: Verify that the response indicates success and the wallet - // state is 'Locked'. - resp := <-req.resp - require.NoError(t, resp) - require.False(t, w.state.isUnlocked()) -} - -// TestHandleLockReq_Errors verifies that handleLockReq correctly handles error -// conditions, such as attempting to lock a stopped wallet. func TestHandleLockReq_Errors(t *testing.T) { t.Parallel() @@ -249,12 +217,15 @@ func TestHandleChangePassphraseReq(t *testing.T) { } req := newChangePassphraseReq(reqStruct) - // Setup the expected call to the address manager's ChangePassphrase - // method. + // DBPutPassphrase drives the legacy address manager for the private + // rotation, then the controller refreshes the vault's runtime state. deps.addrStore.On( "ChangePassphrase", mock.Anything, []byte("old"), []byte("new"), true, mock.Anything, ).Return(nil).Once() + deps.vault.On( + "RefreshPrivatePassphrase", []byte("new"), + ).Return(nil).Once() // Act: Call the handler. w.handleChangePassphraseReq(req) @@ -622,8 +593,8 @@ func TestControllerLock(t *testing.T) { w.state.toUnlocked() require.True(t, w.state.isUnlocked()) - // Expect a call to the address manager's Lock method. - deps.addrStore.On("Lock").Return(nil).Once() + // Expect a call to the key vault's Lock method. + deps.vault.On("Lock").Return().Once() // Act: Call the Lock method. err := w.Lock(t.Context()) @@ -661,8 +632,10 @@ func TestControllerUnlock(t *testing.T) { pass := []byte("password") - // Expect a call to the address manager's Unlock method. - deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + // Expect a call to the key vault's Unlock method. + deps.vault.On( + "Unlock", mock.Anything, pass, mock.Anything, + ).Return(nil).Once() // Act: Call the Unlock method. err := w.Unlock(t.Context(), UnlockRequest{Passphrase: pass}) @@ -704,10 +677,14 @@ func TestControllerChangePassphrase(t *testing.T) { PrivateNew: []byte("new"), } - // Expect a call to ChangePassphrase in the address store. + // DBPutPassphrase drives the legacy address manager for the private + // rotation, then the controller refreshes the vault's runtime state. deps.addrStore.On( - "ChangePassphrase", mock.Anything, []byte("old"), []byte("new"), - true, mock.Anything, + "ChangePassphrase", mock.Anything, []byte("old"), + []byte("new"), true, mock.Anything, + ).Return(nil).Once() + deps.vault.On( + "RefreshPrivatePassphrase", []byte("new"), ).Return(nil).Once() // Act: Call ChangePassphrase. @@ -861,9 +838,9 @@ func TestMainLoop_AutoLock(t *testing.T) { w.lockTimer = time.NewTimer(time.Millisecond * 10) lockCalled := make(chan struct{}) - deps.addrStore.On("Lock").Run(func(args mock.Arguments) { + deps.vault.On("Lock").Run(func(args mock.Arguments) { close(lockCalled) - }).Return(nil).Once() + }).Return().Once() // Act: Start main loop. w.wg.Add(1) @@ -1437,10 +1414,12 @@ func TestControllerUnlock_DefaultTimeout(t *testing.T) { pass := []byte("pass") req := UnlockRequest{Passphrase: pass} - deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + deps.vault.On( + "Unlock", mock.Anything, pass, mock.Anything, + ).Return(nil).Once() // Auto-lock might trigger if the test runs slowly, but it's not // guaranteed. - deps.addrStore.On("Lock").Return(nil).Maybe() + deps.vault.On("Lock").Return().Maybe() // Act: Perform Unlock with default timeout. err := w.Unlock(t.Context(), req) @@ -1497,7 +1476,9 @@ func TestControllerUnlock_NegativeTimeout(t *testing.T) { pass := []byte("pass") req := UnlockRequest{Passphrase: pass, Timeout: -1} - deps.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() + deps.vault.On( + "Unlock", mock.Anything, pass, mock.Anything, + ).Return(nil).Once() // Act: Perform Unlock with negative timeout (no auto-lock). err := w.Unlock(t.Context(), req) @@ -1526,7 +1507,7 @@ func TestControllerUnlock_DBUnlockFail(t *testing.T) { go w.mainLoop() pass := []byte("pass") - deps.addrStore.On("Unlock", mock.Anything, pass).Return( + deps.vault.On("Unlock", mock.Anything, pass, mock.Anything).Return( errDBMock).Once() // Act: Attempt Unlock. @@ -1540,28 +1521,6 @@ func TestControllerUnlock_DBUnlockFail(t *testing.T) { w.wg.Wait() } -// TestHandleLockReq_LockError verifies error handling when Lock fails. -func TestHandleLockReq_LockError(t *testing.T) { - t.Parallel() - - // Arrange: Setup mock expectations where internal lock fails. - w, deps := createTestWalletWithMocks(t) - - require.NoError(t, w.state.toStarting()) - require.NoError(t, w.state.toStarted()) - - req := lockReq{resp: make(chan error, 1)} - - deps.addrStore.On("Lock").Return(errLockMock).Once() - - // Act: Handle lock request. - w.handleLockReq(req) - err := <-req.resp - - // Assert: Verify error. - require.ErrorContains(t, err, "lock fail") -} - // TestSubmitRescanRequest_HeightOverflow verifies large start height rejection. func TestSubmitRescanRequest_HeightOverflow(t *testing.T) { t.Parallel() diff --git a/wallet/db_ops.go b/wallet/db_ops.go index 0bd55cc74d..8689d0a2f6 100644 --- a/wallet/db_ops.go +++ b/wallet/db_ops.go @@ -198,23 +198,6 @@ func (w *Wallet) DBDeleteExpiredLockedOutputs(_ context.Context) error { return nil } -// DBUnlock attempts to unlock the wallet's address manager with the provided -// passphrase. -// -// TODO(yy): Refactor this in the `Store` implementation - the only db -// operation needed is to load the account info and derive the private keys. -func (w *Wallet) DBUnlock(_ context.Context, passphrase []byte) error { - err := walletdb.View(w.cfg.DB, func(tx walletdb.ReadTx) error { - addrmgrNs := tx.ReadBucket(waddrmgrNamespaceKey) - return w.addrStore.Unlock(addrmgrNs, passphrase) - }) - if err != nil { - return fmt.Errorf("view: %w", err) - } - - return nil -} - // DBPutPassphrase updates the wallet's public or private passphrases. // // TODO(yy): Refactor this in the `Store` implementation - we can call diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index b4bad61b4c..6270c776f6 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -142,25 +142,6 @@ func TestDBBirthdayBlock(t *testing.T) { require.Equal(t, block, retBlock) } -// TestDBUnlock verifies that the wallet can successfully unlock its address -// manager using the provided passphrase. -func TestDBUnlock(t *testing.T) { - t.Parallel() - - // Arrange: Create a test wallet and setup the expected mock call for - // unlocking the address manager. - w, mocks := createTestWalletWithMocks(t) - pass := []byte("password") - - mocks.addrStore.On("Unlock", mock.Anything, pass).Return(nil).Once() - - // Act: Attempt to unlock the wallet with the passphrase. - err := w.DBUnlock(t.Context(), pass) - - // Assert: Verify that the unlock operation succeeded. - require.NoError(t, err) -} - // TestDBDeleteExpiredLockedOutputs verifies that the wallet successfully // invokes the transaction store to remove any expired output locks. func TestDBDeleteExpiredLockedOutputs(t *testing.T) { diff --git a/wallet/deprecated.go b/wallet/deprecated.go index 6d47fa2eaa..328e860d55 100644 --- a/wallet/deprecated.go +++ b/wallet/deprecated.go @@ -29,7 +29,6 @@ import ( "github.com/btcsuite/btcwallet/internal/prompt" "github.com/btcsuite/btcwallet/waddrmgr" kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" - "github.com/btcsuite/btcwallet/wallet/internal/keyvault" "github.com/btcsuite/btcwallet/wallet/txauthor" "github.com/btcsuite/btcwallet/wallet/txrules" "github.com/btcsuite/btcwallet/walletdb" @@ -7215,7 +7214,7 @@ func OpenWithRetry(db walletdb.DB, pubPass []byte, cbs *waddrmgr.OpenCallbacks, id: walletID, addrStore: addrMgr, store: store, - keyVault: keyvault.NewDBVault(store, walletID), + keyVault: kvdb.NewLegacyManagerVault(db, addrMgr), txStore: txMgr, walletDeprecated: deprecated, } diff --git a/wallet/internal/bwtest/mock/store.go b/wallet/internal/bwtest/mock/store.go index 1131c66b21..7433cb10b3 100644 --- a/wallet/internal/bwtest/mock/store.go +++ b/wallet/internal/bwtest/mock/store.go @@ -178,6 +178,18 @@ func (m *Store) GetAccount(ctx context.Context, return args.Get(0).(*db.AccountInfo), args.Error(1) } +// GetAccountSecret implements the db.AccountStore interface. +func (m *Store) GetAccountSecret(ctx context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) { + + args := m.Called(ctx, query) + if args.Get(0) == nil { + return nil, args.Error(1) + } + + return args.Get(0).(*db.AccountSecret), args.Error(1) +} + // ListAccounts implements the db.AccountStore interface. func (m *Store) ListAccounts(ctx context.Context, query db.ListAccountsQuery) ([]db.AccountInfo, error) { diff --git a/wallet/internal/db/accountstore_getaccountsecret.go b/wallet/internal/db/accountstore_getaccountsecret.go new file mode 100644 index 0000000000..e15a4d4acc --- /dev/null +++ b/wallet/internal/db/accountstore_getaccountsecret.go @@ -0,0 +1,21 @@ +package db + +import "errors" + +// ErrAccountSecretUnavailable is returned when a backend does not expose +// store-side account secret material through AccountStore. +var ErrAccountSecretUnavailable = errors.New("account secret unavailable") + +// Validate checks whether a GetAccountSecretQuery identifies exactly one +// account selector. +func (query GetAccountSecretQuery) Validate() error { + if query.Name == nil && query.AccountNumber == nil { + return ErrInvalidAccountQuery + } + + if query.Name != nil && query.AccountNumber != nil { + return ErrInvalidAccountQuery + } + + return nil +} diff --git a/wallet/internal/db/data_types.go b/wallet/internal/db/data_types.go index c20ba450dc..8b82f0b784 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -457,6 +457,37 @@ type AccountInfo struct { rowID int64 } +// AccountSecret holds the encrypted account-level key material used by signing +// operations. The encrypted private key must be decrypted by the caller through +// the wallet key vault. This type intentionally carries no plaintext key +// material. +type AccountSecret struct { + // WalletID is the ID of the wallet that owns the account. + WalletID uint32 + + // Scope is the key scope the account belongs to. + Scope KeyScope + + // AccountNumber is the BIP44 account index for derived accounts. Imported + // accounts have no account number and leave this field at zero. + AccountNumber uint32 + + // AccountName is the human-readable account name. + AccountName string + + // PublicKey is the account-level extended public key in plaintext. + PublicKey []byte + + // EncryptedPrivateKey is the account-level extended private key encrypted + // by the wallet's key vault. A nil value means the account has no private + // account material and cannot sign derived child keys. + EncryptedPrivateKey []byte + + // MasterKeyFingerprint is the fingerprint of the root master key + // corresponding to the account key. + MasterKeyFingerprint uint32 +} + // ScopeAddrSchema is the address schema of a particular KeyScope. It is // persisted on the key_scopes row and consulted when deriving any keys // for a particular scope to know how to encode the public keys as @@ -579,6 +610,25 @@ type GetAccountQuery struct { SkipBalance bool } +// GetAccountSecretQuery contains the parameters for querying account-level +// signing material. The query must specify either the account name or the +// account number within the provided wallet and scope. +type GetAccountSecretQuery struct { + // WalletID is the ID of the wallet to query. + WalletID uint32 + + // Scope is the key scope of the account. + Scope KeyScope + + // Name is the name of the account to query. If nil, the query uses + // AccountNumber. + Name *string + + // AccountNumber is the account number to query. If nil, the query uses + // Name. + AccountNumber *uint32 +} + // ListAccountsQuery holds the set of options for a ListAccounts query. type ListAccountsQuery struct { // WalletID is the ID of the wallet to query. diff --git a/wallet/internal/db/interface.go b/wallet/internal/db/interface.go index 86b21183c2..040cefbdde 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -261,6 +261,12 @@ type AccountStore interface { GetAccount(ctx context.Context, query GetAccountQuery) ( *AccountInfo, error) + // GetAccountSecret retrieves encrypted account-level signing material for + // one account. The result contains encrypted material only; callers must + // use the wallet key vault to decrypt it. + GetAccountSecret(ctx context.Context, query GetAccountSecretQuery) ( + *AccountSecret, error) + // ListAccounts returns a slice of AccountInfo for all accounts, // optionally filtered by name or key scope. It returns an empty slice // if no accounts are found. diff --git a/wallet/internal/db/itest/accountstore_getaccountsecret_test.go b/wallet/internal/db/itest/accountstore_getaccountsecret_test.go new file mode 100644 index 0000000000..3266aec258 --- /dev/null +++ b/wallet/internal/db/itest/accountstore_getaccountsecret_test.go @@ -0,0 +1,96 @@ +//go:build itest + +package itest + +import ( + "context" + "testing" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/stretchr/testify/require" +) + +// TestGetAccountSecret verifies that GetAccountSecret returns account rows +// with encrypted private key material, watch-only nil material, and not-found +// errors as distinct outcomes. +func TestGetAccountSecret(t *testing.T) { + t.Parallel() + + store := NewTestStore(t) + walletID := newWallet(t, store, "wallet-get-account-secret") + scope := db.KeyScopeBIP0084 + pubKey := []byte("derived-account-pubkey") + privKey := []byte("encrypted-account-privkey") + + const fingerprint = uint32(0xAABBCCDD) + + derived, err := store.CreateDerivedAccount( + t.Context(), db.CreateDerivedAccountParams{ + WalletID: walletID, + Scope: scope, + Name: "derived", + }, func(_ context.Context, _ db.KeyScope, _ uint32, + walletIsWatchOnly bool) (*db.DerivedAccountData, error) { + + require.False(t, walletIsWatchOnly) + + return &db.DerivedAccountData{ + PublicKey: pubKey, + EncryptedPrivateKey: privKey, + MasterKeyFingerprint: fingerprint, + }, nil + }, + ) + require.NoError(t, err) + + secret, err := store.GetAccountSecret( + t.Context(), db.GetAccountSecretQuery{ + WalletID: walletID, + Scope: scope, + AccountNumber: derived.AccountNumber, + }, + ) + require.NoError(t, err) + require.Equal(t, walletID, secret.WalletID) + require.Equal(t, scope, secret.Scope) + require.Equal(t, *derived.AccountNumber, secret.AccountNumber) + require.Equal(t, "derived", secret.AccountName) + require.Equal(t, pubKey, secret.PublicKey) + require.Equal(t, privKey, secret.EncryptedPrivateKey) + require.Equal(t, fingerprint, secret.MasterKeyFingerprint) + + watchOnlyName := "watch-only-import" + watchOnlyWalletID := newWatchOnlyWallet( + t, store, "watch-only-get-account-secret", + ) + _, err = store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: watchOnlyWalletID, + Name: watchOnlyName, + Scope: scope, + PublicKey: []byte("watch-only-pubkey"), + }, + ) + require.NoError(t, err) + + secret, err = store.GetAccountSecret( + t.Context(), db.GetAccountSecretQuery{ + WalletID: watchOnlyWalletID, + Scope: scope, + Name: &watchOnlyName, + }, + ) + require.NoError(t, err) + require.Equal(t, watchOnlyName, secret.AccountName) + require.Nil(t, secret.EncryptedPrivateKey) + + missing := uint32(999) + _, err = store.GetAccountSecret( + t.Context(), db.GetAccountSecretQuery{ + WalletID: walletID, + Scope: scope, + AccountNumber: &missing, + }, + ) + require.ErrorIs(t, err, db.ErrAccountNotFound) +} diff --git a/wallet/internal/db/kvdb/accountstore_getaccountsecret.go b/wallet/internal/db/kvdb/accountstore_getaccountsecret.go new file mode 100644 index 0000000000..dbb4317ef0 --- /dev/null +++ b/wallet/internal/db/kvdb/accountstore_getaccountsecret.go @@ -0,0 +1,20 @@ +package kvdb + +import ( + "context" + + "github.com/btcsuite/btcwallet/wallet/internal/db" +) + +// GetAccountSecret reports that kvdb account secrets are not exposed through +// the store-side account-secret contract. +func (s *Store) GetAccountSecret(_ context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) { + + err := query.Validate() + if err != nil { + return nil, err + } + + return nil, db.ErrAccountSecretUnavailable +} diff --git a/wallet/internal/db/kvdb/vault.go b/wallet/internal/db/kvdb/vault.go new file mode 100644 index 0000000000..03e21ad0b0 --- /dev/null +++ b/wallet/internal/db/kvdb/vault.go @@ -0,0 +1,121 @@ +package kvdb + +import ( + "context" + "fmt" + "time" + + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/internal/keyvault" + "github.com/btcsuite/btcwallet/walletdb" +) + +// LegacyManagerVault adapts the legacy address manager to keyvault.Vault. +type LegacyManagerVault struct { + db walletdb.DB + mgr *waddrmgr.Manager +} + +// Compile-time assertion that LegacyManagerVault satisfies keyvault.Vault. +var _ keyvault.Vault = (*LegacyManagerVault)(nil) + +// NewLegacyManagerVault creates a Vault backed by a legacy walletdb address +// manager. +func NewLegacyManagerVault(db walletdb.DB, + mgr *waddrmgr.Manager) *LegacyManagerVault { + + return &LegacyManagerVault{ + db: db, + mgr: mgr, + } +} + +// Unlock authenticates the private passphrase through the legacy address +// manager. +// +// The timeout is ignored: the legacy address manager has no auto-lock timer of +// its own, so the wallet controller keeps owning the auto-lock schedule. The +// vault only forwards the unlock to the underlying manager. +func (v *LegacyManagerVault) Unlock(ctx context.Context, passphrase []byte, + _ time.Duration) error { + + err := checkContext(ctx) + if err != nil { + return err + } + + err = walletdb.View(v.db, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgr.NamespaceKey) + if ns == nil { + return errMissingAddrmgrNamespace + } + + return v.mgr.Unlock(ns, passphrase) + }) + if err != nil { + return fmt.Errorf("view: %w", err) + } + + return nil +} + +// Lock clears any cached secret key material from the legacy address manager. +// +// Lock is idempotent: an already-locked manager returns waddrmgr.ErrLocked, +// which is swallowed. Any other failure is only logged because the +// keyvault.Vault contract gives Lock no way to surface an error. +func (v *LegacyManagerVault) Lock() { + err := v.mgr.Lock() + if err != nil && !waddrmgr.IsError(err, waddrmgr.ErrLocked) { + log.Errorf("LegacyManagerVault lock manager: %v", err) + } +} + +// IsLocked reports whether the legacy address manager is currently locked. +func (v *LegacyManagerVault) IsLocked() bool { + return v.mgr.IsLocked() +} + +// Encrypt encrypts plaintext key material through the legacy address manager. +func (v *LegacyManagerVault) Encrypt(keyType waddrmgr.CryptoKeyType, + plaintext []byte) ([]byte, error) { + + ciphertext, err := v.mgr.Encrypt(keyType, plaintext) + if err != nil { + return nil, fmt.Errorf("encrypt: %w", err) + } + + return ciphertext, nil +} + +// Decrypt decrypts ciphertext key material through the legacy address manager. +func (v *LegacyManagerVault) Decrypt(keyType waddrmgr.CryptoKeyType, + ciphertext []byte) ([]byte, error) { + + plaintext, err := v.mgr.Decrypt(keyType, ciphertext) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + + return plaintext, nil +} + +// RefreshPrivatePassphrase is a no-op for the legacy address manager. +// +// The legacy manager rotates its in-memory crypto state in place while it +// applies a private passphrase change, so there is no separate vault-owned +// runtime state left to refresh afterwards. +func (v *LegacyManagerVault) RefreshPrivatePassphrase(_ []byte) error { + return nil +} + +// checkContext returns ctx.Err when the context is already canceled. +func checkContext(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + + default: + return nil + } +} diff --git a/wallet/internal/db/pg/accountstore_getaccountsecret.go b/wallet/internal/db/pg/accountstore_getaccountsecret.go new file mode 100644 index 0000000000..ac71f1f9fd --- /dev/null +++ b/wallet/internal/db/pg/accountstore_getaccountsecret.go @@ -0,0 +1,115 @@ +package pg + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/pg/sqlc" +) + +// GetAccountSecret retrieves encrypted account-level signing material for one +// account. +func (s *Store) GetAccountSecret(ctx context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) { + + err := query.Validate() + if err != nil { + return nil, err + } + + var secret *db.AccountSecret + + err = s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetAccountSecret(ctx, sqlc.GetAccountSecretParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), + AccountName: db.NullableStringToSQLNullString(query.Name), + }) + if err != nil { + return mapGetAccountSecretErr(err, query) + } + + secret, err = accountSecretRowToInfo(row) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil +} + +// accountSecretRowToInfo converts a PostgreSQL account-secret row to the +// backend-independent AccountSecret shape. +func accountSecretRowToInfo( + row sqlc.GetAccountSecretRow) (*db.AccountSecret, error) { + + walletID, err := db.Int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("wallet ID: %w", err) + } + + purpose, err := db.Int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("scope purpose: %w", err) + } + + coin, err := db.Int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("scope coin type: %w", err) + } + + var accountNumber uint32 + if row.AccountNumber.Valid { + accountNumber, err = db.Int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + var masterFingerprint uint32 + if row.MasterFingerprint.Valid { + masterFingerprint, err = db.Int64ToUint32( + row.MasterFingerprint.Int64, + ) + if err != nil { + return nil, fmt.Errorf("master fingerprint: %w", err) + } + } + + return &db.AccountSecret{ + WalletID: walletID, + Scope: db.KeyScope{Purpose: purpose, Coin: coin}, + AccountNumber: accountNumber, + AccountName: row.AccountName, + PublicKey: row.PublicKey, + EncryptedPrivateKey: row.EncryptedPrivateKey, + MasterKeyFingerprint: masterFingerprint, + }, nil +} + +// mapGetAccountSecretErr returns the typed ErrAccountNotFound when err is +// sql.ErrNoRows, falling back to a wrapped form otherwise. +func mapGetAccountSecretErr(err error, + query db.GetAccountSecretQuery) error { + + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get account secret: %w", err) + } + + if query.Name != nil { + return fmt.Errorf("account %q in scope %d/%d: %w", *query.Name, + query.Scope.Purpose, query.Scope.Coin, + db.ErrAccountNotFound) + } + + return fmt.Errorf("account %d in scope %d/%d: %w", + *query.AccountNumber, query.Scope.Purpose, query.Scope.Coin, + db.ErrAccountNotFound) +} diff --git a/wallet/internal/db/sqlite/accountstore_getaccountsecret.go b/wallet/internal/db/sqlite/accountstore_getaccountsecret.go new file mode 100644 index 0000000000..3b26cb9a82 --- /dev/null +++ b/wallet/internal/db/sqlite/accountstore_getaccountsecret.go @@ -0,0 +1,115 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/sql/sqlite/sqlc" +) + +// GetAccountSecret retrieves encrypted account-level signing material for one +// account. +func (s *Store) GetAccountSecret(ctx context.Context, + query db.GetAccountSecretQuery) (*db.AccountSecret, error) { + + err := query.Validate() + if err != nil { + return nil, err + } + + var secret *db.AccountSecret + + err = s.execRead(ctx, func(q *sqlc.Queries) error { + row, err := q.GetAccountSecret(ctx, sqlc.GetAccountSecretParams{ + WalletID: int64(query.WalletID), + Purpose: int64(query.Scope.Purpose), + CoinType: int64(query.Scope.Coin), + AccountNumber: db.NullableUint32ToSQLInt64(query.AccountNumber), + AccountName: db.NullableStringToSQLNullString(query.Name), + }) + if err != nil { + return mapGetAccountSecretErr(err, query) + } + + secret, err = accountSecretRowToInfo(row) + + return err + }) + if err != nil { + return nil, err + } + + return secret, nil +} + +// accountSecretRowToInfo converts a SQLite account-secret row to the +// backend-independent AccountSecret shape. +func accountSecretRowToInfo( + row sqlc.GetAccountSecretRow) (*db.AccountSecret, error) { + + walletID, err := db.Int64ToUint32(row.WalletID) + if err != nil { + return nil, fmt.Errorf("wallet ID: %w", err) + } + + purpose, err := db.Int64ToUint32(row.Purpose) + if err != nil { + return nil, fmt.Errorf("scope purpose: %w", err) + } + + coin, err := db.Int64ToUint32(row.CoinType) + if err != nil { + return nil, fmt.Errorf("scope coin type: %w", err) + } + + var accountNumber uint32 + if row.AccountNumber.Valid { + accountNumber, err = db.Int64ToUint32(row.AccountNumber.Int64) + if err != nil { + return nil, fmt.Errorf("account number: %w", err) + } + } + + var masterFingerprint uint32 + if row.MasterFingerprint.Valid { + masterFingerprint, err = db.Int64ToUint32( + row.MasterFingerprint.Int64, + ) + if err != nil { + return nil, fmt.Errorf("master fingerprint: %w", err) + } + } + + return &db.AccountSecret{ + WalletID: walletID, + Scope: db.KeyScope{Purpose: purpose, Coin: coin}, + AccountNumber: accountNumber, + AccountName: row.AccountName, + PublicKey: row.PublicKey, + EncryptedPrivateKey: row.EncryptedPrivateKey, + MasterKeyFingerprint: masterFingerprint, + }, nil +} + +// mapGetAccountSecretErr returns the typed ErrAccountNotFound when err is +// sql.ErrNoRows, falling back to a wrapped form otherwise. +func mapGetAccountSecretErr(err error, + query db.GetAccountSecretQuery) error { + + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get account secret: %w", err) + } + + if query.Name != nil { + return fmt.Errorf("account %q in scope %d/%d: %w", *query.Name, + query.Scope.Purpose, query.Scope.Coin, + db.ErrAccountNotFound) + } + + return fmt.Errorf("account %d in scope %d/%d: %w", + *query.AccountNumber, query.Scope.Purpose, query.Scope.Coin, + db.ErrAccountNotFound) +} diff --git a/wallet/internal/sql/pg/queries/accounts.sql b/wallet/internal/sql/pg/queries/accounts.sql index a859e7298e..71560ce6a8 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -52,6 +52,37 @@ INSERT INTO account_secrets ( $1, $2 ); +-- name: GetAccountSecret :one +-- Returns account-level key material for signing. The account row is returned +-- even when no account_secrets row exists so callers can distinguish a +-- watch-only account from an absent account. +SELECT + a.wallet_id, + ks.purpose, + ks.coin_type, + a.account_number, + a.account_name, + a.public_key, + acs.encrypted_private_key, + a.master_fingerprint +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN account_secrets AS acs ON a.id = acs.account_id +WHERE + a.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND ( + ( + sqlc.narg('account_number')::BIGINT IS NOT NULL + AND a.account_number = sqlc.narg('account_number')::BIGINT + ) + OR ( + sqlc.narg('account_name')::TEXT IS NOT NULL + AND a.account_name = sqlc.narg('account_name')::TEXT + ) + ); + -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT diff --git a/wallet/internal/sql/pg/sqlc/accounts.sql.go b/wallet/internal/sql/pg/sqlc/accounts.sql.go index 505d44fbf9..ef0d78d878 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -741,6 +741,79 @@ func (q *Queries) GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccou return i, err } +const GetAccountSecret = `-- name: GetAccountSecret :one +SELECT + a.wallet_id, + ks.purpose, + ks.coin_type, + a.account_number, + a.account_name, + a.public_key, + acs.encrypted_private_key, + a.master_fingerprint +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN account_secrets AS acs ON a.id = acs.account_id +WHERE + a.wallet_id = $1 + AND ks.purpose = $2 + AND ks.coin_type = $3 + AND ( + ( + $4::BIGINT IS NOT NULL + AND a.account_number = $4::BIGINT + ) + OR ( + $5::TEXT IS NOT NULL + AND a.account_name = $5::TEXT + ) + ) +` + +type GetAccountSecretParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 + AccountName sql.NullString +} + +type GetAccountSecretRow struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + PublicKey []byte + EncryptedPrivateKey []byte + MasterFingerprint sql.NullInt64 +} + +// Returns account-level key material for signing. The account row is returned +// even when no account_secrets row exists so callers can distinguish a +// watch-only account from an absent account. +func (q *Queries) GetAccountSecret(ctx context.Context, arg GetAccountSecretParams) (GetAccountSecretRow, error) { + row := q.queryRow(ctx, q.getAccountSecretStmt, GetAccountSecret, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + arg.AccountName, + ) + var i GetAccountSecretRow + err := row.Scan( + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.AccountNumber, + &i.AccountName, + &i.PublicKey, + &i.EncryptedPrivateKey, + &i.MasterFingerprint, + ) + return i, err +} + const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one UPDATE accounts SET next_external_index = next_external_index + 1 diff --git a/wallet/internal/sql/pg/sqlc/db.go b/wallet/internal/sql/pg/sqlc/db.go index bc48371515..c7f08602b2 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByWalletAndIdStmt, err = db.PrepareContext(ctx, GetAccountPropsByWalletAndId); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsByWalletAndId: %w", err) } + if q.getAccountSecretStmt, err = db.PrepareContext(ctx, GetAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountSecret: %w", err) + } if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) } @@ -448,6 +451,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByWalletAndIdStmt: %w", cerr) } } + if q.getAccountSecretStmt != nil { + if cerr := q.getAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountSecretStmt: %w", cerr) + } + } if q.getActiveUtxoLeaseLockIDStmt != nil { if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) @@ -840,6 +848,7 @@ type Queries struct { getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt getAccountPropsByWalletAndIdStmt *sql.Stmt + getAccountSecretStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -939,6 +948,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, getAccountPropsByWalletAndIdStmt: q.getAccountPropsByWalletAndIdStmt, + getAccountSecretStmt: q.getAccountSecretStmt, getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, diff --git a/wallet/internal/sql/pg/sqlc/querier.go b/wallet/internal/sql/pg/sqlc/querier.go index 8f23c27125..1d9f3dc2cb 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -155,6 +155,10 @@ type Querier interface { GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns full account properties by wallet id and account id. GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) + // Returns account-level key material for signing. The account row is returned + // even when no account_secrets row exists so callers can distinguish a + // watch-only account from an absent account. + GetAccountSecret(ctx context.Context, arg GetAccountSecretParams) (GetAccountSecretRow, error) // Returns the lock ID for the current active lease on a UTXO ID. // // How: diff --git a/wallet/internal/sql/sqlite/queries/accounts.sql b/wallet/internal/sql/sqlite/queries/accounts.sql index 4c7c9f0041..5a104e304b 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -52,6 +52,39 @@ INSERT INTO account_secrets ( ?, ? ); +-- name: GetAccountSecret :one +-- Returns account-level key material for signing. The account row is returned +-- even when no account_secrets row exists so callers can distinguish a +-- watch-only account from an absent account. +SELECT + a.wallet_id, + ks.purpose, + ks.coin_type, + a.account_number, + a.account_name, + a.public_key, + acs.encrypted_private_key, + a.master_fingerprint +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN account_secrets AS acs ON a.id = acs.account_id +WHERE + a.wallet_id = sqlc.arg('wallet_id') + AND ks.purpose = sqlc.arg('purpose') + AND ks.coin_type = sqlc.arg('coin_type') + AND ( + ( + cast(sqlc.narg('account_number') AS INTEGER) IS NOT NULL + AND a.account_number = cast( + sqlc.narg('account_number') AS INTEGER + ) + ) + OR ( + cast(sqlc.narg('account_name') AS TEXT) IS NOT NULL + AND a.account_name = cast(sqlc.narg('account_name') AS TEXT) + ) + ); + -- name: GetAccountByScopeAndName :one -- Returns a single account by scope id and account name. SELECT diff --git a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go index 6c31c39895..33bfa4a42a 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -748,6 +748,81 @@ func (q *Queries) GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccou return i, err } +const GetAccountSecret = `-- name: GetAccountSecret :one +SELECT + a.wallet_id, + ks.purpose, + ks.coin_type, + a.account_number, + a.account_name, + a.public_key, + acs.encrypted_private_key, + a.master_fingerprint +FROM accounts AS a +INNER JOIN key_scopes AS ks ON a.scope_id = ks.id +LEFT JOIN account_secrets AS acs ON a.id = acs.account_id +WHERE + a.wallet_id = ?1 + AND ks.purpose = ?2 + AND ks.coin_type = ?3 + AND ( + ( + cast(?4 AS INTEGER) IS NOT NULL + AND a.account_number = cast( + ?4 AS INTEGER + ) + ) + OR ( + cast(?5 AS TEXT) IS NOT NULL + AND a.account_name = cast(?5 AS TEXT) + ) + ) +` + +type GetAccountSecretParams struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 + AccountName sql.NullString +} + +type GetAccountSecretRow struct { + WalletID int64 + Purpose int64 + CoinType int64 + AccountNumber sql.NullInt64 + AccountName string + PublicKey []byte + EncryptedPrivateKey []byte + MasterFingerprint sql.NullInt64 +} + +// Returns account-level key material for signing. The account row is returned +// even when no account_secrets row exists so callers can distinguish a +// watch-only account from an absent account. +func (q *Queries) GetAccountSecret(ctx context.Context, arg GetAccountSecretParams) (GetAccountSecretRow, error) { + row := q.queryRow(ctx, q.getAccountSecretStmt, GetAccountSecret, + arg.WalletID, + arg.Purpose, + arg.CoinType, + arg.AccountNumber, + arg.AccountName, + ) + var i GetAccountSecretRow + err := row.Scan( + &i.WalletID, + &i.Purpose, + &i.CoinType, + &i.AccountNumber, + &i.AccountName, + &i.PublicKey, + &i.EncryptedPrivateKey, + &i.MasterFingerprint, + ) + return i, err +} + const GetAndIncrementNextExternalIndex = `-- name: GetAndIncrementNextExternalIndex :one UPDATE accounts SET next_external_index = next_external_index + 1 diff --git a/wallet/internal/sql/sqlite/sqlc/db.go b/wallet/internal/sql/sqlite/sqlc/db.go index bc48371515..c7f08602b2 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -108,6 +108,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByWalletAndIdStmt, err = db.PrepareContext(ctx, GetAccountPropsByWalletAndId); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsByWalletAndId: %w", err) } + if q.getAccountSecretStmt, err = db.PrepareContext(ctx, GetAccountSecret); err != nil { + return nil, fmt.Errorf("error preparing query GetAccountSecret: %w", err) + } if q.getActiveUtxoLeaseLockIDStmt, err = db.PrepareContext(ctx, GetActiveUtxoLeaseLockID); err != nil { return nil, fmt.Errorf("error preparing query GetActiveUtxoLeaseLockID: %w", err) } @@ -448,6 +451,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByWalletAndIdStmt: %w", cerr) } } + if q.getAccountSecretStmt != nil { + if cerr := q.getAccountSecretStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAccountSecretStmt: %w", cerr) + } + } if q.getActiveUtxoLeaseLockIDStmt != nil { if cerr := q.getActiveUtxoLeaseLockIDStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getActiveUtxoLeaseLockIDStmt: %w", cerr) @@ -840,6 +848,7 @@ type Queries struct { getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt getAccountPropsByWalletAndIdStmt *sql.Stmt + getAccountSecretStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -939,6 +948,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, getAccountPropsByWalletAndIdStmt: q.getAccountPropsByWalletAndIdStmt, + getAccountSecretStmt: q.getAccountSecretStmt, getActiveUtxoLeaseLockIDStmt: q.getActiveUtxoLeaseLockIDStmt, getAddressByScriptPubKeyStmt: q.getAddressByScriptPubKeyStmt, getAddressSecretStmt: q.getAddressSecretStmt, diff --git a/wallet/internal/sql/sqlite/sqlc/querier.go b/wallet/internal/sql/sqlite/sqlc/querier.go index 5525a73556..638cf1a9e6 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -152,6 +152,10 @@ type Querier interface { GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, error) // Returns full account properties by wallet id and account id. GetAccountPropsByWalletAndId(ctx context.Context, arg GetAccountPropsByWalletAndIdParams) (GetAccountPropsByWalletAndIdRow, error) + // Returns account-level key material for signing. The account row is returned + // even when no account_secrets row exists so callers can distinguish a + // watch-only account from an absent account. + GetAccountSecret(ctx context.Context, arg GetAccountSecretParams) (GetAccountSecretRow, error) // Returns the lock ID for the current active lease on a UTXO ID. // // How: diff --git a/wallet/manager.go b/wallet/manager.go index 26624f84fb..8a10bd521f 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -12,7 +12,6 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/internal/db" kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" - "github.com/btcsuite/btcwallet/wallet/internal/keyvault" ) var ( @@ -324,6 +323,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { } store := kvdb.NewStore(cfg.DB, txMgr, addrMgr) + vault := kvdb.NewLegacyManagerVault(cfg.DB, addrMgr) // Cache the wallet's master HD fingerprint up-front, before any // context/cancel is set up so an error here doesn't leak a @@ -345,7 +345,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { addrStore: addrMgr, store: store, cache: newStoreRuntimeCache(store), - keyVault: keyvault.NewDBVault(store, walletID), + keyVault: vault, txStore: txMgr, requestChan: make(chan any), lifetimeCtx: lifetimeCtx, diff --git a/wallet/psbt_manager.go b/wallet/psbt_manager.go index 6c50f6f743..bca5775a25 100644 --- a/wallet/psbt_manager.go +++ b/wallet/psbt_manager.go @@ -1052,9 +1052,16 @@ func (w *Wallet) parseBip32Path(path []uint32) (BIP32Path, error) { bip32Path := BIP32Path{ KeyScope: scope, DerivationPath: waddrmgr.DerivationPath{ - Account: account, - Branch: branch, - Index: index, + // InternalAccount is the wallet's database account + // number that both the legacy DeriveFromKeyPath and + // the SQL account-secret lookup key on. Leaving it + // unset always resolves account 0, so a PSBT for a + // non-zero account would otherwise sign with the wrong + // key or fail to find one. + InternalAccount: account, + Account: account, + Branch: branch, + Index: index, }, } diff --git a/wallet/psbt_manager_test.go b/wallet/psbt_manager_test.go index 82ba159f7a..b63b4e372d 100644 --- a/wallet/psbt_manager_test.go +++ b/wallet/psbt_manager_test.go @@ -1562,9 +1562,10 @@ func TestParseBip32Path(t *testing.T) { wantPath: BIP32Path{ KeyScope: waddrmgr.KeyScopeBIP0044, DerivationPath: waddrmgr.DerivationPath{ - Account: 0, - Branch: 0, - Index: 0, + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, }, }, }, @@ -1576,9 +1577,10 @@ func TestParseBip32Path(t *testing.T) { wantPath: BIP32Path{ KeyScope: waddrmgr.KeyScopeBIP0084, DerivationPath: waddrmgr.DerivationPath{ - Account: 1, - Branch: 0, - Index: 5, + InternalAccount: 1, + Account: 1, + Branch: 0, + Index: 5, }, }, }, @@ -1625,9 +1627,10 @@ func TestParseBip32Path(t *testing.T) { Purpose: 999, Coin: 0, }, DerivationPath: waddrmgr.DerivationPath{ - Account: 0, - Branch: 0, - Index: 0, + InternalAccount: 0, + Account: 0, + Branch: 0, + Index: 0, }, }, expectedErr: nil, @@ -2821,6 +2824,83 @@ func TestSignPsbt(t *testing.T) { require.Len(t, packet.Inputs[0].PartialSigs, 1) } +// TestSignPsbtResolvesNonZeroInternalAccount verifies that signing a PSBT whose +// BIP32 derivation path names a non-zero account threads that account into the +// resolved DerivationPath.InternalAccount. The store account-secret lookup and +// the legacy DeriveFromKeyPath both key on InternalAccount, so a path that +// leaves it unset would silently resolve account 0 and sign with the wrong key. +func TestSignPsbtResolvesNonZeroInternalAccount(t *testing.T) { + t.Parallel() + + // Arrange: a private key whose P2WKH output the PSBT will spend. + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pubKeyBytes := privKey.PubKey().SerializeCompressed() + + // Arrange: a derivation path naming a non-zero account (account 2). + // CoinType is 1 to match the RegressionNet test params. + const wantAccount = 2 + + derivationPath := []uint32{ + hdkeychain.HardenedKeyStart + 84, + hdkeychain.HardenedKeyStart + 1, + hdkeychain.HardenedKeyStart + wantAccount, + 0, 0, + } + derivation := &psbt.Bip32Derivation{ + PubKey: pubKeyBytes, + Bip32Path: derivationPath, + } + + p2wkhAddr, err := address.NewAddressWitnessPubKeyHash( + address.Hash160(pubKeyBytes), &chainParams, + ) + require.NoError(t, err) + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + utxo := &wire.TxOut{Value: 1000, PkScript: p2wkhScript} + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{}}) + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + + packet.Inputs[0].WitnessUtxo = utxo + packet.Inputs[0].Bip32Derivation = []*psbt.Bip32Derivation{derivation} + packet.Inputs[0].SighashType = txscript.SigHashAll + + signParams := &SignPsbtParams{Packet: packet} + w, mocks := createUnlockedWalletWithMocks(t) + + // Assert (via matcher): the DerivationPath handed to the legacy + // manager carries the non-zero account in InternalAccount. This is the + // value both the legacy and SQL derivation paths key on. + wantInternalAccount := mock.MatchedBy( + func(kp waddrmgr.DerivationPath) bool { + return kp.InternalAccount == wantAccount + }, + ) + mocks.addrStore.On("FetchScopedKeyManager", mock.Anything). + Return(mocks.accountManager, nil) + mocks.accountManager.On( + "DeriveFromKeyPath", mock.Anything, wantInternalAccount, + ).Return(mocks.pubKeyAddr, nil) + mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil) + + // Act. + result, err := w.SignPsbt(t.Context(), signParams) + + // Assert: signing succeeded for the input, which only happens when the + // matcher above accepted the resolved InternalAccount. + require.NoError(t, err) + require.Len(t, result.SignedInputs, 1) + require.Equal(t, uint32(0), result.SignedInputs[0]) + require.Len(t, packet.Inputs[0].PartialSigs, 1) +} + // TestSignPsbtInputsNotReady tests that SignPsbt fails if inputs are not ready // (missing WitnessUtxo/NonWitnessUtxo). func TestSignPsbtInputsNotReady(t *testing.T) { diff --git a/wallet/signer.go b/wallet/signer.go index 4bbb33b34e..04dd02dd5d 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -13,8 +13,10 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btcwallet/internal/zero" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet/internal/db" + "github.com/btcsuite/btcwallet/wallet/internal/keyvault" "github.com/btcsuite/btcwallet/walletdb" ) @@ -34,6 +36,14 @@ var ( // ErrInvalidSignParam is returned when the parameters for the signing // operation are invalid. ErrInvalidSignParam = errors.New("invalid signing parameters") + + // ErrWatchOnlyAccount is returned when account metadata exists but has no + // private key material available for signing. + ErrWatchOnlyAccount = errors.New("account is watch-only") + + // ErrAccountNotInStore is returned when neither legacy waddrmgr nor the + // durable store can resolve the signing account. + ErrAccountNotInStore = errors.New("account not in store") ) // Signer provides an interface for common, safe cryptographic operations, @@ -460,7 +470,7 @@ var _ SpendDetails = (*SegwitV0SpendDetails)(nil) var _ SpendDetails = (*TaprootSpendDetails)(nil) // DerivePubKey derives a public key from a full BIP-32 derivation path. -func (w *Wallet) DerivePubKey(_ context.Context, path BIP32Path) ( +func (w *Wallet) DerivePubKey(ctx context.Context, path BIP32Path) ( *btcec.PublicKey, error) { err := w.state.validateStarted() @@ -469,11 +479,32 @@ func (w *Wallet) DerivePubKey(_ context.Context, path BIP32Path) ( } managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) - if err != nil { + switch { + case err == nil: + return managedPubKeyAddr.PubKey(), nil + + // SQL-only accounts have no mirrored legacy waddrmgr scope/account, so + // the lookup above misses. Mirror derivePathPrivKey's fallback and + // resolve the public key from the account-level extended public key in + // the store. Unlike the private-key fallback this works for watch-only + // accounts too, since it never needs the encrypted private material. + case isWaddrmgrAccountClassError( + err, waddrmgr.ErrScopeNotFound, waddrmgr.ErrAccountNotFound, + ): + + pubKey, storeErr := w.resolveDerivedPubKeyFromStore( + ctx, path.KeyScope, path.DerivationPath, + ) + if storeErr != nil { + return nil, fmt.Errorf("store account fallback after "+ + "legacy address miss: %w: %w", err, storeErr) + } + + return pubKey, nil + + default: return nil, err } - - return managedPubKeyAddr.PubKey(), nil } // fetchManagedPubKeyAddress is a helper function that encapsulates the common @@ -542,10 +573,54 @@ func (w *Wallet) fetchManagedPubKeyAddress(path BIP32Path) ( return managedPubKeyAddr, nil } +// derivePathPrivKey resolves the signing private key for a full BIP-32 path. +// +// It first walks the legacy waddrmgr-backed managed-address lookup, which is +// the fast path for accounts mirrored into waddrmgr. When that lookup misses +// because the account or its scope only lives in the SQL store, it falls back +// to the account-level encrypted secret resolved through keyVault. The +// fallback is gated on a waddrmgr account/scope miss so legacy-backed accounts +// keep their existing behavior and only genuine store-only accounts take the +// slower path. +// +// The returned private key is owned by the caller, who is responsible for +// zeroing it once signing completes. +func (w *Wallet) derivePathPrivKey(ctx context.Context, path BIP32Path) ( + *btcec.PrivateKey, error) { + + managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + switch { + case err == nil: + privKey, err := managedPubKeyAddr.PrivKey() + if err != nil { + return nil, fmt.Errorf("cannot get private key: %w", err) + } + + return privKey, nil + + case isWaddrmgrAccountClassError( + err, waddrmgr.ErrScopeNotFound, waddrmgr.ErrAccountNotFound, + ): + + privKey, storeErr := w.resolveDerivedPrivKeyFromStore( + ctx, path.KeyScope, path.DerivationPath, + ) + if storeErr != nil { + return nil, fmt.Errorf("store account fallback after "+ + "legacy address miss: %w: %w", err, storeErr) + } + + return privKey, nil + + default: + return nil, err + } +} + // ECDH performs a scalar multiplication (ECDH-like operation) between a key // from the wallet and a remote public key. The output returned will be the // sha256 of the resulting shared point serialized in compressed format. -func (w *Wallet) ECDH(_ context.Context, path BIP32Path, +func (w *Wallet) ECDH(ctx context.Context, path BIP32Path, pub *btcec.PublicKey) ([32]byte, error) { err := w.state.canSign() @@ -553,17 +628,10 @@ func (w *Wallet) ECDH(_ context.Context, path BIP32Path, return [32]byte{}, err } - managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + privKey, err := w.derivePathPrivKey(ctx, path) if err != nil { return [32]byte{}, err } - - // Get the private key for the derived address. - privKey, err := managedPubKeyAddr.PrivKey() - if err != nil { - return [32]byte{}, fmt.Errorf("cannot get private key: %w", - err) - } defer privKey.Zero() // Perform the scalar multiplication and hash the result. @@ -601,7 +669,7 @@ func validateSignDigestIntent(intent *SignDigestIntent) error { } // SignDigest signs a message digest based on the provided intent. -func (w *Wallet) SignDigest(_ context.Context, path BIP32Path, +func (w *Wallet) SignDigest(ctx context.Context, path BIP32Path, intent *SignDigestIntent) (Signature, error) { err := w.state.canSign() @@ -614,16 +682,10 @@ func (w *Wallet) SignDigest(_ context.Context, path BIP32Path, return nil, err } - managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + privKey, err := w.derivePathPrivKey(ctx, path) if err != nil { return nil, err } - - // Get the private key for the derived address. - privKey, err := managedPubKeyAddr.PrivKey() - if err != nil { - return nil, fmt.Errorf("cannot get private key: %w", err) - } defer privKey.Zero() // Now, sign the message using the derived private key. This is all @@ -698,7 +760,7 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, return nil, err } - privKey, err := w.privKeyForOutput(scriptInfo) + privKey, err := w.privKeyForOutput(ctx, scriptInfo) if err != nil { return nil, err } @@ -720,11 +782,12 @@ func (w *Wallet) ComputeUnlockingScript(ctx context.Context, // privKeyForOutput returns the private key needed to sign for the given // wallet-controlled output. -func (w *Wallet) privKeyForOutput(scriptInfo OutputScriptInfo) ( +func (w *Wallet) privKeyForOutput(ctx context.Context, + scriptInfo OutputScriptInfo) ( *btcec.PrivateKey, error) { if canUseAddressInfoDerivation(scriptInfo.AddressInfo) { - return w.privKeyForAddressInfo(scriptInfo.AddressInfo) + return w.privKeyForAddressInfo(ctx, scriptInfo.AddressInfo) } pubKeyAddr, err := w.loadManagedPubKeyAddr(scriptInfo.Addr) @@ -732,7 +795,7 @@ func (w *Wallet) privKeyForOutput(scriptInfo OutputScriptInfo) ( return nil, err } - return w.resolvePrivKey(pubKeyAddr) + return w.resolvePrivKey(ctx, pubKeyAddr) } // canUseAddressInfoDerivation reports whether address metadata contains enough @@ -747,7 +810,8 @@ func canUseAddressInfoDerivation(addressInfo AddressInfo) bool { // privKeyForAddressInfo derives the private key described by store-backed // address metadata. -func (w *Wallet) privKeyForAddressInfo(addressInfo AddressInfo) ( +func (w *Wallet) privKeyForAddressInfo(ctx context.Context, + addressInfo AddressInfo) ( *btcec.PrivateKey, error) { derivation := addressInfo.Derivation @@ -764,7 +828,9 @@ func (w *Wallet) privKeyForAddressInfo(addressInfo AddressInfo) ( MasterKeyFingerprint: derivation.MasterKeyFingerprint, } - return w.resolveDerivedPathPrivKey(derivation.KeyScope, derivationPath) + return w.resolveDerivedPathPrivKey( + ctx, derivation.KeyScope, derivationPath, + ) } // loadManagedPubKeyAddr loads a managed pubkey address for signer-private key @@ -801,7 +867,8 @@ func (w *Wallet) loadManagedPubKeyAddr(addr address.Address) ( // resolvePrivKey resolves the private key for a managed pubkey address without // using output-script inspection as the private-key lookup seam. -func (w *Wallet) resolvePrivKey(pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( +func (w *Wallet) resolvePrivKey(ctx context.Context, + pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( *btcec.PrivateKey, error) { // Imported spendable keys have no derivation path, so we fall back to the @@ -821,25 +888,39 @@ func (w *Wallet) resolvePrivKey(pubKeyAddr waddrmgr.ManagedPubKeyAddress) ( pubKeyAddr.Address()) } - return w.resolveDerivedPathPrivKey(keyScope, derivationPath) + return w.resolveDerivedPathPrivKey(ctx, keyScope, derivationPath) } // resolveDerivedPathPrivKey resolves one derived private key through the scoped // manager cache or the database-backed fallback. -func (w *Wallet) resolveDerivedPathPrivKey(keyScope waddrmgr.KeyScope, +func (w *Wallet) resolveDerivedPathPrivKey(ctx context.Context, + keyScope waddrmgr.KeyScope, derivationPath waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { - // TODO(yy): SQL-only accounts (created via Store.CreateDerivedAccount - // without a mirrored legacy waddrmgr account) miss both - // DeriveFromKeyPathCache and the DB-backed DeriveFromKeyPath fallback - // below because the legacy waddrmgr has no row for them. The - // signer-store PR (impl-tx-creator-store) will replace this path for - // SQL-only accounts with a keyVault-backed derivation: fetch - // account_secrets.encrypted_priv_key, decrypt via w.keyVault, and - // derive at branch/index locally — symmetric to deriveAddressData's - // AccountPubKey plumbing on the public-key side. + // SQL-only accounts (created via Store.CreateDerivedAccount without a + // mirrored legacy waddrmgr account) miss both DeriveFromKeyPathCache + // and the DB-backed DeriveFromKeyPath fallback below because the legacy + // waddrmgr has no row for them. Each of those misses therefore falls + // through to resolveDerivedPrivKeyFromStore, which fetches + // account_secrets.encrypted_priv_key, decrypts it via w.keyVault, and + // derives at branch/index locally. accountManager, err := w.addrStore.FetchScopedKeyManager(keyScope) if err != nil { + if isWaddrmgrAccountClassError( + err, waddrmgr.ErrScopeNotFound, waddrmgr.ErrAccountNotFound, + ) { + + privKey, storeErr := w.resolveDerivedPrivKeyFromStore( + ctx, keyScope, derivationPath, + ) + if storeErr != nil { + return nil, fmt.Errorf("store account fallback after "+ + "legacy scope miss: %w: %w", err, storeErr) + } + + return privKey, nil + } + return nil, fmt.Errorf("fetch scoped key manager: %w", err) } @@ -851,11 +932,28 @@ func (w *Wallet) resolveDerivedPathPrivKey(keyScope waddrmgr.KeyScope, // Only a cold account cache warrants the slower DB-backed fallback. Other // derivation errors are real failures that re-running through the database // will not repair. - if !waddrmgr.IsError(err, waddrmgr.ErrAccountNotCached) { + if !isWaddrmgrAccountClassError(err, waddrmgr.ErrAccountNotCached) { return nil, fmt.Errorf("derive private key from cache: %w", err) } - return w.resolveDerivedPrivKey(accountManager, derivationPath) + privKey, err = w.resolveDerivedPrivKey(accountManager, derivationPath) + if err == nil { + return privKey, nil + } + + if !isWaddrmgrAccountClassError(err, waddrmgr.ErrAccountNotFound) { + return nil, err + } + + privKey, storeErr := w.resolveDerivedPrivKeyFromStore( + ctx, keyScope, derivationPath, + ) + if storeErr != nil { + return nil, fmt.Errorf("store account fallback after legacy "+ + "account miss: %w: %w", err, storeErr) + } + + return privKey, nil } // resolveDerivedPrivKey resolves one derived private key through the normal @@ -895,6 +993,177 @@ func (w *Wallet) resolveDerivedPrivKey(accountManager waddrmgr.AccountStore, return privKey, nil } +// resolveDerivedPrivKeyFromStore resolves one derived private key from the +// account-level encrypted secret stored behind the wallet store. +func (w *Wallet) resolveDerivedPrivKeyFromStore(ctx context.Context, + keyScope waddrmgr.KeyScope, + path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { + + if w.cache == nil { + return nil, fmt.Errorf("%w: cache", ErrMissingParam) + } + + secret, err := w.cache.GetAccountSecret(ctx, db.GetAccountSecretQuery{ + WalletID: w.id, + Scope: db.KeyScope(keyScope), + AccountNumber: &path.InternalAccount, + }) + switch { + case errors.Is(err, db.ErrAccountSecretUnavailable), + errors.Is(err, db.ErrAccountNotFound): + + return nil, ErrAccountNotInStore + + case err != nil: + return nil, fmt.Errorf("fetch account secret: %w", err) + } + + if len(secret.EncryptedPrivateKey) == 0 { + return nil, ErrWatchOnlyAccount + } + + if w.keyVault == nil { + return nil, fmt.Errorf("%w: keyVault", ErrMissingParam) + } + + return deriveStoredAccountChildKey( + w.keyVault, secret.EncryptedPrivateKey, path, + ) +} + +// deriveStoredAccountChildKey decrypts an account's encrypted private key with +// the wallet's keyVault and walks the branch and index derivation to produce +// the leaf private key. The decrypted byte slice and intermediate HD keys are +// zeroed before the call returns. Note that hdkeychain/base58 parsing allocates +// a transient immutable string copy of the decrypted bytes that cannot be +// wiped and is left to the garbage collector. +func deriveStoredAccountChildKey(vault keyvault.Vault, + encryptedAccountPriv []byte, + path waddrmgr.DerivationPath) (*btcec.PrivateKey, error) { + + plaintext, err := vault.Decrypt( + waddrmgr.CKTPrivate, encryptedAccountPriv, + ) + if err != nil { + return nil, fmt.Errorf("decrypt account priv: %w", err) + } + + acctPriv, err := hdkeychain.NewKeyFromString(string(plaintext)) + if err != nil { + zero.Bytes(plaintext) + return nil, fmt.Errorf("parse account priv: %w", err) + } + + zero.Bytes(plaintext) + + defer acctPriv.Zero() + + branchKey, err := deriveChildKey(acctPriv, path.Branch) + if err != nil { + return nil, fmt.Errorf("derive branch: %w", err) + } + defer branchKey.Zero() + + addrKey, err := deriveChildKey(branchKey, path.Index) + if err != nil { + return nil, fmt.Errorf("derive index: %w", err) + } + defer addrKey.Zero() + + privKey, err := addrKey.ECPrivKey() + if err != nil { + return nil, fmt.Errorf("derive private key: %w", err) + } + + return privKey, nil +} + +// resolveDerivedPubKeyFromStore resolves one derived public key from the +// account-level extended public key stored behind the wallet store. It is the +// public-key counterpart of resolveDerivedPrivKeyFromStore and, since the +// account xpub is stored in plaintext, it also serves watch-only accounts that +// hold no encrypted private material. +func (w *Wallet) resolveDerivedPubKeyFromStore(ctx context.Context, + keyScope waddrmgr.KeyScope, + path waddrmgr.DerivationPath) (*btcec.PublicKey, error) { + + if w.cache == nil { + return nil, fmt.Errorf("%w: cache", ErrMissingParam) + } + + secret, err := w.cache.GetAccountSecret(ctx, db.GetAccountSecretQuery{ + WalletID: w.id, + Scope: db.KeyScope(keyScope), + AccountNumber: &path.InternalAccount, + }) + switch { + case errors.Is(err, db.ErrAccountSecretUnavailable), + errors.Is(err, db.ErrAccountNotFound): + + return nil, ErrAccountNotInStore + + case err != nil: + return nil, fmt.Errorf("fetch account secret: %w", err) + } + + if len(secret.PublicKey) == 0 { + return nil, fmt.Errorf("%w: account public key", ErrMissingParam) + } + + return deriveStoredAccountChildPubKey(secret.PublicKey, path) +} + +// deriveStoredAccountChildPubKey parses an account-level extended public key +// and walks the branch and index derivation to produce the leaf public key. +// The intermediate HD keys are zeroed before the call returns. +func deriveStoredAccountChildPubKey(accountPubKey []byte, + path waddrmgr.DerivationPath) (*btcec.PublicKey, error) { + + acctPub, err := hdkeychain.NewKeyFromString(string(accountPubKey)) + if err != nil { + return nil, fmt.Errorf("parse account pub: %w", err) + } + defer acctPub.Zero() + + branchKey, err := deriveChildKey(acctPub, path.Branch) + if err != nil { + return nil, fmt.Errorf("derive branch: %w", err) + } + defer branchKey.Zero() + + addrKey, err := deriveChildKey(branchKey, path.Index) + if err != nil { + return nil, fmt.Errorf("derive index: %w", err) + } + defer addrKey.Zero() + + pubKey, err := addrKey.ECPubKey() + if err != nil { + return nil, fmt.Errorf("derive public key: %w", err) + } + + return pubKey, nil +} + +// isWaddrmgrAccountClassError reports whether err wraps a waddrmgr +// ManagerError whose code belongs to the supplied set. +func isWaddrmgrAccountClassError(err error, + codes ...waddrmgr.ErrorCode) bool { + + var mErr waddrmgr.ManagerError + if !errors.As(err, &mErr) { + return false + } + + for _, code := range codes { + if mErr.ErrorCode == code { + return true + } + } + + return false +} + // signAndAssembleScript is a helper function that performs the final signing // and script assembly for a given set of parameters and a private key. func signAndAssembleScript(params *UnlockingScriptParams, @@ -978,7 +1247,7 @@ func redeemSigScript(redeemScript []byte) ([]byte, error) { // ComputeRawSig generates a raw signature for a single transaction input. The // caller is responsible for assembling the final witness. -func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( +func (w *Wallet) ComputeRawSig(ctx context.Context, params *RawSigParams) ( RawSignature, error) { err := w.state.canSign() @@ -986,18 +1255,10 @@ func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( return nil, err } - // Get the managed address for the specified derivation path. This will - // be used to retrieve the private key. - managedAddr, err := w.fetchManagedPubKeyAddress(params.Path) + privKey, err := w.derivePathPrivKey(ctx, params.Path) if err != nil { return nil, err } - - // Get the private key for the address. - privKey, err := managedAddr.PrivKey() - if err != nil { - return nil, fmt.Errorf("cannot get private key: %w", err) - } defer privKey.Zero() // If a tweaker is provided, we'll use it to tweak the private key. @@ -1023,7 +1284,7 @@ func (w *Wallet) ComputeRawSig(_ context.Context, params *RawSigParams) ( // path. // // DANGER: This method exports sensitive key material. -func (w *Wallet) DerivePrivKey(_ context.Context, path BIP32Path) ( +func (w *Wallet) DerivePrivKey(ctx context.Context, path BIP32Path) ( *btcec.PrivateKey, error) { err := w.state.canSign() @@ -1031,17 +1292,7 @@ func (w *Wallet) DerivePrivKey(_ context.Context, path BIP32Path) ( return nil, err } - managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) - if err != nil { - return nil, err - } - - privKey, err := managedPubKeyAddr.PrivKey() - if err != nil { - return nil, fmt.Errorf("cannot get private key: %w", err) - } - - return privKey, nil + return w.derivePathPrivKey(ctx, path) } // GetPrivKeyForAddress returns the private key for a given address. @@ -1064,7 +1315,7 @@ func (w *Wallet) GetPrivKeyForAddress(ctx context.Context, a address.Address) ( info, err := w.GetAddressInfo(ctx, a) switch { case err == nil && canUseAddressInfoDerivation(info): - return w.privKeyForAddressInfo(info) + return w.privKeyForAddressInfo(ctx, info) case err == nil: // Store record exists but no usable derivation info diff --git a/wallet/signer_test.go b/wallet/signer_test.go index bc7ee98291..da772393d9 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -948,6 +948,52 @@ func TestComputeUnlockingScriptSQLDerivedAddress(t *testing.T) { require.NoError(t, vm.Execute(), "script execution failed") } +// TestResolveDerivedPrivKeyFromStoreRejectsWatchOnly verifies that the +// store-only signer branch returns a precise error for accounts without +// encrypted private key material. +func TestResolveDerivedPrivKeyFromStoreRejectsWatchOnly(t *testing.T) { + t.Parallel() + + w, mocks := createStartedWalletWithMocks(t) + path := waddrmgr.DerivationPath{InternalAccount: 3} + query := db.GetAccountSecretQuery{ + WalletID: w.id, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AccountNumber: &path.InternalAccount, + } + mocks.store.On("GetAccountSecret", mock.Anything, query).Return( + &db.AccountSecret{AccountNumber: path.InternalAccount}, nil, + ).Once() + + _, err := w.resolveDerivedPrivKeyFromStore( + t.Context(), waddrmgr.KeyScopeBIP0084, path, + ) + require.ErrorIs(t, err, ErrWatchOnlyAccount) +} + +// TestResolveDerivedPrivKeyFromStoreAbsentAccount verifies that a missing +// account row terminates the store-only signer branch with +// ErrAccountNotInStore. +func TestResolveDerivedPrivKeyFromStoreAbsentAccount(t *testing.T) { + t.Parallel() + + w, mocks := createStartedWalletWithMocks(t) + path := waddrmgr.DerivationPath{InternalAccount: 7} + query := db.GetAccountSecretQuery{ + WalletID: w.id, + Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), + AccountNumber: &path.InternalAccount, + } + mocks.store.On("GetAccountSecret", mock.Anything, query).Return( + (*db.AccountSecret)(nil), db.ErrAccountNotFound, + ).Once() + + _, err := w.resolveDerivedPrivKeyFromStore( + t.Context(), waddrmgr.KeyScopeBIP0084, path, + ) + require.ErrorIs(t, err, ErrAccountNotInStore) +} + // TestGetPrivKeyForAddressSQLDerivedAddress verifies that GetPrivKeyForAddress // recovers the private key for an address whose only persistent record lives // in the SQL store. @@ -983,11 +1029,8 @@ func TestGetPrivKeyForAddressSQLDerivedAddress(t *testing.T) { // TestNewAddressOnSQLOnlyAccount verifies that SQL-owned accounts can derive // receive addresses without a mirrored legacy waddrmgr account. // -// The test exercises the public-key path only. Signing with addresses owned -// by a SQL-only account currently routes through the legacy waddrmgr -// derivation cache, which does not see SQL-only accounts; that gap is -// tracked separately and will be closed by the signer-store work on -// impl-tx-creator-store using keyVault-backed account-secret derivation. +// The test exercises the public-key path; signer coverage for SQL-only +// accounts is provided by TestGetPrivKeyForAddressSQLDerivedAddress. func TestNewAddressOnSQLOnlyAccount(t *testing.T) { t.Parallel() @@ -1162,7 +1205,7 @@ func TestResolvePrivKeyFallsBackAfterCacheMiss(t *testing.T) { mocks.pubKeyAddr.On("PrivKey").Return(privKey, nil).Once() // Act: Resolve the private key for the managed pubkey address. - resolvedPrivKey, err := w.resolvePrivKey(mocks.pubKeyAddr) + resolvedPrivKey, err := w.resolvePrivKey(t.Context(), mocks.pubKeyAddr) require.NoError(t, err) // Assert: The fallback path returns the same private key bytes.