Skip to content

Commit

Permalink
Add client module (#359)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
jholdstock authored Nov 28, 2022
1 parent 5d7d347 commit 4acd57e
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 0 deletions.
251 changes: 251 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions client/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
31 changes: 31 additions & 0 deletions client/go.sum
Original file line number Diff line number Diff line change
@@ -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=

0 comments on commit 4acd57e

Please sign in to comment.