Skip to content

Commit

Permalink
CCQ: Server allow list (#3347)
Browse files Browse the repository at this point in the history
  • Loading branch information
bruce-riley committed Sep 5, 2023
1 parent 2627973 commit b2e7700
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 12 deletions.
2 changes: 2 additions & 0 deletions devnet/query-server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ spec:
- node/hack/query/querier.key
- --listenAddr
- "[::]:6069"
- --permFile
- "node/cmd/ccq/devnet.config.json"
- --ethRPC
- http://eth-devnet:8545
- --ethContract
Expand Down
64 changes: 64 additions & 0 deletions node/cmd/ccq/devnet.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"permissions": [
{
"userName": "Test User",
"apiKey": "my_secret_key",
"allowedCalls": [
{
"ethCall": {
"note:": "Name of WETH on Goerli",
"chain": 2,
"contractAddress": "B4FBF271143F4FBf7B91A5ded31805e42b2208d6",
"call": "0x06fdde03"
}
},
{
"ethCall": {
"note:": "Total supply of WETH on Goerli",
"chain": 2,
"contractAddress": "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6",
"call": "0x18160ddd"
}
},
{
"ethCall": {
"note:": "Name of WETH on Devnet",
"chain": 2,
"contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"call": "0x06fdde03"
}
},
{
"ethCall": {
"note:": "Total supply of WETH on Devnet",
"chain": 2,
"contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"call": "0x18160ddd"
}
}
]
},
{
"userName": "Test User Two",
"apiKey": "my_secret_key_2",
"allowedCalls": [
{
"ethCall": {
"note:": "Name of WETH on Goerli",
"chain": 2,
"contractAddress": "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6",
"call": "0x06fdde03"
}
},
{
"ethCall": {
"note:": "Name of WETH on Devnet",
"chain": 2,
"contractAddress": "0xDDb64fE46a91D46ee29420539FC25FD07c5FEa3E",
"call": "0x06fdde03"
}
}
]
}
]
}
28 changes: 24 additions & 4 deletions node/cmd/ccq/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import (
"github.com/certusone/wormhole/node/pkg/query"
"github.com/gorilla/mux"
pubsub "github.com/libp2p/go-libp2p-pubsub"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
)

type queryRequest struct {
ApiKey string `json:"api_key"`
Bytes string `json:"bytes"`
Signature string `json:"signature"`
}
Expand All @@ -27,6 +29,8 @@ type queryResponse struct {

type httpServer struct {
topic *pubsub.Topic
logger *zap.Logger
permissions Permissions
pendingResponses *PendingResponses
}

Expand All @@ -38,16 +42,24 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) {
return
}

// There should be one and only one API key in the header.
apiKey, exists := r.Header["X-Api-Key"]
if !exists || len(apiKey) != 1 {
s.logger.Debug("received a request without an api key", zap.Stringer("url", r.URL), zap.Error(err))
http.Error(w, "api key is missing", http.StatusBadRequest)
return
}

