Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

vspadmin: Add retirexpub command #480

Merged
merged 4 commits into from
Jun 27, 2024
Merged
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
14 changes: 14 additions & 0 deletions cmd/vspadmin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,17 @@ Example:
```no-highlight
$ go run ./cmd/vspadmin writeconfig
```

### `retirexpub`

Replaces the currently used xpub with a new one. Once an xpub key has been
retired it can not be used by the VSP again.

**Note:** vspd must be stopped before this command can be used because it
modifies values in the vspd database.

Example:

```no-highlight
$ go run ./cmd/vspadmin retirexpub <xpub>
```
64 changes: 57 additions & 7 deletions cmd/vspadmin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/decred/dcrd/dcrutil/v4"
"github.com/decred/dcrd/hdkeychain/v3"
"github.com/decred/slog"
"github.com/decred/vspd/database"
"github.com/decred/vspd/internal/config"
"github.com/decred/vspd/internal/vspd"
Expand Down Expand Up @@ -45,6 +46,21 @@ func fileExists(name string) bool {
return true
}

// validatePubkey returns an error if the provided key is invalid, not for the
// expected network, or it is public instead of private.
func validatePubkey(key string, network *config.Network) error {
parsedKey, err := hdkeychain.NewKeyFromString(key, network.Params)
if err != nil {
return fmt.Errorf("failed to parse feexpub: %w", err)
}

if parsedKey.IsPrivate() {
return errors.New("feexpub is a private key, should be public")
}

return nil
}

func createDatabase(homeDir string, feeXPub string, network *config.Network) error {
dataDir := filepath.Join(homeDir, "data", network.Name)
dbFile := filepath.Join(dataDir, dbFilename)
Expand All @@ -55,14 +71,9 @@ func createDatabase(homeDir string, feeXPub string, network *config.Network) err
}

// Ensure provided xpub is a valid key for the selected network.
feeXpub, err := hdkeychain.NewKeyFromString(feeXPub, network.Params)
err := validatePubkey(feeXPub, network)
if err != nil {
return fmt.Errorf("failed to parse feexpub: %w", err)
}

// Ensure key is public.
if feeXpub.IsPrivate() {
return errors.New("feexpub is a private key, should be public")
return err
}

// Ensure the data directory exists.
Expand Down Expand Up @@ -106,6 +117,29 @@ func writeConfig(homeDir string) error {
return nil
}

func retireXPub(homeDir string, feeXPub string, network *config.Network) error {
dataDir := filepath.Join(homeDir, "data", network.Name)
dbFile := filepath.Join(dataDir, dbFilename)

// Ensure provided xpub is a valid key for the selected network.
err := validatePubkey(feeXPub, network)
if err != nil {
return err
}

db, err := database.Open(dbFile, slog.Disabled, 999)
if err != nil {
return fmt.Errorf("error opening db file %s: %w", dbFile, err)
}

err = db.RetireXPub(feeXPub)
if err != nil {
return fmt.Errorf("db.RetireXPub failed: %w", err)
}

return nil
}

