Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions wallet/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
12 changes: 12 additions & 0 deletions wallet/internal/bwtest/mock/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions wallet/internal/db/accountstore_getaccountsecret.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions wallet/internal/db/data_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions wallet/internal/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am questioning why do we need this method - when the wallet is unlocked, we will cache the secrets in the cache? thus that whenever we need to create secrets, we will use the cache to do so?
in addition what is the relationship among account secret, wallet secret, keyvault and cache?

*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.
Expand Down
93 changes: 93 additions & 0 deletions wallet/internal/db/itest/accountstore_getaccountsecret_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
20 changes: 20 additions & 0 deletions wallet/internal/db/kvdb/accountstore_getaccountsecret.go
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions wallet/internal/db/pg/accountstore_getaccountsecret.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading