From 7c879bde677c4822c06b066c51b69c1359b0761f Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 May 2026 19:32:37 +0800 Subject: [PATCH 1/3] db: add account secret lookup contract Introduce GetAccountSecret on db.AccountStore so the wallet signer can read the encrypted account-level signing material that SQL backends persist for SQL-only accounts. The kvdb backend returns db.ErrAccountSecretUnavailable because its signing path still resolves through the legacy waddrmgr address manager. Add the matching pg/sqlite sqlc queries, account_secrets itest coverage for both SQL backends, and a GetAccountSecret method on the shared walletmock.Store so wallet-side tests can stub the new contract. --- wallet/internal/bwtest/mock/store.go | 12 ++ .../db/accountstore_getaccountsecret.go | 23 ++++ wallet/internal/db/data_types.go | 50 ++++++++ wallet/internal/db/interface.go | 7 ++ .../accountstore_getaccountsecret_test.go | 93 ++++++++++++++ .../db/kvdb/accountstore_getaccountsecret.go | 20 +++ .../db/pg/accountstore_getaccountsecret.go | 115 ++++++++++++++++++ .../sqlite/accountstore_getaccountsecret.go | 115 ++++++++++++++++++ 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 + 16 files changed, 675 insertions(+) create mode 100644 wallet/internal/db/accountstore_getaccountsecret.go create mode 100644 wallet/internal/db/itest/accountstore_getaccountsecret_test.go create mode 100644 wallet/internal/db/kvdb/accountstore_getaccountsecret.go create mode 100644 wallet/internal/db/pg/accountstore_getaccountsecret.go create mode 100644 wallet/internal/db/sqlite/accountstore_getaccountsecret.go diff --git a/wallet/internal/bwtest/mock/store.go b/wallet/internal/bwtest/mock/store.go index d4c04b53e8..37d69fcc2c 100644 --- a/wallet/internal/bwtest/mock/store.go +++ b/wallet/internal/bwtest/mock/store.go @@ -265,6 +265,18 @@ func (m *Store) GetAddressSecret(ctx context.Context, return args.Get(0).(*db.AddressSecret), 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) +} + // ListAddressTypes implements the db.AddressStore interface. func (m *Store) ListAddressTypes(ctx context.Context) ( []db.AddressTypeInfo, error) { diff --git a/wallet/internal/db/accountstore_getaccountsecret.go b/wallet/internal/db/accountstore_getaccountsecret.go new file mode 100644 index 0000000000..5088668a59 --- /dev/null +++ b/wallet/internal/db/accountstore_getaccountsecret.go @@ -0,0 +1,23 @@ +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 46e399f443..205400170c 100644 --- a/wallet/internal/db/data_types.go +++ b/wallet/internal/db/data_types.go @@ -443,6 +443,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 @@ -565,6 +596,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 e712332c26..6742ff5f41 100644 --- a/wallet/internal/db/interface.go +++ b/wallet/internal/db/interface.go @@ -227,6 +227,13 @@ type AccountStore interface { GetAccount(ctx context.Context, query GetAccountQuery) ( *AccountInfo, error) + // GetAccountSecret retrieves encrypted account-level signing material for + // one account. The returned EncryptedPrivateKey is still ciphertext; + // callers must decrypt it through the wallet key vault before deriving + // private child keys. + 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..c4184be2e3 --- /dev/null +++ b/wallet/internal/db/itest/accountstore_getaccountsecret_test.go @@ -0,0 +1,93 @@ +//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" + _, err = store.CreateImportedAccount( + t.Context(), db.CreateImportedAccountParams{ + WalletID: walletID, + Name: watchOnlyName, + Scope: scope, + PublicKey: []byte("watch-only-pubkey"), + }, + ) + require.NoError(t, err) + + secret, err = store.GetAccountSecret( + t.Context(), db.GetAccountSecretQuery{ + WalletID: walletID, + 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/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 a91116ef29..363defe73a 100644 --- a/wallet/internal/sql/pg/queries/accounts.sql +++ b/wallet/internal/sql/pg/queries/accounts.sql @@ -56,6 +56,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 06ebd2361f..8ae1c18d10 100644 --- a/wallet/internal/sql/pg/sqlc/accounts.sql.go +++ b/wallet/internal/sql/pg/sqlc/accounts.sql.go @@ -715,6 +715,79 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount 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 03961b581c..3e71eeaf9a 100644 --- a/wallet/internal/sql/pg/sqlc/db.go +++ b/wallet/internal/sql/pg/sqlc/db.go @@ -99,6 +99,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %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) } @@ -409,6 +412,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %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) @@ -773,6 +781,7 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAccountSecretStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -864,6 +873,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + 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 ce5f193221..cb9e1ccf8f 100644 --- a/wallet/internal/sql/pg/sqlc/querier.go +++ b/wallet/internal/sql/pg/sqlc/querier.go @@ -152,6 +152,10 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, 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 73e2c3bae9..2755665c16 100644 --- a/wallet/internal/sql/sqlite/queries/accounts.sql +++ b/wallet/internal/sql/sqlite/queries/accounts.sql @@ -56,6 +56,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 417adbc89e..13c2f75554 100644 --- a/wallet/internal/sql/sqlite/sqlc/accounts.sql.go +++ b/wallet/internal/sql/sqlite/sqlc/accounts.sql.go @@ -725,6 +725,81 @@ func (q *Queries) GetAccountPropsById(ctx context.Context, id int64) (GetAccount 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 03961b581c..3e71eeaf9a 100644 --- a/wallet/internal/sql/sqlite/sqlc/db.go +++ b/wallet/internal/sql/sqlite/sqlc/db.go @@ -99,6 +99,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getAccountPropsByIdStmt, err = db.PrepareContext(ctx, GetAccountPropsById); err != nil { return nil, fmt.Errorf("error preparing query GetAccountPropsById: %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) } @@ -409,6 +412,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getAccountPropsByIdStmt: %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) @@ -773,6 +781,7 @@ type Queries struct { getAccountByWalletScopeAndNameStmt *sql.Stmt getAccountByWalletScopeAndNumberStmt *sql.Stmt getAccountPropsByIdStmt *sql.Stmt + getAccountSecretStmt *sql.Stmt getActiveUtxoLeaseLockIDStmt *sql.Stmt getAddressByScriptPubKeyStmt *sql.Stmt getAddressSecretStmt *sql.Stmt @@ -864,6 +873,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getAccountByWalletScopeAndNameStmt: q.getAccountByWalletScopeAndNameStmt, getAccountByWalletScopeAndNumberStmt: q.getAccountByWalletScopeAndNumberStmt, getAccountPropsByIdStmt: q.getAccountPropsByIdStmt, + 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 7c4afb215b..f75c579096 100644 --- a/wallet/internal/sql/sqlite/sqlc/querier.go +++ b/wallet/internal/sql/sqlite/sqlc/querier.go @@ -150,6 +150,10 @@ type Querier interface { GetAccountByWalletScopeAndNumber(ctx context.Context, arg GetAccountByWalletScopeAndNumberParams) (GetAccountByWalletScopeAndNumberRow, error) // Returns full account properties by account id. GetAccountPropsById(ctx context.Context, id int64) (GetAccountPropsByIdRow, 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 8f40c9731ce7e0d62d9715776d2830b6a936da54 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 May 2026 19:33:22 +0800 Subject: [PATCH 2/3] wallet: expose store account secrets through cache Add GetAccountSecret to runtimeCache so wallet-side consumers can read SQL-backed account signing material through a single boundary. The storeRuntimeCache implementation is a pass-through today, marked with the same TODO(yy) as the other cache reads so the cache layer can grow typed error wrapping later without churning callers. --- wallet/cache.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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). From b0f6271bfaee087be5e151981287936eaeaf12f6 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 27 May 2026 19:33:56 +0800 Subject: [PATCH 3/3] wallet: derive SQL-only private keys via keyVault When waddrmgr reports an account or scope miss, the legacy DeriveFromKeyPathCache fallback also fails for accounts that only live in the SQL store. Fall back to the encrypted account-level private key fetched from the store cache, decrypt it through keyVault, derive the requested branch and index, and zero intermediate key material before returning. Add SQLite-backed signing coverage for a SQL-only derived account in signer_test so the fallback exercises a real SQL store end-to-end and the existing kvdb-backed coverage keeps regressing the legacy path. --- wallet/signer.go | 299 +++++++++++++++++++++++++++++-------- wallet/signer_test.go | 340 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 563 insertions(+), 76 deletions(-) diff --git a/wallet/signer.go b/wallet/signer.go index 21114dd13e..75f182c9e6 100644 --- a/wallet/signer.go +++ b/wallet/signer.go @@ -13,8 +13,10 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "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, @@ -542,10 +552,58 @@ 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 + + // A scope or account miss means the account is not mirrored into the + // legacy waddrmgr; resolve it from the store-backed account secret + // instead. Any other error is a real failure and must surface. + 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 +611,12 @@ func (w *Wallet) ECDH(_ context.Context, path BIP32Path, return [32]byte{}, err } - managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + // Resolve the private key for the derived path, falling back to the + // store-backed account secret for SQL-only accounts. + 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 +654,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 +667,12 @@ func (w *Wallet) SignDigest(_ context.Context, path BIP32Path, return nil, err } - managedPubKeyAddr, err := w.fetchManagedPubKeyAddress(path) + // Resolve the private key for the derived path, falling back to the + // store-backed account secret for SQL-only accounts. + 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 +747,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 +769,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 +782,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 +797,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 +815,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 +854,8 @@ func (w *Wallet) loadManagedPubKeyAddr(addr btcutil.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 +875,41 @@ 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 + // 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 — symmetric to deriveAddressData's // AccountPubKey plumbing on the public-key side. 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 +921,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 +982,114 @@ 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 + index +// derivation to produce the leaf private key. The decrypted byte slice +// and the intermediate hd keys are zeroed before the call returns. +// Note that hdkeychain/base58 parsing allocates a transient immutable +// string copy (string(plaintext)) 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) + } + + // Zero the decrypted byte slice as soon as it has been parsed (on + // both the error and success paths) so it does not stay alive + // through the branch and index derivation below. + 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, @@ -978,7 +1173,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 +1181,12 @@ 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) + // Resolve the private key for the specified derivation path, falling + // back to the store-backed account secret for SQL-only accounts. + 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 +1212,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 +1220,9 @@ 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 + // Resolve the private key for the derived path, falling back to the + // store-backed account secret for SQL-only accounts. + return w.derivePathPrivKey(ctx, path) } // GetPrivKeyForAddress returns the private key for a given address. @@ -1064,7 +1245,7 @@ func (w *Wallet) GetPrivKeyForAddress(ctx context.Context, a btcutil.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 65127baa84..3c76643ce7 100644 --- a/wallet/signer_test.go +++ b/wallet/signer_test.go @@ -945,6 +945,300 @@ func TestComputeUnlockingScriptSQLDerivedAddress(t *testing.T) { require.NoError(t, vm.Execute(), "script execution failed") } +// TestComputeUnlockingScriptSQLOnlyAccount verifies that signing can derive a +// private key from SQL account secrets when the account does not exist in +// legacy waddrmgr. +func TestComputeUnlockingScriptSQLOnlyAccount(t *testing.T) { + t.Parallel() + + w, chain := newSQLAddressSigningWallet(t) + _, err := w.NewAccount( + t.Context(), waddrmgr.KeyScopeBIP0084, "secondary", + ) + require.NoError(t, err) + + chain.On( + "NotifyReceived", + mock.MatchedBy(func(addrs []btcutil.Address) bool { + return len(addrs) == 1 + }), + ).Return(nil).Once() + + addr, err := w.NewAddress( + t.Context(), "secondary", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + _, err = w.loadManagedPubKeyAddr(addr) + require.Error(t, err) + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + params := &UnlockingScriptParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + } + + script, err := w.ComputeUnlockingScript(t.Context(), params) + require.NoError(t, err) + require.Nil(t, script.SigScript) + require.NotNil(t, script.Witness) + + tx.TxIn[0].Witness = script.Witness + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "script execution failed") +} + +// newSQLOnlyAccountSigner returns a started wallet together with a freshly +// derived P2WKH address that belongs to a SQL-only account (one with no +// mirrored legacy waddrmgr row) and the full BIP-32 path for that address. The +// legacy managed-address lookup is asserted to miss so the path-based signer +// APIs are forced down the store-secret fallback rather than the waddrmgr +// derivation cache. +func newSQLOnlyAccountSigner(t *testing.T) (*Wallet, btcutil.Address, + BIP32Path) { + + t.Helper() + + w, chain := newSQLAddressSigningWallet(t) + _, err := w.NewAccount( + t.Context(), waddrmgr.KeyScopeBIP0084, "secondary", + ) + require.NoError(t, err) + + chain.On( + "NotifyReceived", + mock.MatchedBy(func(addrs []btcutil.Address) bool { + return len(addrs) == 1 + }), + ).Return(nil).Once() + + addr, err := w.NewAddress( + t.Context(), "secondary", waddrmgr.WitnessPubKey, false, + ) + require.NoError(t, err) + + // The legacy managed-address lookup must miss for a SQL-only account; + // otherwise the test would exercise the waddrmgr path instead of the + // store-secret fallback. + _, err = w.loadManagedPubKeyAddr(addr) + require.Error(t, err) + + // Reconstruct the full derivation path from the store metadata so the + // path-based signer APIs can be driven directly. + info, err := w.GetAddressInfo(t.Context(), addr) + require.NoError(t, err) + require.NotNil(t, info.Derivation) + + derivation := info.Derivation + path := BIP32Path{ + KeyScope: derivation.KeyScope, + DerivationPath: waddrmgr.DerivationPath{ + InternalAccount: derivation.Account, + Account: derivation.Account + + hdkeychain.HardenedKeyStart, + Branch: derivation.Branch, + Index: derivation.Index, + MasterKeyFingerprint: derivation.MasterKeyFingerprint, + }, + } + + return w, addr, path +} + +// TestDerivePrivKeySQLOnlyAccount verifies that DerivePrivKey resolves a +// private key for a SQL-only account through the store-secret fallback, and +// that the key matches the address it was derived for. +func TestDerivePrivKeySQLOnlyAccount(t *testing.T) { + t.Parallel() + + w, addr, path := newSQLOnlyAccountSigner(t) + + privKey, err := w.DerivePrivKey(t.Context(), path) + require.NoError(t, err) + require.NotNil(t, privKey) + + defer privKey.Zero() + + // The derived key must round-trip back to the same P2WKH address. + pubKeyHash := btcutil.Hash160(privKey.PubKey().SerializeCompressed()) + derivedAddr, err := btcutil.NewAddressWitnessPubKeyHash( + pubKeyHash, w.cfg.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, addr.String(), derivedAddr.String()) +} + +// TestSignDigestSQLOnlyAccount verifies that SignDigest produces a valid +// signature for a SQL-only account via the store-secret fallback. +func TestSignDigestSQLOnlyAccount(t *testing.T) { + t.Parallel() + + w, _, path := newSQLOnlyAccountSigner(t) + + // Independently derive the expected public key to verify the signature + // against. + privKey, err := w.DerivePrivKey(t.Context(), path) + require.NoError(t, err) + + pubKey := privKey.PubKey() + privKey.Zero() + + msgHash := chainhash.HashB([]byte("sql-only sign digest")) + intent := &SignDigestIntent{ + Digest: msgHash, + SigType: SigTypeECDSA, + } + + sig, err := w.SignDigest(t.Context(), path, intent) + require.NoError(t, err) + + ecdsaSig, ok := sig.(ECDSASignature) + require.True(t, ok, "expected ECDSASignature") + require.True(t, ecdsaSig.Verify(msgHash, pubKey), "signature invalid") +} + +// TestECDHSQLOnlyAccount verifies that ECDH derives the shared secret for a +// SQL-only account via the store-secret fallback. +func TestECDHSQLOnlyAccount(t *testing.T) { + t.Parallel() + + w, _, path := newSQLOnlyAccountSigner(t) + + // Recover the local private key so the expected shared secret can be + // computed independently. + localPriv, err := w.DerivePrivKey(t.Context(), path) + require.NoError(t, err) + + defer localPriv.Zero() + + remotePriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + remotePub := remotePriv.PubKey() + + sharedSecret, err := w.ECDH(t.Context(), path, remotePub) + require.NoError(t, err) + + expected := btcec.GenerateSharedSecret(localPriv, remotePub) + + var expectedArray [32]byte + copy(expectedArray[:], expected) + require.Equal(t, expectedArray, sharedSecret) +} + +// TestComputeRawSigSQLOnlyAccount verifies that ComputeRawSig produces a valid +// segwit-v0 signature for a SQL-only account via the store-secret fallback. +func TestComputeRawSigSQLOnlyAccount(t *testing.T) { + t.Parallel() + + w, addr, path := newSQLOnlyAccountSigner(t) + + // Derive the public key independently to assemble and verify the + // witness. + privKey, err := w.DerivePrivKey(t.Context(), path) + require.NoError(t, err) + + pubKey := privKey.PubKey() + privKey.Zero() + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + prevOut, tx := createDummyTestTx(pkScript) + fetcher := txscript.NewCannedPrevOutputFetcher( + prevOut.PkScript, prevOut.Value, + ) + sigHashes := txscript.NewTxSigHashes(tx, fetcher) + + params := &RawSigParams{ + Tx: tx, + InputIndex: 0, + Output: prevOut, + SigHashes: sigHashes, + HashType: txscript.SigHashAll, + Path: path, + Details: SegwitV0SpendDetails{ + WitnessScript: pkScript, + }, + } + + rawSig, err := w.ComputeRawSig(t.Context(), params) + require.NoError(t, err) + + // Assemble the witness from the raw signature and verify it executes. + rawSig = append(rawSig, byte(txscript.SigHashAll)) + tx.TxIn[0].Witness = wire.TxWitness{ + rawSig, pubKey.SerializeCompressed(), + } + + vm, err := txscript.NewEngine( + prevOut.PkScript, tx, 0, txscript.StandardVerifyFlags, nil, + sigHashes, prevOut.Value, fetcher, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "signature verification 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. @@ -980,11 +1274,9 @@ 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 only. The private-key (signing) side +// for SQL-only accounts is covered by the *SQLOnlyAccount signer tests above, +// which drive the keyVault-backed account-secret fallback. func TestNewAddressOnSQLOnlyAccount(t *testing.T) { t.Parallel() @@ -1159,7 +1451,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. @@ -1335,10 +1627,22 @@ func newSQLAddressSigningWallet(t *testing.T) (*Wallet, *bwmock.Chain) { legacyDB, cleanup := setupTestDB(t) t.Cleanup(cleanup) - addrStore := newSpendableAddressManager(t, legacyDB) + addrStore, rootKey := newSpendableAddressManager(t, legacyDB) t.Cleanup(func() { addrStore.Close() }) + t.Cleanup(rootKey.Zero) + + masterFingerprint, err := masterKeyFingerprint(rootKey) + require.NoError(t, err) + + encryptedRoot, err := addrStore.Encrypt( + waddrmgr.CKTPrivate, []byte(rootKey.String()), + ) + require.NoError(t, err) + + rootPub, err := rootKey.Neuter() + require.NoError(t, err) var w *Wallet @@ -1361,8 +1665,8 @@ func newSQLAddressSigningWallet(t *testing.T) (*Wallet, *bwmock.Chain) { t.Context(), db.CreateWalletParams{ Name: "sql-signing", ManagerVersion: 1, - EncryptedMasterPrivKey: []byte{1}, - MasterPubKey: []byte{2}, + EncryptedMasterPrivKey: encryptedRoot, + MasterPubKey: []byte(rootPub.String()), MasterKeyPrivParams: []byte{3}, EncryptedCryptoPrivKey: []byte{4}, EncryptedCryptoScriptKey: []byte{5}, @@ -1375,16 +1679,18 @@ func newSQLAddressSigningWallet(t *testing.T) (*Wallet, *bwmock.Chain) { WalletID: walletInfo.ID, Scope: db.KeyScope(waddrmgr.KeyScopeBIP0084), Name: "default", - }, testAccountDerivationFunc(), + }, newAccountDeriveFn(rootKey, addrStore, masterFingerprint), ) require.NoError(t, err) chain := &bwmock.Chain{} w = &Wallet{ - addrStore: addrStore, - store: store, - keyVault: addrStore, - state: newWalletState(nil), + addrStore: addrStore, + store: store, + cache: newStoreRuntimeCache(store), + keyVault: addrStore, + state: newWalletState(nil), + masterFingerprint: masterFingerprint, cfg: Config{ DB: legacyDB, Chain: chain, @@ -1405,9 +1711,9 @@ func newSQLAddressSigningWallet(t *testing.T) (*Wallet, *bwmock.Chain) { } // newSpendableAddressManager creates and unlocks a deterministic legacy -// waddrmgr manager for signer integration tests. +// waddrmgr manager and returns its root key for signer integration tests. func newSpendableAddressManager(t *testing.T, - dbConn walletdb.DB) *waddrmgr.Manager { + dbConn walletdb.DB) (*waddrmgr.Manager, *hdkeychain.ExtendedKey) { t.Helper() @@ -1452,7 +1758,7 @@ func newSpendableAddressManager(t *testing.T, }) require.NoError(t, err) - return mgr + return mgr, rootKey } // testAccountDerivationFunc returns minimal spendable account material for SQL