From 4acd57efbcc527622bfc60b7a0bf5b0df2d1c6cc Mon Sep 17 00:00:00 2001 From: Jamie Holdstock Date: Tue, 29 Nov 2022 00:16:00 +0800 Subject: [PATCH] Add client module (#359) * Add vspd client from dcrwallet. * client: Extract and export ValidateServerSignature * client: Add helper funcs. * client: Add trace logging. * client: Use existing error types. * Add comments to explain error handling logic. --- client/client.go | 251 +++++++++++++++++++++++++++++++++++++++++++++++ client/go.mod | 22 +++++ client/go.sum | 31 ++++++ 3 files changed, 304 insertions(+) create mode 100644 client/client.go create mode 100644 client/go.mod create mode 100644 client/go.sum diff --git a/client/client.go b/client/client.go new file mode 100644 index 00000000..a9da6a2f --- /dev/null +++ b/client/client.go @@ -0,0 +1,251 @@ +package client + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httputil" + + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/slog" + "github.com/decred/vspd/types" +) + +type Client struct { + http.Client + URL string + PubKey []byte + sign func(context.Context, string, stdaddr.Address) ([]byte, error) + log slog.Logger +} + +type Signer interface { + SignMessage(ctx context.Context, message string, address stdaddr.Address) ([]byte, error) +} + +func NewClient(url string, pub []byte, s Signer, log slog.Logger) *Client { + return &Client{ + URL: url, + PubKey: pub, + sign: s.SignMessage, + log: log, + } +} + +func (c *Client) VspInfo(ctx context.Context) (*types.VspInfoResponse, error) { + var resp *types.VspInfoResponse + err := c.get(ctx, "/api/v3/vspinfo", &resp) + if err != nil { + return nil, err + } + return resp, nil +} + +func (c *Client) FeeAddress(ctx context.Context, req types.FeeAddressRequest, + commitmentAddr stdaddr.Address) (*types.FeeAddressResponse, error) { + + requestBody, err := json.Marshal(req) + if err != nil { + return nil, err + } + + var resp *types.FeeAddressResponse + err = c.post(ctx, "/api/v3/feeaddress", commitmentAddr, &resp, json.RawMessage(requestBody)) + if err != nil { + return nil, err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + return nil, fmt.Errorf("server response contains differing request") + } + + return resp, nil +} + +func (c *Client) PayFee(ctx context.Context, req types.PayFeeRequest, + commitmentAddr stdaddr.Address) (*types.PayFeeResponse, error) { + + requestBody, err := json.Marshal(req) + if err != nil { + return nil, err + } + + var resp *types.PayFeeResponse + err = c.post(ctx, "/api/v3/payfee", commitmentAddr, &resp, json.RawMessage(requestBody)) + if err != nil { + return nil, err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + return nil, fmt.Errorf("server response contains differing request") + } + + return resp, nil +} + +func (c *Client) TicketStatus(ctx context.Context, req types.TicketStatusRequest, + commitmentAddr stdaddr.Address) (*types.TicketStatusResponse, error) { + + requestBody, err := json.Marshal(req) + if err != nil { + return nil, err + } + + var resp *types.TicketStatusResponse + err = c.post(ctx, "/api/v3/ticketstatus", commitmentAddr, &resp, json.RawMessage(requestBody)) + if err != nil { + return nil, err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + return nil, fmt.Errorf("server response contains differing request") + } + + return resp, nil +} + +func (c *Client) SetVoteChoices(ctx context.Context, req types.SetVoteChoicesRequest, + commitmentAddr stdaddr.Address) (*types.SetVoteChoicesResponse, error) { + + requestBody, err := json.Marshal(req) + if err != nil { + return nil, err + } + + var resp *types.SetVoteChoicesResponse + err = c.post(ctx, "/api/v3/setvotechoices", commitmentAddr, &resp, json.RawMessage(requestBody)) + if err != nil { + return nil, err + } + + // verify initial request matches server + if !bytes.Equal(requestBody, resp.Request) { + return nil, fmt.Errorf("server response contains differing request") + } + + return resp, nil +} + +func (c *Client) post(ctx context.Context, path string, addr stdaddr.Address, resp, req interface{}) error { + return c.do(ctx, http.MethodPost, path, addr, resp, req) +} + +func (c *Client) get(ctx context.Context, path string, resp interface{}) error { + return c.do(ctx, http.MethodGet, path, nil, resp, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, addr stdaddr.Address, resp, req interface{}) error { + var reqBody io.Reader + var sig []byte + + sendBody := method == http.MethodPost + if sendBody { + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + sig, err = c.sign(ctx, string(body), addr) + if err != nil { + return fmt.Errorf("sign request: %w", err) + } + reqBody = bytes.NewReader(body) + } + + httpReq, err := http.NewRequestWithContext(ctx, method, c.URL+path, reqBody) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + if sig != nil { + httpReq.Header.Set("VSP-Client-Signature", base64.StdEncoding.EncodeToString(sig)) + } + + if c.log.Level() == slog.LevelTrace { + dump, err := httputil.DumpRequestOut(httpReq, sendBody) + if err == nil { + c.log.Tracef("Request to %s\n%s\n", c.URL, dump) + } else { + c.log.Tracef("VSP request dump failed: %v", err) + } + } + + reply, err := c.Do(httpReq) + if err != nil { + return fmt.Errorf("%s %s: %w", method, httpReq.URL.String(), err) + } + defer reply.Body.Close() + + if c.log.Level() == slog.LevelTrace { + dump, err := httputil.DumpResponse(reply, true) + if err == nil { + c.log.Tracef("Response from %s\n%s\n", c.URL, dump) + } else { + c.log.Tracef("VSP response dump failed: %v", err) + } + } + + respBody, err := io.ReadAll(reply.Body) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + status := reply.StatusCode + + if status != http.StatusOK { + // If no response body, return an error with just the HTTP status. + if len(respBody) == 0 { + return fmt.Errorf("http status %d (%s) with no body", + status, http.StatusText(status)) + } + + // Try to unmarshal the response body to a known vspd error. + var apiError types.ErrorResponse + err = json.Unmarshal(respBody, &apiError) + if err == nil { + return apiError + } + + // If the response body could not be unmarshalled it might not have come + // from vspd (eg. it could be from an nginx reverse proxy or some other + // intermediary server). Return an error with the HTTP status and the + // full body so that it may be investigated. + return fmt.Errorf("http status %d (%s) with body %q", + status, http.StatusText(status), respBody) + } + + err = ValidateServerSignature(reply, respBody, c.PubKey) + if err != nil { + return fmt.Errorf("authenticate server response: %v", err) + } + + err = json.Unmarshal(respBody, resp) + if err != nil { + return fmt.Errorf("unmarshal response body: %w", err) + } + + return nil +} + +func ValidateServerSignature(resp *http.Response, body []byte, serverPubkey []byte) error { + sigBase64 := resp.Header.Get("VSP-Server-Signature") + if sigBase64 == "" { + return errors.New("no signature provided") + } + sig, err := base64.StdEncoding.DecodeString(sigBase64) + if err != nil { + return fmt.Errorf("failed to decode signature: %w", err) + } + if !ed25519.Verify(serverPubkey, body, sig) { + return errors.New("invalid signature") + } + + return nil +} diff --git a/client/go.mod b/client/go.mod new file mode 100644 index 00000000..8c9029ab --- /dev/null +++ b/client/go.mod @@ -0,0 +1,22 @@ +module github.com/decred/vspd/client + +go 1.19 + +require ( + github.com/decred/dcrd/txscript/v4 v4.0.0 + github.com/decred/slog v1.2.0 + github.com/decred/vspd/types v1.1.0 +) + +require ( + github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect + github.com/dchest/siphash v1.2.2 // indirect + github.com/decred/base58 v1.0.3 // indirect + github.com/decred/dcrd/chaincfg/chainhash v1.0.3 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect + github.com/decred/dcrd/crypto/ripemd160 v1.0.1 // indirect + github.com/decred/dcrd/dcrec v1.0.0 // indirect + github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/wire v1.5.0 // indirect +) diff --git a/client/go.sum b/client/go.sum new file mode 100644 index 00000000..e50a88d0 --- /dev/null +++ b/client/go.sum @@ -0,0 +1,31 @@ +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.2 h1:9DFz8tQwl9pTVt5iok/9zKyzA1Q6bRGiF3HPiEEVr9I= +github.com/dchest/siphash v1.2.2/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= +github.com/decred/base58 v1.0.3 h1:KGZuh8d1WEMIrK0leQRM47W85KqCAdl2N+uagbctdDI= +github.com/decred/base58 v1.0.3/go.mod h1:pXP9cXCfM2sFLb2viz2FNIdeMWmZDBKG3ZBYbiSM78E= +github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= +github.com/decred/dcrd/chaincfg/chainhash v1.0.3 h1:PF2czcYZGW3dz4i/35AUfVAgnqHl9TMNQt1ADTYGOoE= +github.com/decred/dcrd/chaincfg/chainhash v1.0.3/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= +github.com/decred/dcrd/chaincfg/v3 v3.1.0 h1:u8l+E6ryv8E0WY69pM/lUI36UeAVcLKBwD/Q3xPiuog= +github.com/decred/dcrd/chaincfg/v3 v3.1.0/go.mod h1:4XF9nlx2NeGD4xzw1+L0DGICZMl0a5rKV8nnuHLgk8o= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/ripemd160 v1.0.1 h1:TjRL4LfftzTjXzaufov96iDAkbY2R3aTvH2YMYa1IOc= +github.com/decred/dcrd/crypto/ripemd160 v1.0.1/go.mod h1:F0H8cjIuWTRoixr/LM3REB8obcWkmYx0gbxpQWR8RPg= +github.com/decred/dcrd/dcrec v1.0.0 h1:W+z6Es+Rai3MXYVoPAxYr5U1DGis0Co33scJ6uH2J6o= +github.com/decred/dcrd/dcrec v1.0.0/go.mod h1:HIaqbEJQ+PDzQcORxnqen5/V1FR3B4VpIfmePklt8Q8= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2 h1:bX7rtGTMBDJxujZ29GNqtn7YCAdINjHKnA6J6tBBv6s= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.2/go.mod h1:d0H8xGMWbiIQP7gN3v2rByWUcuZPm9YsgmnfoxgbINc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/txscript/v4 v4.0.0 h1:BwaBUCMCmg58MCYoBhxVjL8ZZKUIfoJuxu/djmh8h58= +github.com/decred/dcrd/txscript/v4 v4.0.0/go.mod h1:OJtxNc5RqwQyfrRnG2gG8uMeNPo8IAJp+TD1UKXkqk8= +github.com/decred/dcrd/wire v1.5.0 h1:3SgcEzSjqAMQvOugP0a8iX7yQSpiVT1yNi9bc4iOXVg= +github.com/decred/dcrd/wire v1.5.0/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQdOIhPY3w= +github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= +github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= +github.com/decred/vspd/types v1.1.0 h1:hTeqQwgRUN2FGIbuCIdyzBejKV+jxKrmEIcLKxpsB1g= +github.com/decred/vspd/types v1.1.0/go.mod h1:THsO8aBSwWBq6ZsIG25cNqbkNb+EEASXzLhFvODVc0s=