diff --git a/webapi/admin.go b/webapi/admin.go index 5cd4c598..546e8464 100644 --- a/webapi/admin.go +++ b/webapi/admin.go @@ -155,6 +155,13 @@ func (s *Server) adminPage(c *gin.Context) { func (s *Server) ticketSearch(c *gin.Context) { hash := c.PostForm("hash") + // Before hitting the db, ensure this is a valid ticket hash. Ignore bool. + if err := validateTicketHash(hash); err != nil { + s.log.Errorf("ticketSearch: Invalid ticket hash (ticketHash=%s): %v", hash, err) + c.String(http.StatusBadRequest, "invalid ticket hash") + return + } + ticket, found, err := s.db.GetTicketByHash(hash) if err != nil { s.log.Errorf("db.GetTicketByHash error (ticketHash=%s): %v", hash, err) diff --git a/webapi/middleware.go b/webapi/middleware.go index f2248b5d..f01fa036 100644 --- a/webapi/middleware.go +++ b/webapi/middleware.go @@ -6,10 +6,13 @@ package webapi import ( "bytes" + "encoding/base64" "errors" + "fmt" "io" "net/http" "strings" + "time" "github.com/decred/vspd/rpc" "github.com/gin-gonic/gin" @@ -18,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 at vsp with pubkey %s on 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. @@ -349,3 +356,111 @@ func (s *Server) vspAuth(c *gin.Context) { 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 (s *Server) ticketSearchAuth(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 { + s.log.Errorf("%s: Could not get dcrd client: %v", funcName, dcrdErr.(error)) + c.Set(errorKey, errInternalError) + return + } + + currentBlockHeader, err := dcrdClient.GetBestBlockHeader() + if err != nil { + s.log.Errorf("%s: Error getting best block header : %v", funcName, err) + c.Set(errorKey, errInternalError) + // Average blocks per day for the current network. + blocksPerDay := (24 * time.Hour) / s.cfg.NetParams.TargetTimePerBlock + blockWindow := int(currentBlockHeader.Height) / int(blocksPerDay) + + decodedByte, err := base64.StdEncoding.DecodeString(encodedString) + if err != nil { + s.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] + vspPublicKey := s.cache.data.PubKey + messageSigned := fmt.Sprintf(TicketSearchMessageFmt, ticketHash, vspPublicKey, blockWindow) + + // Before hitting the db or any RPC, ensure this is a valid ticket hash. + err = validateTicketHash(ticketHash) + if err != nil { + s.log.Errorf("%s: Invalid ticket (clientIP=%s): %v", funcName, c.ClientIP(), err) + c.Set(errorKey, errInvalidTicket) + return + } + + // Check if this ticket already appears in the database. + ticket, ticketFound, err := s.db.GetTicketByHash(ticketHash) + if err != nil { + s.log.Errorf("%s: db.GetTicketByHash error (ticketHash=%s): %v", funcName, ticketHash, err) + c.Set(errorKey, errInternalError) + return + } + + if !ticketFound { + s.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 + if ticketFound { + commitmentAddress = ticket.CommitmentAddress + } else { + commitmentAddress, err = getCommitmentAddress(ticketHash, dcrdClient, s.cfg.NetParams) + if err != nil { + s.log.Errorf("%s: Failed to get commitment address (clientIP=%s, ticketHash=%s): %v", + funcName, c.ClientIP(), ticketHash, err) + + var apiErr *apiError + if errors.Is(err, apiErr) { + c.Set(errorKey, errInvalidTicket) + } else { + c.Set(errorKey, errInternalError) + } + + return + } + } + + // Validate request signature to ensure ticket ownership. + err = validateSignature(ticketHash, commitmentAddress, signature, messageSigned, s.db, s.cfg.NetParams) + if err != nil { + s.log.Errorf("%s: Couldn't validate signature (clientIP=%s, ticketHash=%s): %v", + funcName, c.ClientIP(), ticketHash, 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(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..bf712ee8 --- /dev/null +++ b/webapi/ticket.go @@ -0,0 +1,71 @@ +// 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 (s *Server) ticketPage(c *gin.Context) { + c.HTML(http.StatusOK, "ticket.html", gin.H{ + "WebApiCache": s.cache.getData(), + "WebApiCfg": s.cfg, + }) +} + +// ticketErrPage returns error message to the ticket page. +func (s *Server) ticketErrPage(c *gin.Context, status int, message string) { + c.HTML(status, "ticket.html", gin.H{ + "WebApiCache": s.cache.getData(), + "WebApiCfg": s.cfg, + "Error": message, + }) + +} + +// manualTicketSearch is the handler for "POST /ticket". +func (s *Server) manualTicketSearch(c *gin.Context) { + // Get values which have been added to context by middleware. + err := c.MustGet(errorKey) + if err != nil { + apiErr := err.(apiError) + s.ticketErrPage(c, apiErr.httpStatus(), apiErr.String()) + return + } + + ticket := c.MustGet(ticketKey).(database.Ticket) + knownTicket := c.MustGet(knownTicketKey).(bool) + + voteChanges, err := s.db.GetVoteChanges(ticket.Hash) + if err != nil { + s.log.Errorf("db.GetVoteChanges error (ticket=%s): %v", ticket.Hash, err) + s.ticketErrPage(c, http.StatusBadRequest, "Error getting vote changes from database") + return + } + + altSignAddrData, err := s.db.AltSignAddrData(ticket.Hash) + if err != nil { + s.log.Errorf("db.AltSignAddrData error (ticket=%s): %v", ticket.Hash, err) + s.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: s.cfg.MaxVoteChangeRecords, + }, + "WebApiCache": s.cache.getData(), + "WebApiCfg": s.cfg, + }) +} diff --git a/webapi/webapi.go b/webapi/webapi.go index ae06e653..0a4596ec 100644 --- a/webapi/webapi.go +++ b/webapi/webapi.go @@ -62,6 +62,7 @@ const ( ticketKey = "Ticket" knownTicketKey = "KnownTicket" commitmentAddressKey = "CommitmentAddress" + errorKey = "Error" ) type Server struct { @@ -244,6 +245,8 @@ func (s *Server) router(cookieSecret []byte, dcrd rpc.DcrdConnect, wallets rpc.W // Website routes. router.GET("", s.homepage) + router.GET("/ticket", s.ticketPage) + router.POST("/ticket", s.withDcrdClient(dcrd), s.ticketSearchAuth, s.manualTicketSearch) login := router.Group("/admin").Use( s.withSession(cookieStore),