queryRequestBytes, err := hex.DecodeString(q.Bytes)
if err != nil {
s.logger.Debug("failed to decode request bytes", zap.Error(err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

// TODO: check if request signer is authorized on Wormchain

signature, err := hex.DecodeString(q.Signature)
if err != nil {
s.logger.Debug("failed to decode signature bytes", zap.Error(err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
Expand All @@ -57,7 +69,11 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) {
Signature: signature,
}

// TODO: validate request before publishing
if err := validateRequest(s.logger, s.permissions, apiKey[0], signedQueryRequest); err != nil {
s.logger.Debug("invalid request", zap.String("api_key", apiKey[0]), zap.Error(err))
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

m := gossipv1.GossipMessage{
Message: &gossipv1.GossipMessage_SignedQueryRequest{
Expand All @@ -67,6 +83,7 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) {

b, err := proto.Marshal(&m)
if err != nil {
s.logger.Debug("failed to marshal gossip message", zap.Error(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
Expand All @@ -80,6 +97,7 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) {

err = s.topic.Publish(r.Context(), b)
if err != nil {
s.logger.Debug("failed to publish gossip message", zap.Error(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
s.pendingResponses.Remove(pendingResponse)
return
Expand Down Expand Up @@ -118,10 +136,12 @@ func (s *httpServer) handleQuery(w http.ResponseWriter, r *http.Request) {
s.pendingResponses.Remove(pendingResponse)
}

func NewHTTPServer(addr string, t *pubsub.Topic, p *PendingResponses) *http.Server {
func NewHTTPServer(addr string, t *pubsub.Topic, permissions Permissions, p *PendingResponses, logger *zap.Logger) *http.Server {
s := &httpServer{
topic: t,
permissions: permissions,
pendingResponses: p,
logger: logger,
}
r := mux.NewRouter()
r.HandleFunc("/v1/query", s.handleQuery).Methods("PUT")
Expand Down
16 changes: 13 additions & 3 deletions node/cmd/ccq/query_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ var (
p2pBootstrap *string
listenAddr *string
nodeKeyPath *string
permFile *string
ethRPC *string
ethContract *string
logLevel *string
Expand All @@ -30,10 +31,11 @@ var (

func init() {
p2pNetworkID = QueryServerCmd.Flags().String("network", "/wormhole/dev", "P2P network identifier")
p2pPort = QueryServerCmd.Flags().Uint("port", 8996, "P2P UDP listener port")
p2pPort = QueryServerCmd.Flags().Uint("port", 8995, "P2P UDP listener port")
p2pBootstrap = QueryServerCmd.Flags().String("bootstrap", "", "P2P bootstrap peers (comma-separated)")
listenAddr = QueryServerCmd.Flags().String("listenAddr", "[::]:6069", "Listen address for query server (disabled if blank)")
nodeKeyPath = QueryServerCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)")
listenAddr = QueryServerCmd.Flags().String("listenAddr", "[::]:6069", "Listen address for query server (disabled if blank)")
permFile = QueryServerCmd.Flags().String("permFile", "", "JSON file containing permissions configuration")
ethRPC = QueryServerCmd.Flags().String("ethRPC", "", "Ethereum RPC for fetching current guardian set")
ethContract = QueryServerCmd.Flags().String("ethContract", "", "Ethereum core bridge address for fetching current guardian set")
logLevel = QueryServerCmd.Flags().String("logLevel", "info", "Logging level (debug, info, warn, error, dpanic, panic, fatal)")
Expand Down Expand Up @@ -87,13 +89,21 @@ func runQueryServer(cmd *cobra.Command, args []string) {
if *p2pBootstrap == "" {
logger.Fatal("Please specify --bootstrap")
}
if *permFile == "" {
logger.Fatal("Please specify --permFile")
}
if *ethRPC == "" {
logger.Fatal("Please specify --ethRPC")
}
if *ethContract == "" {
logger.Fatal("Please specify --ethContract")
}

permissions, err := parseConfig(*permFile)
if err != nil {
logger.Fatal("Failed to load permissions file", zap.String("permFile", *permFile), zap.Error(err))
}

// Load p2p private key
var priv crypto.PrivKey
priv, err = common.GetOrCreateNodeKey(logger, *nodeKeyPath)
Expand All @@ -113,7 +123,7 @@ func runQueryServer(cmd *cobra.Command, args []string) {

// Start the HTTP server
go func() {
s := NewHTTPServer(*listenAddr, p2p.topic_req, pendingResponses)
s := NewHTTPServer(*listenAddr, p2p.topic_req, permissions, pendingResponses, logger)
logger.Sugar().Infof("Server listening on %s", *listenAddr)
err := s.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
Expand Down
158 changes: 158 additions & 0 deletions node/cmd/ccq/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@ package ccq

import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/certusone/wormhole/node/pkg/common"
gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
"github.com/certusone/wormhole/node/pkg/query"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
"go.uber.org/zap"

ethAbi "github.com/certusone/wormhole/node/pkg/watchers/evm/connectors/ethabi"
ethBind "github.com/ethereum/go-ethereum/accounts/abi/bind"
eth_common "github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -39,3 +49,151 @@ func FetchCurrentGuardianSet(rpcUrl, coreAddr string) (*common.GuardianSet, erro
Index: currentIndex,
}, nil
}

type Config struct {
Permissions []User `json:"Permissions"`
}

type User struct {
UserName string `json:"userName"`
ApiKey string `json:"apiKey"`
AllowedCalls []AllowedCall `json:"allowedCalls"`
}

type AllowedCall struct {
EthCall *EthCall `json:"ethCall"`
}

type EthCall struct {
Chain int `json:"chain"`
ContractAddress string `json:"contractAddress"`
Call string `json:"call"`
}

type Permissions map[string]*permissionEntry

type permissionEntry struct {
userName string
apiKey string
allowedCalls allowedCallsForUser // Key is something like "ethCall:2:000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6:06fdde03"
}

type allowedCallsForUser map[string]struct{}

// parseConfig parses the permissions config file into a map keyed by API key.
func parseConfig(fileName string) (Permissions, error) {
jsonFile, err := os.Open(fileName)
if err != nil {
return nil, fmt.Errorf(`failed to open permissions file "%s": %w`, fileName, err)
}
defer jsonFile.Close()

byteValue, err := io.ReadAll(jsonFile)
if err != nil {
return nil, fmt.Errorf(`failed to read permissions file "%s": %w`, fileName, err)
}

var config Config
if err := json.Unmarshal(byteValue, &config); err != nil {
return nil, fmt.Errorf(`failed to unmarshal json from permissions file "%s": %w`, fileName, err)
}

ret := make(Permissions)
for _, user := range config.Permissions {
apiKey := strings.ToLower(user.ApiKey)
if _, exists := ret[apiKey]; exists {
return nil, fmt.Errorf(`API key "%s" in permissions file "%s" is a duplicate`, apiKey, fileName)
}

// Build the list of allowed calls for this API key.
allowedCalls := make(allowedCallsForUser)
for _, ac := range user.AllowedCalls {
var callKey string
if ac.EthCall != nil {
// Convert the contract address into a standard format like "000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6".
contractAddress, err := vaa.StringToAddress(ac.EthCall.ContractAddress)
if err != nil {
return nil, fmt.Errorf(`invalid contract address "%s" for API key "%s" in permissions file "%s"`, ac.EthCall.ContractAddress, apiKey, fileName)
}

// The call should be the ABI four byte hex hash of the function signature. Parse it into a standard form of "06fdde03".
call, err := hex.DecodeString(strings.TrimPrefix(ac.EthCall.Call, "0x"))
if err != nil {
return nil, fmt.Errorf(`invalid eth call "%s" for API key "%s" in permissions file "%s"`, ac.EthCall.Call, apiKey, fileName)
}
if len(call) != 4 {
return nil, fmt.Errorf(`eth call "%s" for API key "%s" in permissions file "%s" has an invalid length, must be four bytes`, ac.EthCall.Call, apiKey, fileName)
}

// The permission key is the chain, contract address and call formatted as a colon separated string.
callKey = fmt.Sprintf("ethCall:%d:%s:%s", ac.EthCall.Chain, contractAddress, hex.EncodeToString(call))
} else {
return nil, fmt.Errorf(`unsupported call type for API key "%s" in permissions file "%s"`, apiKey, fileName)
}

if _, exists := allowedCalls[callKey]; exists {
return nil, fmt.Errorf(`"%s" is a duplicate allowed call for API key "%s" in permissions file "%s"`, callKey, apiKey, fileName)
}

allowedCalls[callKey] = struct{}{}
}

pe := &permissionEntry{
userName: user.UserName,
apiKey: apiKey,
allowedCalls: allowedCalls,
}

ret[apiKey] = pe
}

return ret, nil
}

// validateRequest verifies that this API key is allowed to do all of the calls in this request.
func validateRequest(logger *zap.Logger, perms Permissions, apiKey string, qr *gossipv1.SignedQueryRequest) error {
apiKey = strings.ToLower(apiKey)
permsForUser, exists := perms[strings.ToLower(apiKey)]
if !exists {
return fmt.Errorf("invalid api key")
}

// TODO: Should we verify the signatures?

var queryRequest query.QueryRequest
err := queryRequest.Unmarshal(qr.QueryRequest)
if err != nil {
return fmt.Errorf("failed to unmarshal request: %w", err)
}

// Make sure the overall query request is sane.
if err := queryRequest.Validate(); err != nil {
return fmt.Errorf("failed to validate request: %w", err)
}

// Make sure they are allowed to make all of the calls that they are asking for.
for _, pcq := range queryRequest.PerChainQueries {
switch q := pcq.Query.(type) {
case *query.EthCallQueryRequest:
for _, callData := range q.CallData {
contractAddress, err := vaa.BytesToAddress(callData.To)
if err != nil {
return fmt.Errorf("failed to parse contract address: %w", err)
}
if len(callData.Data) < 4 {
return fmt.Errorf("eth call data must be at least four bytes")
}
call := hex.EncodeToString(callData.Data)
callKey := fmt.Sprintf("ethCall:%d:%s:%s", int(pcq.ChainId), contractAddress, call)
if _, exists := permsForUser.allowedCalls[callKey]; !exists {
logger.Debug(`api key "%s" has requested an unauthorized call "%s"`)
return fmt.Errorf(`call "%s" not authorized`, callKey)
}
}
default:
return fmt.Errorf("unsupported query type")
}
}

return nil
}
Loading

0 comments on commit b2e7700

Please sign in to comment.