vspadmin: Add retirexpub command.

The new command opens an existing vspd database and replaces the
currently used xpub with a new one.
This commit is contained in:
jholdstock 2024-06-26 08:48:31 +01:00 committed by Jamie Holdstock
parent c23095444f
commit cab4058710
5 changed files with 175 additions and 7 deletions

View File

@ -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>
```

View File

@ -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"
@ -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)
@ -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.
@ -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 {
@ -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

View File

@ -78,6 +78,7 @@ func TestDatabase(t *testing.T) {
"testFilterTickets": testFilterTickets,
"testCountTickets": testCountTickets,
"testFeeXPub": testFeeXPub,
"testRetireFeeXPub": testRetireFeeXPub,
"testDeleteTicket": testDeleteTicket,
"testVoteChangeRecords": testVoteChangeRecords,
"testHTTPBackup": testHTTPBackup,

View File

@ -6,7 +6,9 @@ package database
import (
"encoding/json"
"errors"
"fmt"
"time"
bolt "go.etcd.io/bbolt"
)
@ -63,6 +65,49 @@ func (vdb *VspDatabase) FeeXPub() (FeeXPub, error) {
return xpubs[highest], nil
}
// 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) {

View File

@ -57,3 +57,61 @@ func testFeeXPub(t *testing.T) {
t.Fatalf("expected xpub retirement 0, got %d", retrievedXPub.Retired)
}
}
func testRetireFeeXPub(t *testing.T) {
// Increment the last used index to simulate some usage.
idx := uint32(99)
err := db.SetLastAddressIndex(idx)
if err != nil {
t.Fatalf("error setting address index: %v", err)
}
// Ensure a previously used xpub is rejected.
err = db.RetireXPub(feeXPub)
if err == nil {
t.Fatalf("previous xpub was not rejected")
}
const expectedErr = "provided xpub has already been used"
if err == nil || err.Error() != expectedErr {
t.Fatalf("incorrect error, expected %q, got %q",
expectedErr, err.Error())
}
// An unused xpub should be accepted.
const feeXPub2 = "feexpub2"
err = db.RetireXPub(feeXPub2)
if err != nil {
t.Fatalf("retiring xpub failed: %v", err)
}
// Retrieve the new xpub. Index should be incremented, last addr should be
// reset to 0, key should not be retired.
retrievedXPub, err := db.FeeXPub()
if err != nil {
t.Fatalf("error getting fee xpub: %v", err)
}
if retrievedXPub.Key != feeXPub2 {
t.Fatalf("expected fee xpub %q, got %q", feeXPub2, retrievedXPub.Key)
}
if retrievedXPub.ID != 1 {
t.Fatalf("expected xpub ID 1, got %d", retrievedXPub.ID)
}
if retrievedXPub.LastUsedIdx != 0 {
t.Fatalf("expected xpub last used 0, got %d", retrievedXPub.LastUsedIdx)
}
if retrievedXPub.Retired != 0 {
t.Fatalf("expected xpub retirement 0, got %d", retrievedXPub.Retired)
}
// Old xpub should have retired field set.
xpubs, err := db.AllXPubs()
if err != nil {
t.Fatalf("error getting all fee xpubs: %v", err)
}
if xpubs[0].Retired == 0 {
t.Fatalf("old xpub retired field not set")
}
}