// run is the real main function for vspadmin. It is necessary to work around
// the fact that deferred functions do not run when os.Exit() is called.
func run() int {
Expand Down Expand Up @@ -161,6 +195,22 @@ func run() int {
log("Config file with default values written to %s", cfg.HomeDir)
log("Edit the file and fill in values specific to your vspd deployment")

case "retirexpub":
if len(remainingArgs) != 2 {
log("retirexpub has one required argument, fee xpub")
return 1
}

feeXPub := remainingArgs[1]

err = retireXPub(cfg.HomeDir, feeXPub, network)
if err != nil {
log("retirexpub failed: %v", err)
return 1
}

log("Xpub successfully retired, all future tickets will use the new xpub")

default:
log("%q is not a valid command", remainingArgs[0])
return 1
Expand Down
15 changes: 8 additions & 7 deletions database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ var (
voteChangeBktK = []byte("votechangebkt")
// version is the current database version.
versionK = []byte("version")
// feeXPub is the extended public key used for collecting VSP fees.
feeXPubK = []byte("feeXPub")
// xPubBktK stores current and historic extended public keys used for
// collecting VSP fees.
xPubBktK = []byte("xpubbkt")
// cookieSecret is the secret key for initializing the cookie store.
cookieSecretK = []byte("cookieSecret")
// privatekey is the private key.
privateKeyK = []byte("privatekey")
// lastaddressindex is the index of the last address used for fees.
lastAddressIndexK = []byte("lastaddressindex")
// altSignAddrBktK stores alternate signing addresses.
altSignAddrBktK = []byte("altsigbkt")
)
Expand Down Expand Up @@ -137,12 +136,14 @@ func CreateNew(dbFile, feeXPub string) error {
return err
}

// Store fee xpub.
xpub := FeeXPub{
// Insert the initial fee xpub with ID 0.
newKey := FeeXPub{
ID: 0,
Key: feeXPub,
LastUsedIdx: 0,
Retired: 0,
}
err = insertFeeXPub(tx, xpub)
err = insertFeeXPub(tx, newKey)
if err != nil {
return err
}
Expand Down
1 change: 1 addition & 0 deletions database/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func TestDatabase(t *testing.T) {
"testFilterTickets": testFilterTickets,
"testCountTickets": testCountTickets,
"testFeeXPub": testFeeXPub,
"testRetireFeeXPub": testRetireFeeXPub,
"testDeleteTicket": testDeleteTicket,
"testVoteChangeRecords": testVoteChangeRecords,
"testHTTPBackup": testHTTPBackup,
Expand Down
128 changes: 105 additions & 23 deletions database/feexpub.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,58 +5,140 @@
package database

import (
"encoding/json"
"errors"
"fmt"
"time"

bolt "go.etcd.io/bbolt"
)

// FeeXPub is serialized to json and stored in bbolt db.
type FeeXPub struct {
Key string
LastUsedIdx uint32
ID uint32 `json:"id"`
Key string `json:"key"`
LastUsedIdx uint32 `json:"lastusedidx"`
// Retired is a unix timestamp representing the moment the key was retired,
// or zero for the currently active key.
Retired int64 `json:"retired"`
}

// insertFeeXPub stores the provided pubkey in the database, regardless of
// whether a value pre-exists.
func insertFeeXPub(tx *bolt.Tx, xpub FeeXPub) error {
vspBkt := tx.Bucket(vspBktK)

err := vspBkt.Put(feeXPubK, []byte(xpub.Key))
keyBkt, err := vspBkt.CreateBucketIfNotExists(xPubBktK)
if err != nil {
return err
return fmt.Errorf("failed to get %s bucket: %w", string(xPubBktK), err)
}

keyBytes, err := json.Marshal(xpub)
if err != nil {
return fmt.Errorf("could not marshal xpub: %w", err)
}

return vspBkt.Put(lastAddressIndexK, uint32ToBytes(xpub.LastUsedIdx))
err = keyBkt.Put(uint32ToBytes(xpub.ID), keyBytes)
if err != nil {
return fmt.Errorf("could not store xpub: %w", err)
}

return nil
}

// FeeXPub retrieves the extended pubkey used for generating fee addresses
// from the database.
// FeeXPub retrieves the currently active extended pubkey used for generating
// fee addresses from the database.
func (vdb *VspDatabase) FeeXPub() (FeeXPub, error) {
var feeXPub string
var idx uint32
xpubs, err := vdb.AllXPubs()
if err != nil {
return FeeXPub{}, err
}

// Find the active xpub - the one with the highest ID.
var highest uint32
for id := range xpubs {
if id > highest {
highest = id
}
}

return xpubs[highest], nil
Copy link
Member

Choose a reason for hiding this comment

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

I believe the initialization of the database ensures there is at least one xpub, so it probably doesn't matter, but this will crash if there are no xpubs in the database. It seems like it'd be safer to me to have a check before the loop like:

if len(xpubs) == 0 {
	return FeeXPub{}, /* no xpub configured error */
}

// Find the active xpub - the one with the highest ID.
var highest uint32
...

Copy link
Member Author

Choose a reason for hiding this comment

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

I think we already have that scenario covered - AllXPubs() will return a "bucket does not exist" error if there are no xpubs in the DB.

Copy link
Member

Choose a reason for hiding this comment

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

That's what I meant by the bit about saying I believe the initialization of the database ensures there is at least one. Generally speaking, just because a bucket exists does not necessarily imply it has anything in it. It probably doesn't matter too much for vspd, but I'm always super careful about such things in dcrd to avoid crashes.

}

// RetireXPub will mark the currently active xpub key as retired and insert the
// provided pubkey as the currently active one.
func (vdb *VspDatabase) RetireXPub(xpub string) error {
// Ensure the new xpub has never been used before.
xpubs, err := vdb.AllXPubs()
if err != nil {
return err
}
for _, x := range xpubs {
if x.Key == xpub {
return errors.New("provided xpub has already been used")
}
}

current, err := vdb.FeeXPub()
if err != nil {
return err
}
current.Retired = time.Now().Unix()

return vdb.db.Update(func(tx *bolt.Tx) error {
// Store the retired xpub.
err := insertFeeXPub(tx, current)
if err != nil {
return err
}

// Insert new xpub.
newKey := FeeXPub{
ID: current.ID + 1,
Key: xpub,
LastUsedIdx: 0,
Retired: 0,
}
err = insertFeeXPub(tx, newKey)
if err != nil {
return err
}

return nil
})
}

// AllXPubs retrieves the current and any retired extended pubkeys from the
// database.
func (vdb *VspDatabase) AllXPubs() (map[uint32]FeeXPub, error) {
xpubs := make(map[uint32]FeeXPub)

err := vdb.db.View(func(tx *bolt.Tx) error {
vspBkt := tx.Bucket(vspBktK)
bkt := tx.Bucket(vspBktK).Bucket(xPubBktK)

// Get the key.
xpubBytes := vspBkt.Get(feeXPubK)
if xpubBytes == nil {
return nil
if bkt == nil {
return fmt.Errorf("%s bucket doesn't exist", string(xPubBktK))
}
feeXPub = string(xpubBytes)

// Get the last used address index.
idxBytes := vspBkt.Get(lastAddressIndexK)
if idxBytes == nil {
err := bkt.ForEach(func(k, v []byte) error {
var xpub FeeXPub
err := json.Unmarshal(v, &xpub)
if err != nil {
return fmt.Errorf("could not unmarshal xpub key: %w", err)
}

xpubs[bytesToUint32(k)] = xpub

return nil
})
if err != nil {
return fmt.Errorf("error iterating over %s bucket: %w", string(xPubBktK), err)
}
idx = bytesToUint32(idxBytes)

return nil
})
if err != nil {
return FeeXPub{}, fmt.Errorf("could not retrieve fee xpub: %w", err)
}

return FeeXPub{Key: feeXPub, LastUsedIdx: idx}, nil
return xpubs, err
}

// SetLastAddressIndex updates the last index used to derive a new fee address
Expand Down
Loading
Loading