From a18d21bb3edd37ad98a3ad51c8991c62768ce259 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:10:00 +0800 Subject: [PATCH 01/11] kvdb: add legacy manager vault shim Add LegacyManagerVault wrapping walletdb.DB and *waddrmgr.Manager, implementing keyvault.Vault by delegating to the legacy address manager. Unlock authenticates the private passphrase inside a walletdb view and ignores the auto-lock timeout, since the controller keeps owning that timer. Lock is void and swallows the idempotent ErrLocked, logging any other failure. IsLocked, Encrypt, and Decrypt forward to the manager, and RefreshPrivatePassphrase is a no-op because the manager rotates its crypto state in place. The shim lives beside the other kvdb legacy adapters and lets later commits route wallet auth and key-material encryption through the vault seam without changing behavior. (cherry picked from commit fec2904206cb6acc05cd8ce70eaf4328238096aa) --- wallet/internal/db/kvdb/vault.go | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 wallet/internal/db/kvdb/vault.go 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 + } +} From f42f95eea641e37c06d6085e5b8d74976c669b23 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:10:12 +0800 Subject: [PATCH 02/11] db: add GetAccountSecret query Add the GetAccountSecret query to the pg and sqlite account query sets and regenerate the sqlc bindings. The query joins accounts to key_scopes and left-joins account_secrets so callers can distinguish a watch-only account (no secret row) from an absent account. Generated code is kept in its own commit, separate from the hand-written Go that consumes it. (cherry picked from commit 99c59941b0a16f62e70d70dbff433f1b03588b8c) --- wallet/internal/sql/pg/queries/accounts.sql | 31 ++++++++ wallet/internal/sql/pg/sqlc/accounts.sql.go | 73 ++++++++++++++++++ wallet/internal/sql/pg/sqlc/db.go | 10 +++ wallet/internal/sql/pg/sqlc/querier.go | 4 + .../internal/sql/sqlite/queries/accounts.sql | 33 ++++++++ .../internal/sql/sqlite/sqlc/accounts.sql.go | 75 +++++++++++++++++++ wallet/internal/sql/sqlite/sqlc/db.go | 10 +++ wallet/internal/sql/sqlite/sqlc/querier.go | 4 + 8 files changed, 240 insertions(+) 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: From 32b311c23f34f44ae2b63b696d37c995d528f04a Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:11:06 +0800 Subject: [PATCH 03/11] db: add account secret types Add the AccountSecret result type and the GetAccountSecretQuery selector, plus the GetAccountSecretQuery.Validate helper and the ErrAccountSecretUnavailable sentinel. AccountSecret carries only encrypted private-key material; decryption stays with the wallet key vault. These types back the per-backend GetAccountSecret implementations that follow. (cherry picked from commit dc242ba18471d919cf119dfc662d80c34251a908) --- .../db/accountstore_getaccountsecret.go | 21 ++++++++ wallet/internal/db/data_types.go | 50 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 wallet/internal/db/accountstore_getaccountsecret.go 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. From f963186b3406d20354eecbb2eeaa3c390ba7c87b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:11:17 +0800 Subject: [PATCH 04/11] pg: implement GetAccountSecret Add the PostgreSQL Store.GetAccountSecret method backed by the generated GetAccountSecret query. It validates the selector, maps the row to the backend-independent AccountSecret, and reports a typed ErrAccountNotFound when no account row matches. The method is not yet part of the AccountStore interface; that wiring lands once every backend implements it. (cherry picked from commit 69c0b755ba6bd9a1d67672f061138206d8bf8a13) --- .../db/pg/accountstore_getaccountsecret.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 wallet/internal/db/pg/accountstore_getaccountsecret.go 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) +} From f271caf5480bac167344453c3e5d2d5a8493c584 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:11:18 +0800 Subject: [PATCH 05/11] sqlite: implement GetAccountSecret Add the SQLite Store.GetAccountSecret method backed by the generated GetAccountSecret query, mirroring the PostgreSQL implementation: validate the selector, map the row to AccountSecret, and surface ErrAccountNotFound for a missing account. Interface wiring lands once all backends implement it. (cherry picked from commit cf0865fb25e3b05001adebe4d43eaca20c54737f) --- .../sqlite/accountstore_getaccountsecret.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 wallet/internal/db/sqlite/accountstore_getaccountsecret.go 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) +} From 726cfaa247c67ebf5d2388fb255af69460e8e5d6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:11:31 +0800 Subject: [PATCH 06/11] db: add GetAccountSecret to the store contract Add GetAccountSecret to the AccountStore interface now that pg and sqlite implement it, and complete the remaining implementations: the kvdb backend reports ErrAccountSecretUnavailable (legacy secrets stay in waddrmgr), and the testify mock store gains the method. The runtime cache exposes a pass-through GetAccountSecret seam, and an itest covers derived, watch-only, and absent-account outcomes. (cherry picked from commit 21ce20dd89c85f6197a6daedbd8b0a1367df08a6) --- wallet/cache.go | 20 ++++ wallet/internal/bwtest/mock/store.go | 12 +++ wallet/internal/db/interface.go | 6 ++ .../accountstore_getaccountsecret_test.go | 96 +++++++++++++++++++ .../db/kvdb/accountstore_getaccountsecret.go | 20 ++++ 5 files changed, 154 insertions(+) create mode 100644 wallet/internal/db/itest/accountstore_getaccountsecret_test.go create mode 100644 wallet/internal/db/kvdb/accountstore_getaccountsecret.go 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/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/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 +} From 94ccb659ed151cd23b278e757e7cf79ec36e62a8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:12:40 +0800 Subject: [PATCH 07/11] wallet: construct legacy keyvault shim Build the keyVault from kvdb.NewLegacyManagerVault during Load and OpenWithRetry instead of the not-yet-implemented keyvault.NewDBVault stub. The kvdb-open path now flows wallet auth and key-material encryption through a real legacy-backed vault while behavior stays unchanged. The SQL-open path keeps the DBVault stub until its runtime crypto lands. (cherry picked from commit 2d073a505b7622c0e9e375600101b6aee913c7d9) --- wallet/deprecated.go | 3 +-- wallet/manager.go | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) 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/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, From a344ec270084755affea7770b55f0be82870d595 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:14:04 +0800 Subject: [PATCH 08/11] wallet: route unlock and lock through keyvault Route handleUnlockReq and handleLockReq through the key vault instead of the wallet-owned DBUnlock helper, and delete DBUnlock and its test. Unlock passes a negative timeout so the controller keeps owning its auto-lock timer; Lock is void, with the idempotent ErrLocked swallow now living in the vault. The vault interface has no passphrase-change method, so the passphrase rotation stays in the wallet: handleChangePassphraseReq persists the change through DBPutPassphrase and then asks the vault to refresh its runtime state on a private rotation. The controller tests assert against the vault and the restored DBPutPassphrase path. (cherry picked from commit da88730377791c2254c5f798584e9ad487390c01) --- wallet/common_test.go | 1 - wallet/controller.go | 39 +++++++------ wallet/controller_test.go | 115 ++++++++++++-------------------------- wallet/db_ops.go | 17 ------ wallet/db_ops_test.go | 19 ------- 5 files changed, 58 insertions(+), 133 deletions(-) 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) { From 15319d7b9102b72851f38444570e41c5678f8c33 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:18:58 +0800 Subject: [PATCH 09/11] wallet: add store-backed key derivation primitives Add deriveStoredAccountChildKey, which decrypts an account's encrypted private key through the key vault and derives the branch/index leaf key while zeroing intermediate material, and isWaddrmgrAccountClassError for classifying waddrmgr scope/account misses. Also add the ErrWatchOnlyAccount and ErrAccountNotInStore sentinels. These primitives back the store-backed signer fallback wired in next. (cherry picked from commit be8bb1843fc2fea681c616399f6df141d3383d34) --- wallet/signer.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/wallet/signer.go b/wallet/signer.go index 4bbb33b34e..351d4a1baf 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, @@ -895,6 +905,72 @@ func (w *Wallet) resolveDerivedPrivKey(accountManager waddrmgr.AccountStore, return privKey, nil } +// 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 +} + +// 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, From 7390b65140fd85b45799c4f932360c6413dd37b8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Thu, 11 Jun 2026 06:19:17 +0800 Subject: [PATCH 10/11] wallet: route signer keys through Store on legacy miss Add derivePathPrivKey and resolveDerivedPrivKeyFromStore, and thread a context through the signing paths (ECDH, SignDigest, ComputeRawSig, DerivePrivKey and the output/imported-key helpers). When the legacy waddrmgr lookup misses on a scope or account, resolve the account's encrypted private key from the Store and derive the leaf key through the key vault, with precise watch-only and not-in-store errors. Covers SQL-only accounts that have no mirrored waddrmgr row. (cherry picked from commit de9ddb6c0705ee14383427b39be992da377cf19d) --- wallet/signer.go | 207 ++++++++++++++++++++++++++++++------------ wallet/signer_test.go | 55 +++++++++-- 2 files changed, 196 insertions(+), 66 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 351d4a1baf..12866446c7 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -552,10 +552,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() @@ -563,17 +607,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. @@ -611,7 +648,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() @@ -624,16 +661,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 @@ -708,7 +739,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 } @@ -730,11 +761,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) @@ -742,7 +774,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 @@ -757,7 +789,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 @@ -774,7 +807,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 @@ -811,7 +846,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 @@ -831,25 +867,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) } @@ -861,11 +911,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 @@ -905,6 +972,44 @@ 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 @@ -1054,7 +1159,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() @@ -1062,18 +1167,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. @@ -1099,7 +1196,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() @@ -1107,17 +1204,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. @@ -1140,7 +1227,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. From 6a008f64b3e7af86e99b4d1ff9a1cd6e0bd4ee28 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 1 Jul 2026 01:32:42 +0800 Subject: [PATCH 11/11] review(import/sign): store-back deprecated imports, pubkey derive, PSBT account #13: mirror deprecated public-key + taproot-script imports into w.store for SQL (skip the redundant kvdb mirror, where the legacy manager is the store). #14: DerivePubKey falls back to the account xpub from the store for SQL-only accounts (resolveDerivedPubKeyFromStore), matching the privkey fallback. #15: parseBip32Path sets InternalAccount alongside Account so non-zero-account PSBT signing resolves the correct account secret instead of account 0. (cherry picked from commit ca4c9d4c6acd44325cc476eae415fa7e2dff4476) # Conflicts: # wallet/signer_test.go --- wallet/psbt_manager.go | 13 +++-- wallet/psbt_manager_test.go | 98 +++++++++++++++++++++++++++++++++---- wallet/signer.go | 96 ++++++++++++++++++++++++++++++++++-- 3 files changed, 191 insertions(+), 16 deletions(-) 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 12866446c7..04dd02dd5d 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -470,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() @@ -479,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 @@ -1057,6 +1078,73 @@ func deriveStoredAccountChildKey(vault keyvault.Vault, 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,