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 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: 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