diff --git a/devnet/query-server.yaml b/devnet/query-server.yaml index f5df1bdb40..cb6c36bad5 100644 --- a/devnet/query-server.yaml +++ b/devnet/query-server.yaml @@ -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 diff --git a/node/cmd/ccq/devnet.config.json b/node/cmd/ccq/devnet.config.json new file mode 100644 index 0000000000..50d90e61c8 --- /dev/null +++ b/node/cmd/ccq/devnet.config.json @@ -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" + } + } + ] + } + ] +} diff --git a/node/cmd/ccq/http.go b/node/cmd/ccq/http.go index 128e8a7942..553d603cc2 100644 --- a/node/cmd/ccq/http.go +++ b/node/cmd/ccq/http.go @@ -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"` } @@ -27,6 +29,8 @@ type queryResponse struct { type httpServer struct { topic *pubsub.Topic + logger *zap.Logger + permissions Permissions pendingResponses *PendingResponses } @@ -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 } @@ -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{ @@ -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 } @@ -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 @@ -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") diff --git a/node/cmd/ccq/query_server.go b/node/cmd/ccq/query_server.go index de26c27856..9241280a81 100644 --- a/node/cmd/ccq/query_server.go +++ b/node/cmd/ccq/query_server.go @@ -21,6 +21,7 @@ var ( p2pBootstrap *string listenAddr *string nodeKeyPath *string + permFile *string ethRPC *string ethContract *string logLevel *string @@ -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)") @@ -87,6 +89,9 @@ 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") } @@ -94,6 +99,11 @@ func runQueryServer(cmd *cobra.Command, args []string) { 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) @@ -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 { diff --git a/node/cmd/ccq/utils.go b/node/cmd/ccq/utils.go index b0b1959e52..fcdeba7193 100644 --- a/node/cmd/ccq/utils.go +++ b/node/cmd/ccq/utils.go @@ -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" @@ -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 +} diff --git a/sdk/js-query/src/query/ethCall.test.ts b/sdk/js-query/src/query/ethCall.test.ts index e95060694e..cdfd90d851 100644 --- a/sdk/js-query/src/query/ethCall.test.ts +++ b/sdk/js-query/src/query/ethCall.test.ts @@ -21,6 +21,7 @@ import { jest.setTimeout(60000); const CI = false; +const ENV = "DEVNET"; const ETH_NODE_URL = CI ? "ws://eth-devnet:8545" : "ws://localhost:8545"; const QUERY_SERVER_URL = "http://localhost:6069/v1/query"; @@ -99,12 +100,17 @@ describe("eth call", () => { const nonce = 1; const request = new QueryRequest(nonce, [ethQuery]); const serialized = request.serialize(); - const digest = QueryRequest.digest("DEVNET", serialized); + const digest = QueryRequest.digest(ENV, serialized); const signature = sign(PRIVATE_KEY, digest); - const response = await axios.put(QUERY_SERVER_URL, { - signature, - bytes: Buffer.from(serialized).toString("hex"), - }); + const api_key = "my_secret_key"; + const response = await axios.put( + QUERY_SERVER_URL, + { + signature, + bytes: Buffer.from(serialized).toString("hex"), + }, + { headers: { "X-API-Key": api_key } } + ); expect(response.status).toBe(200); const queryResponse = QueryResponse.fromBytes( Buffer.from(response.data.bytes, "hex")