From 4da873b41820a2e7199d78f67b45f028f99068aa Mon Sep 17 00:00:00 2001 From: ukane-philemon Date: Mon, 24 Jan 2022 08:24:50 +0100 Subject: [PATCH] Add manual ticket search feature. --- config.go | 6 ++ vspd.go | 1 + webapi/admin.go | 8 ++ webapi/helpers.go | 85 +++++++++++++-- webapi/middleware.go | 192 +++++++++++++++++++++++---------- webapi/templates/homepage.html | 2 +- webapi/templates/ticket.html | 39 +++++++ webapi/ticket.go | 72 +++++++++++++ webapi/webapi.go | 4 + 9 files changed, 341 insertions(+), 68 deletions(-) create mode 100644 webapi/templates/ticket.html create mode 100644 webapi/ticket.go diff --git a/config.go b/config.go index 155a10e9..62794716 100644 --- a/config.go +++ b/config.go @@ -64,6 +64,7 @@ type config struct { VspClosedMsg string `long:"vspclosedmsg" ini-name:"vspclosedmsg" description:"A short message displayed on the webpage and returned by the status API endpoint if vspclosed is true."` AdminPass string `long:"adminpass" ini-name:"adminpass" description:"Password for accessing admin page."` Designation string `long:"designation" ini-name:"designation" description:"Short name for the VSP. Customizes the logo in the top toolbar."` + vspURL string `long:"vspURL" ini-name:"vspURL" description:"URL of the VSP."` // The following flags should be set on CLI only, not via config file. ShowVersion bool `long:"version" no-ini:"true" description:"Display version information and exit."` @@ -297,6 +298,11 @@ func loadConfig() (*config, error) { cfg.VspClosedMsg = "" } + // Ensure the VSP url is set. + if cfg.vspURL == "" { + return nil, errors.New("the vspURL option is not set") + } + // Ensure the support email address is set. if cfg.SupportEmail == "" { return nil, errors.New("the supportemail option is not set") diff --git a/vspd.go b/vspd.go index f9922e15..e538f87c 100644 --- a/vspd.go +++ b/vspd.go @@ -98,6 +98,7 @@ func run(ctx context.Context) error { SupportEmail: cfg.SupportEmail, VspClosed: cfg.VspClosed, VspClosedMsg: cfg.VspClosedMsg, + VspURL: cfg.vspURL, AdminPass: cfg.AdminPass, Debug: cfg.WebServerDebug, Designation: cfg.Designation, diff --git a/webapi/admin.go b/webapi/admin.go index ecd0f145..1da530f5 100644 --- a/webapi/admin.go +++ b/webapi/admin.go @@ -155,6 +155,14 @@ func adminPage(c *gin.Context) { func ticketSearch(c *gin.Context) { hash := c.PostForm("hash") + // Before hitting the db, ensure this is a valid ticket hash. Ignore bool. + _, err := validateTicketHash(c, hash) + if err != nil { + log.Error(err) + c.String(http.StatusBadRequest, "invalid ticket hash") + return + } + ticket, found, err := db.GetTicketByHash(hash) if err != nil { log.Errorf("db.GetTicketByHash error (ticketHash=%s): %v", hash, err) diff --git a/webapi/helpers.go b/webapi/helpers.go index 201f9548..49b158b3 100644 --- a/webapi/helpers.go +++ b/webapi/helpers.go @@ -10,9 +10,11 @@ import ( "fmt" "github.com/decred/dcrd/blockchain/stake/v4" + "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/wire" + "github.com/decred/vspd/rpc" "github.com/gin-gonic/gin" ) @@ -52,17 +54,26 @@ agendaLoop: return nil } -func validateSignature(reqBytes []byte, commitmentAddress string, c *gin.Context) error { - // Ensure a signature is provided. - signature := c.GetHeader("VSP-Client-Signature") - if signature == "" { - return errors.New("no VSP-Client-Signature header") - } +func validateSignature(hash, commitmentAddress, signature, message string, c *gin.Context) error { + firstErr := dcrutil.VerifyMessage(commitmentAddress, signature, message, cfg.NetParams) + if firstErr != nil { + // Don't return an error straight away if sig validation fails - + // first check if we have an alternate sign address for this ticket. + altSigData, err := db.AltSignAddrData(hash) + if err != nil { + return fmt.Errorf("db.AltSignAddrData failed (ticketHash=%s): %v", hash, err) - err := dcrutil.VerifyMessage(commitmentAddress, signature, string(reqBytes), cfg.NetParams) - if err != nil { - return err + } + + // If we have no alternate sign address, or if validating with the + // alt sign addr fails, return an error to the client. + if altSigData == nil || dcrutil.VerifyMessage(altSigData.AltSignAddr, signature, message, cfg.NetParams) != nil { + return fmt.Errorf("Bad signature (clientIP=%s, ticketHash=%s)", c.ClientIP(), hash) + } + + return firstErr } + return nil } @@ -88,3 +99,59 @@ func isValidTicket(tx *wire.MsgTx) error { return nil } + +// validateTicketHash ensures the provided ticket hash is a valid ticket hash. +// A ticket hash should be 64 chars (MaxHashStringSize) and should parse into +// a chainhash.Hash without error. +func validateTicketHash(c *gin.Context, hash string) (bool, error) { + if len(hash) != chainhash.MaxHashStringSize { + return false, fmt.Errorf("Incorrect hash length (clientIP=%s): got %d, expected %d", c.ClientIP(), len(hash), chainhash.MaxHashStringSize) + + } + _, err := chainhash.NewHashFromStr(hash) + if err != nil { + return false, fmt.Errorf("Invalid hash (clientIP=%s): %v", c.ClientIP(), err) + + } + + return true, nil +} + +// getCommitmentAddress gets the commitment address of the provided ticket hash +// from the chain. +func getCommitmentAddress(c *gin.Context, hash string) (string, bool, error) { + var commitmentAddress string + dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC) + dcrdErr := c.MustGet(dcrdErrorKey) + if dcrdErr != nil { + return commitmentAddress, false, fmt.Errorf("could not get dcrd client: %v", dcrdErr.(error)) + + } + + resp, err := dcrdClient.GetRawTransaction(hash) + if err != nil { + return commitmentAddress, false, fmt.Errorf("dcrd.GetRawTransaction for ticket failed (ticketHash=%s): %v", hash, err) + + } + + msgTx, err := decodeTransaction(resp.Hex) + if err != nil { + return commitmentAddress, false, fmt.Errorf("Failed to decode ticket hex (ticketHash=%s): %v", hash, err) + + } + + err = isValidTicket(msgTx) + if err != nil { + return commitmentAddress, true, fmt.Errorf("Invalid ticket (clientIP=%s, ticketHash=%s): %v", c.ClientIP(), hash, err) + + } + + addr, err := stake.AddrFromSStxPkScrCommitment(msgTx.TxOut[1].PkScript, cfg.NetParams) + if err != nil { + return commitmentAddress, false, fmt.Errorf("AddrFromSStxPkScrCommitment error (ticketHash=%s): %v", hash, err) + + } + + commitmentAddress = addr.String() + return commitmentAddress, false, nil +} diff --git a/webapi/middleware.go b/webapi/middleware.go index 0b66e63c..ec0885b5 100644 --- a/webapi/middleware.go +++ b/webapi/middleware.go @@ -6,13 +6,14 @@ package webapi import ( "bytes" + "encoding/base64" "errors" + "fmt" "io" "net/http" "strings" + "time" - "github.com/decred/dcrd/blockchain/stake/v4" - "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/vspd/rpc" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" @@ -20,6 +21,10 @@ import ( "github.com/jrick/wsrpc/v2" ) +// TicketSearchMessageFmt is the format for the message to be signed +// in order to search for a ticket using the vspd frontend. +const TicketSearchMessageFmt = "I want to check vspd ticket status for ticket %s on VSP %s on block window %d." + // withSession middleware adds a gorilla session to the request context for // downstream handlers to make use of. Sessions are used by admin pages to // maintain authentication status. @@ -287,17 +292,9 @@ func vspAuth() gin.HandlerFunc { hash := request.TicketHash // Before hitting the db or any RPC, ensure this is a valid ticket hash. - // A ticket hash should be 64 chars (MaxHashStringSize) and should parse - // into a chainhash.Hash without error. - if len(hash) != chainhash.MaxHashStringSize { - log.Errorf("%s: Incorrect hash length (clientIP=%s): got %d, expected %d", - funcName, c.ClientIP(), len(hash), chainhash.MaxHashStringSize) - sendErrorWithMsg("invalid ticket hash", errBadRequest, c) - return - } - _, err = chainhash.NewHashFromStr(hash) - if err != nil { - log.Errorf("%s: Invalid hash (clientIP=%s): %v", funcName, c.ClientIP(), err) + validticket, err := validateTicketHash(c, hash) + if !validticket { + log.Errorf("%s: %v", funcName, err) sendErrorWithMsg("invalid ticket hash", errBadRequest, c) return } @@ -313,74 +310,153 @@ func vspAuth() gin.HandlerFunc { // If the ticket was found in the database, we already know its // commitment address. Otherwise we need to get it from the chain. var commitmentAddress string + var isInvalid bool + if ticketFound { commitmentAddress = ticket.CommitmentAddress } else { - dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC) - dcrdErr := c.MustGet(dcrdErrorKey) - if dcrdErr != nil { - log.Errorf("%s: could not get dcrd client: %v", funcName, dcrdErr.(error)) - sendError(errInternalError, c) - return - } - - resp, err := dcrdClient.GetRawTransaction(hash) + commitmentAddress, isInvalid, err = getCommitmentAddress(c, hash) if err != nil { - log.Errorf("%s: dcrd.GetRawTransaction for ticket failed (ticketHash=%s): %v", funcName, hash, err) - sendError(errInternalError, c) + if isInvalid { + sendError(errInvalidTicket, c) + } else { + sendError(errInternalError, c) + } + log.Errorf("%s: %v", funcName, err) return } + } - msgTx, err := decodeTransaction(resp.Hex) - if err != nil { - log.Errorf("%s: Failed to decode ticket hex (ticketHash=%s): %v", funcName, ticket.Hash, err) - sendError(errInternalError, c) - return - } + // Ensure a signature is provided. + signature := c.GetHeader("VSP-Client-Signature") + if signature == "" { + sendErrorWithMsg("no VSP-Client-Signature header", errBadRequest, c) + return + } - err = isValidTicket(msgTx) - if err != nil { - log.Warnf("%s: Invalid ticket (clientIP=%s, ticketHash=%s): %v", funcName, c.ClientIP(), hash, err) - sendError(errInvalidTicket, c) - return - } + // Validate request signature to ensure ticket ownership. + err = validateSignature(hash, commitmentAddress, signature, string(reqBytes), c) + if err != nil { + log.Errorf("%s: %v", funcName, err) + sendError(errBadSignature, c) + return + } - addr, err := stake.AddrFromSStxPkScrCommitment(msgTx.TxOut[1].PkScript, cfg.NetParams) - if err != nil { - log.Errorf("%s: AddrFromSStxPkScrCommitment error (ticketHash=%s): %v", funcName, hash, err) - sendError(errInternalError, c) - return - } + // Add ticket information to context so downstream handlers don't need + // to access the db for it. + c.Set(ticketKey, ticket) + c.Set(knownTicketKey, ticketFound) + c.Set(commitmentAddressKey, commitmentAddress) + } + +} + +// ticketSearchAuth middleware reads the request form body and extracts the +// ticket hash and signature from the base64 string provided. The commitment +// address for the ticket is retrieved from the database if it is known, or +// it is retrieved from the chain if not. +// The middleware errors out if required information is not provided or the +// signature does not contain a message signed with the commitment +// address. Ticket information is added to the request context for downstream +// handlers to use. +func ticketSearchAuth() gin.HandlerFunc { + return func(c *gin.Context) { + funcName := "ticketSearchAuth" + + encodedString := c.PostForm("encoded") + + // Get information added to context. + dcrdClient := c.MustGet(dcrdKey).(*rpc.DcrdRPC) + dcrdErr := c.MustGet(dcrdErrorKey) + if dcrdErr != nil { + log.Warnf("%s: %v", funcName, dcrdErr.(error)) + c.Set(errorKey, errInternalError) + return + } - commitmentAddress = addr.String() + currentBlockHeader, err := dcrdClient.GetBestBlockHeader() + if err != nil { + log.Errorf("%s: Error getting best block header : %v", funcName, err) + c.Set(errorKey, errInternalError) + return } - // Validate request signature to ensure ticket ownership. - err = validateSignature(reqBytes, commitmentAddress, c) + // Average blocks per day for the current network. + blocksPerDay := (24 * time.Hour) / cfg.NetParams.TargetTimePerBlock + blockWindow := int(currentBlockHeader.Height) / int(blocksPerDay) + + decodedByte, err := base64.StdEncoding.DecodeString(encodedString) + if err != nil { + log.Errorf("%s: Decoding form data error : %v", funcName, err) + c.Set(errorKey, errBadRequest) + return + } + + data := strings.Split(string(decodedByte), ":") + if len(data) != 2 { + c.Set(errorKey, errBadRequest) + return + } + + ticketHash := data[0] + signature := data[1] + vspURL := cfg.VspURL + messageSigned := fmt.Sprintf(TicketSearchMessageFmt, ticketHash, vspURL, blockWindow) + + // Before hitting the db or any RPC, ensure this is a valid ticket hash. + validticket, err := validateTicketHash(c, ticketHash) + if !validticket { + log.Errorf("%s: %v", funcName, err) + c.Set(errorKey, errInvalidTicket) + return + } + + // Check if this ticket already appears in the database. + ticket, ticketFound, err := db.GetTicketByHash(ticketHash) if err != nil { - // Don't return an error straight away if sig validation fails - - // first check if we have an alternate sign address for this ticket. - altSigData, err := db.AltSignAddrData(hash) + log.Errorf("%s: db.GetTicketByHash error (ticketHash=%s): %v", funcName, ticketHash, err) + c.Set(errorKey, errInternalError) + return + } + + if !ticketFound { + log.Warnf("%s: Unknown ticket (clientIP=%s)", funcName, c.ClientIP()) + c.Set(errorKey, errUnknownTicket) + return + } + + // If the ticket was found in the database, we already know its + // commitment address. Otherwise we need to get it from the chain. + var commitmentAddress string + var isInvalid bool + if ticketFound { + commitmentAddress = ticket.CommitmentAddress + } else { + commitmentAddress, isInvalid, err = getCommitmentAddress(c, ticketHash) if err != nil { - log.Errorf("%s: db.AltSignAddrData failed (ticketHash=%s): %v", funcName, hash, err) - sendError(errInternalError, c) + if isInvalid { + c.Set(errorKey, errInvalidTicket) + } else { + c.Set(errorKey, errInternalError) + } + log.Errorf("%s: %v", funcName, err) return } + } - // If we have no alternate sign address, or if validating with the - // alt sign addr fails, return an error to the client. - if altSigData == nil || validateSignature(reqBytes, altSigData.AltSignAddr, c) != nil { - log.Warnf("%s: Bad signature (clientIP=%s, ticketHash=%s)", funcName, c.ClientIP(), hash) - sendError(errBadSignature, c) - return - } + // Validate request signature to ensure ticket ownership. + err = validateSignature(ticketHash, commitmentAddress, signature, messageSigned, c) + if err != nil { + log.Errorf("%s: %v", funcName, err) + c.Set(errorKey, errBadSignature) + return } // Add ticket information to context so downstream handlers don't need // to access the db for it. c.Set(ticketKey, ticket) c.Set(knownTicketKey, ticketFound) - c.Set(commitmentAddressKey, commitmentAddress) + c.Set(errorKey, nil) } } diff --git a/webapi/templates/homepage.html b/webapi/templates/homepage.html index 0a2d1e6d..24e79fa1 100644 --- a/webapi/templates/homepage.html +++ b/webapi/templates/homepage.html @@ -22,7 +22,7 @@

VSP Overview

A Voting Service Provider (VSP) maintains a pool of always-online voting wallets, and allows Decred ticket holders to use these wallets to vote their tickets in exchange for a small fee. - VSPs are completely non-custodial - they never hold, manage, or have access to any user funds. + VSPs are completely non-custodial - they never hold, manage, or have access to any user funds. Click here to search tickets.
Visit docs.decred.org to find out more about VSPs, tickets, and voting.

diff --git a/webapi/templates/ticket.html b/webapi/templates/ticket.html new file mode 100644 index 00000000..53203eba --- /dev/null +++ b/webapi/templates/ticket.html @@ -0,0 +1,39 @@ +{{ template "header" . }} + +
+
+ +
+

Ticket Search

+
+
+
+ +
+
+
+
+
+
+

+ {{.Error}}

+ +
+ + +
+ +
+ + {{ with .SearchResult }} + {{ template "ticket-search-result" . }} + {{ end }} +
+
+
+
+
+ + +{{ template "footer" . }} diff --git a/webapi/ticket.go b/webapi/ticket.go new file mode 100644 index 00000000..ef6dfcb3 --- /dev/null +++ b/webapi/ticket.go @@ -0,0 +1,72 @@ +// Copyright (c) 2022 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package webapi + +import ( + "net/http" + + "github.com/decred/vspd/database" + "github.com/gin-gonic/gin" +) + +// ticketPage is the handler for "GET /ticket" +func ticketPage(c *gin.Context) { + c.HTML(http.StatusOK, "ticket.html", gin.H{ + "WebApiCache": getCache(), + "WebApiCfg": cfg, + }) +} + +// ticketErrPage returns error message to the ticket page. +func ticketErrPage(c *gin.Context, status int, message string) { + c.HTML(status, "ticket.html", gin.H{ + "WebApiCache": getCache(), + "WebApiCfg": cfg, + "Error": message, + }) + +} + +// manualTicketSearch is the handler for "POST /ticket". +func manualTicketSearch(c *gin.Context) { + + // Get values which have been added to context by middleware. + err := c.MustGet(errorKey) + if err != nil { + apiErr := err.(apiError) + ticketErrPage(c, apiErr.httpStatus(), apiErr.defaultMessage()) + return + } + + ticket := c.MustGet(ticketKey).(database.Ticket) + knownTicket := c.MustGet(knownTicketKey).(bool) + + voteChanges, err := db.GetVoteChanges(ticket.Hash) + if err != nil { + log.Errorf("db.GetVoteChanges error (ticket=%s): %v", ticket.Hash, err) + ticketErrPage(c, http.StatusBadRequest, "Error getting vote changes from database") + return + } + + altSignAddrData, err := db.AltSignAddrData(ticket.Hash) + if err != nil { + log.Errorf("db.AltSignAddrData error (ticket=%s): %v", ticket.Hash, err) + ticketErrPage(c, http.StatusBadRequest, "Error getting alternate signature from database") + return + } + + c.HTML(http.StatusOK, "ticket.html", gin.H{ + "SearchResult": searchResult{ + Hash: ticket.Hash, + Found: knownTicket, + Ticket: ticket, + AltSignAddrData: altSignAddrData, + VoteChanges: voteChanges, + MaxVoteChanges: cfg.MaxVoteChangeRecords, + }, + "WebApiCache": getCache(), + "WebApiCfg": cfg, + }) +} diff --git a/webapi/webapi.go b/webapi/webapi.go index c5083b95..2094889a 100644 --- a/webapi/webapi.go +++ b/webapi/webapi.go @@ -33,6 +33,7 @@ type Config struct { SupportEmail string VspClosed bool VspClosedMsg string + VspURL string AdminPass string Debug bool Designation string @@ -61,6 +62,7 @@ const ( ticketKey = "Ticket" knownTicketKey = "KnownTicket" commitmentAddressKey = "CommitmentAddress" + errorKey = "Error" ) var cfg Config @@ -237,6 +239,8 @@ func router(debugMode bool, cookieSecret []byte, dcrd rpc.DcrdConnect, wallets r // Website routes. router.GET("", homepage) + router.GET("/ticket", ticketPage) + router.POST("/ticket", withDcrdClient(dcrd), ticketSearchAuth(), manualTicketSearch) login := router.Group("/admin").Use( withSession(cookieStore),