diff --git a/cmd/vspadmin/README.md b/cmd/vspadmin/README.md index e5727a74..fe6497c1 100644 --- a/cmd/vspadmin/README.md +++ b/cmd/vspadmin/README.md @@ -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 +``` diff --git a/cmd/vspadmin/main.go b/cmd/vspadmin/main.go index 132f28d4..834726f0 100644 --- a/cmd/vspadmin/main.go +++ b/cmd/vspadmin/main.go @@ -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 diff --git a/database/database_test.go b/database/database_test.go index be5bdb58..0bb3fde2 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -78,6 +78,7 @@ func TestDatabase(t *testing.T) { "testFilterTickets": testFilterTickets, "testCountTickets": testCountTickets, "testFeeXPub": testFeeXPub, + "testRetireFeeXPub": testRetireFeeXPub, "testDeleteTicket": testDeleteTicket, "testVoteChangeRecords": testVoteChangeRecords, "testHTTPBackup": testHTTPBackup, diff --git a/database/feexpub.go b/database/feexpub.go index 201924c0..213a55e6 100644 --- a/database/feexpub.go +++ b/database/feexpub.go @@ -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) { diff --git a/database/feexpub_test.go b/database/feexpub_test.go index 3d470de1..231bae72 100644 --- a/database/feexpub_test.go +++ b/database/feexpub_test.go @@ -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") + } +}