diff --git a/bridge/cmd/guardiand/adminclient.go b/bridge/cmd/guardiand/adminclient.go new file mode 100644 index 00000000..828cd84d --- /dev/null +++ b/bridge/cmd/guardiand/adminclient.go @@ -0,0 +1,81 @@ +package guardiand + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "time" + + "github.com/spf13/cobra" + "github.com/status-im/keycard-go/hexutils" + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/prototext" + + nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1" +) + +var clientSocketPath *string + +func init() { + pf := AdminClientInjectGuardianSetUpdateCmd.Flags() + clientSocketPath = pf.String("socket", "", "gRPC admin server socket to connect to") + err := cobra.MarkFlagRequired(pf, "socket") + if err != nil { + panic(err) + } + + AdminCmd.AddCommand(AdminClientInjectGuardianSetUpdateCmd) + AdminCmd.AddCommand(AdminClientGuardianSetTemplateCmd) + AdminCmd.AddCommand(AdminClientGuardianSetVerifyCmd) +} + +var AdminCmd = &cobra.Command{ + Use: "admin", + Short: "Guardian node admin commands", +} + +var AdminClientInjectGuardianSetUpdateCmd = &cobra.Command{ + Use: "guardian-set-update-inject", + Short: "Inject and sign a guardian set update from a prototxt file (see docs!)", + Run: runInjectGuardianSetUpdate, + Args: cobra.ExactArgs(1), +} + +func getAdminClient(ctx context.Context, addr string) (*grpc.ClientConn, error, nodev1.NodePrivilegedClient) { + conn, err := grpc.DialContext(ctx, fmt.Sprintf("unix:///%s", addr), grpc.WithInsecure()) + + if err != nil { + log.Fatalf("failed to connect to %s: %v", addr, err) + } + + c := nodev1.NewNodePrivilegedClient(conn) + return conn, err, c +} + +func runInjectGuardianSetUpdate(cmd *cobra.Command, args []string) { + path := args[0] + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, err, c := getAdminClient(ctx, *clientSocketPath) + defer conn.Close() + + b, err := ioutil.ReadFile(path) + if err != nil { + log.Fatalf("failed to read file: %v", err) + } + + var msg nodev1.GuardianSetUpdate + err = prototext.Unmarshal(b, &msg) + if err != nil { + log.Fatalf("failed to deserialize: %v", err) + } + + resp, err := c.SubmitGuardianSetVAA(ctx, &nodev1.SubmitGuardianSetVAARequest{GuardianSet: &msg}) + if err != nil { + log.Fatalf("failed to submit guardian set update: %v", err) + } + + log.Printf("VAA successfully injected with digest %s", hexutils.BytesToHex(resp.Digest)) +} diff --git a/bridge/cmd/guardiand/adminserver.go b/bridge/cmd/guardiand/adminserver.go new file mode 100644 index 00000000..7fee4739 --- /dev/null +++ b/bridge/cmd/guardiand/adminserver.go @@ -0,0 +1,123 @@ +package guardiand + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "time" + + ethcommon "github.com/ethereum/go-ethereum/common" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/certusone/wormhole/bridge/pkg/common" + nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1" + "github.com/certusone/wormhole/bridge/pkg/supervisor" + "github.com/certusone/wormhole/bridge/pkg/vaa" +) + +type nodePrivilegedService struct { + nodev1.UnimplementedNodePrivilegedServer + injectC chan<- *vaa.VAA + logger *zap.Logger +} + +// adminGuardianSetUpdateToVAA converts a nodev1.GuardianSetUpdate message to its canonical VAA representation. +// Returns an error if the data is invalid. +func adminGuardianSetUpdateToVAA(req *nodev1.GuardianSetUpdate) (*vaa.VAA, error) { + if len(req.Guardians) == 0 { + return nil, errors.New("empty guardian set specified") + } + + if len(req.Guardians) > common.MaxGuardianCount { + return nil, fmt.Errorf("too many guardians - %d, maximum is %d", len(req.Guardians), common.MaxGuardianCount) + } + + addrs := make([]ethcommon.Address, len(req.Guardians)) + for i, g := range req.Guardians { + if !ethcommon.IsHexAddress(g.Pubkey) { + return nil, fmt.Errorf("invalid pubkey format at index %d (%s)", i, g.Name) + } + + addrs[i] = ethcommon.HexToAddress(g.Pubkey) + } + + v := &vaa.VAA{ + Version: vaa.SupportedVAAVersion, + GuardianSetIndex: req.CurrentSetIndex, + Timestamp: time.Unix(int64(req.Timestamp), 0), + Payload: &vaa.BodyGuardianSetUpdate{ + Keys: addrs, + NewIndex: req.CurrentSetIndex + 1, + }, + } + + return v, nil +} + +func (s *nodePrivilegedService) SubmitGuardianSetVAA(ctx context.Context, req *nodev1.SubmitGuardianSetVAARequest) (*nodev1.SubmitGuardianSetVAAResponse, error) { + s.logger.Info("guardian set injected via admin socket", zap.String("request", req.String())) + + v, err := adminGuardianSetUpdateToVAA(req.GuardianSet) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + // Generate digest of the unsigned VAA. + digest, err := v.SigningMsg() + if err != nil { + panic(err) + } + + s.logger.Info("guardian set VAA constructed", + zap.Any("vaa", v), + zap.String("digest", digest.String()), + ) + + s.injectC <- v + + return &nodev1.SubmitGuardianSetVAAResponse{Digest: digest.Bytes()}, nil +} + +func adminServiceRunnable(logger *zap.Logger, socketPath string, injectC chan<- *vaa.VAA) (supervisor.Runnable, error) { + // Delete existing UNIX socket, if present. + fi, err := os.Stat(socketPath) + if err == nil { + fmode := fi.Mode() + if fmode&os.ModeType == os.ModeSocket { + err = os.Remove(socketPath) + if err != nil { + return nil, fmt.Errorf("failed to remove existing socket at %s: %w", socketPath, err) + } + } else { + return nil, fmt.Errorf("%s is not a UNIX socket", socketPath) + } + } + + // Create a new UNIX socket and listen to it. + + // The socket is created with the default umask. We set a restrictive umask in setRestrictiveUmask + // to ensure that any files we create are only readable by the user - this is much harder to mess up. + // The umask avoids a race condition between file creation and chmod. + + laddr, err := net.ResolveUnixAddr("unix", socketPath) + l, err := net.ListenUnix("unix", laddr) + if err != nil { + return nil, fmt.Errorf("failed to listen on %s: %w", socketPath, err) + } + + logger.Info("listening on", zap.String("path", socketPath)) + + nodeService := &nodePrivilegedService{ + injectC: injectC, + logger: logger.Named("adminservice"), + } + + grpcServer := grpc.NewServer() + nodev1.RegisterNodePrivilegedServer(grpcServer, nodeService) + return supervisor.GRPCServer(grpcServer, l, false), nil +} diff --git a/bridge/cmd/guardiand/admintemplate.go b/bridge/cmd/guardiand/admintemplate.go new file mode 100644 index 00000000..b6fdbce8 --- /dev/null +++ b/bridge/cmd/guardiand/admintemplate.go @@ -0,0 +1,61 @@ +package guardiand + +import ( + "fmt" + "io/ioutil" + "log" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/prototext" + + "github.com/certusone/wormhole/bridge/pkg/devnet" + nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1" +) + +var templateNumGuardians *int +var templateGuardianIndex *int + +func init() { + templateNumGuardians = AdminClientGuardianSetTemplateCmd.Flags().Int("num", 1, "Number of devnet guardians in example file") + templateGuardianIndex = AdminClientGuardianSetTemplateCmd.Flags().Int("idx", 0, "Default current guardian set index") +} + +var AdminClientGuardianSetTemplateCmd = &cobra.Command{ + Use: "guardian-set-update-template", + Short: "Generate an empty guardian set template at specified path (offline)", + Run: runGuardianSetTemplate, + Args: cobra.ExactArgs(1), +} + +func runGuardianSetTemplate(cmd *cobra.Command, args []string) { + path := args[0] + + // Use deterministic devnet addresses as examples in the template, such that this doubles as a test fixture. + guardians := make([]*nodev1.GuardianSetUpdate_Guardian, *templateNumGuardians) + for i := 0; i < *templateNumGuardians; i++ { + k := devnet.DeterministicEcdsaKeyByIndex(crypto.S256(), uint64(i)) + guardians[i] = &nodev1.GuardianSetUpdate_Guardian{ + Pubkey: crypto.PubkeyToAddress(k.PublicKey).Hex(), + Name: fmt.Sprintf("Example validator %d", i), + } + } + + m := &nodev1.GuardianSetUpdate{ + CurrentSetIndex: uint32(*templateGuardianIndex), + // Timestamp is hardcoded to make it reproducible on different devnet nodes. + // In production, a real UNIX timestamp should be used (see node.proto). + Timestamp: 1605744545, + Guardians: guardians, + } + + b, err := prototext.MarshalOptions{Multiline: true}.Marshal(m) + if err != nil { + panic(err) + } + + err = ioutil.WriteFile(path, b, 0640) + if err != nil { + log.Fatal(err) + } +} diff --git a/bridge/cmd/guardiand/adminverify.go b/bridge/cmd/guardiand/adminverify.go new file mode 100644 index 00000000..7e4c4134 --- /dev/null +++ b/bridge/cmd/guardiand/adminverify.go @@ -0,0 +1,46 @@ +package guardiand + +import ( + "io/ioutil" + "log" + + "github.com/davecgh/go-spew/spew" + "github.com/spf13/cobra" + "google.golang.org/protobuf/encoding/prototext" + + nodev1 "github.com/certusone/wormhole/bridge/pkg/proto/node/v1" +) + +var AdminClientGuardianSetVerifyCmd = &cobra.Command{ + Use: "guardian-set-update-verify", + Short: "Verify guardian set update in prototxt format (offline)", + Run: runGuardianSetVerify, + Args: cobra.ExactArgs(1), +} + +func runGuardianSetVerify(cmd *cobra.Command, args []string) { + path := args[0] + + b, err := ioutil.ReadFile(path) + if err != nil { + log.Fatalf("failed to read file: %v", err) + } + + var msg nodev1.GuardianSetUpdate + err = prototext.Unmarshal(b, &msg) + if err != nil { + log.Fatalf("failed to deserialize: %v", err) + } + + v, err := adminGuardianSetUpdateToVAA(&msg) + if err != nil { + log.Fatalf("invalid update: %v", err) + } + + digest, err := v.SigningMsg() + if err != nil { + panic(err) + } + + log.Printf("VAA with digest %s: %+v", digest.Hex(), spew.Sdump(v)) +} diff --git a/bridge/cmd/guardiand/bridge.go b/bridge/cmd/guardiand/bridge.go index 58418644..e935b576 100644 --- a/bridge/cmd/guardiand/bridge.go +++ b/bridge/cmd/guardiand/bridge.go @@ -38,6 +38,8 @@ var ( nodeKeyPath *string + adminSocketPath *string + bridgeKeyPath *string ethRPC *string @@ -67,6 +69,8 @@ func init() { nodeKeyPath = BridgeCmd.Flags().String("nodeKey", "", "Path to node key (will be generated if it doesn't exist)") + adminSocketPath = BridgeCmd.Flags().String("adminSocket", "", "Admin gRPC service UNIX domain socket path") + bridgeKeyPath = BridgeCmd.Flags().String("bridgeKey", "", "Path to guardian key (required)") ethRPC = BridgeCmd.Flags().String("ethRPC", "", "Ethereum RPC URL") @@ -133,6 +137,12 @@ func lockMemory() { } } +// setRestrictiveUmask masks the group and world bits. This ensures that key material +// and sockets we create aren't accidentally group- or world-readable. +func setRestrictiveUmask() { + syscall.Umask(0077) // cannot fail +} + // BridgeCmd represents the bridge command var BridgeCmd = &cobra.Command{ Use: "bridge", @@ -146,6 +156,7 @@ func runBridge(cmd *cobra.Command, args []string) { } lockMemory() + setRestrictiveUmask() // Set up logging. The go-log zap wrapper that libp2p uses is compatible with our // usage of zap in supervisor, which is nice. @@ -196,6 +207,9 @@ func runBridge(cmd *cobra.Command, args []string) { if *bridgeKeyPath == "" { logger.Fatal("Please specify -bridgeKey") } + if *adminSocketPath == "" { + logger.Fatal("Please specify -adminSocket") + } if *agentRPC == "" { logger.Fatal("Please specify -agentRPC") } @@ -273,6 +287,9 @@ func runBridge(cmd *cobra.Command, args []string) { // VAAs to submit to Solana solanaVaaC := make(chan *vaa.VAA) + // Injected VAAs (manually generated rather than created via observation) + injectC := make(chan *vaa.VAA) + // Load p2p private key var priv crypto.PrivKey if *unsafeDevMode { @@ -288,6 +305,11 @@ func runBridge(cmd *cobra.Command, args []string) { } } + adminService, err := adminServiceRunnable(logger, *adminSocketPath, injectC) + if err != nil { + logger.Fatal("failed to create admin service socket", zap.Error(err)) + } + // Run supervisor. supervisor.New(rootCtx, logger, func(ctx context.Context) error { if err := supervisor.Run(ctx, "p2p", p2p.Run( @@ -314,11 +336,15 @@ func runBridge(cmd *cobra.Command, args []string) { return err } - p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer) + p := processor.NewProcessor(ctx, lockC, setC, sendC, obsvC, solanaVaaC, injectC, gk, *unsafeDevMode, *devNumGuardians, *ethRPC, *terraLCD, *terraChaidID, *terraContract, *terraFeePayer) if err := supervisor.Run(ctx, "processor", p.Run); err != nil { return err } + if err := supervisor.Run(ctx, "admin", adminService); err != nil { + return err + } + logger.Info("Started internal services") select { diff --git a/bridge/cmd/guardiand/bridgekey.go b/bridge/cmd/guardiand/bridgekey.go index 3df22eaa..925cde3e 100644 --- a/bridge/cmd/guardiand/bridgekey.go +++ b/bridge/cmd/guardiand/bridgekey.go @@ -32,6 +32,7 @@ var KeygenCmd = &cobra.Command{ func runKeygen(cmd *cobra.Command, args []string) { lockMemory() + setRestrictiveUmask() log.Print("Creating new key at ", args[0]) diff --git a/bridge/cmd/root.go b/bridge/cmd/root.go index daefb9a7..9181fa47 100644 --- a/bridge/cmd/root.go +++ b/bridge/cmd/root.go @@ -2,9 +2,10 @@ package cmd import ( "fmt" - "github.com/spf13/cobra" "os" + "github.com/spf13/cobra" + homedir "github.com/mitchellh/go-homedir" "github.com/spf13/viper" @@ -34,6 +35,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.guardiand.yaml)") rootCmd.AddCommand(guardiand.BridgeCmd) rootCmd.AddCommand(guardiand.KeygenCmd) + rootCmd.AddCommand(guardiand.AdminCmd) } // initConfig reads in config file and ENV variables if set. diff --git a/bridge/go.mod b/bridge/go.mod index 3cbfe34f..f85e4450 100644 --- a/bridge/go.mod +++ b/bridge/go.mod @@ -7,6 +7,7 @@ require ( github.com/aristanetworks/goarista v0.0.0-20201012165903-2cb20defcd66 // indirect github.com/btcsuite/btcd v0.21.0-beta // indirect github.com/cenkalti/backoff/v4 v4.1.0 + github.com/davecgh/go-spew v1.1.1 github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect github.com/deckarep/golang-set v1.7.1 // indirect github.com/ethereum/go-ethereum v1.9.23 @@ -45,7 +46,7 @@ require ( github.com/shirou/gopsutil v2.20.9+incompatible // indirect github.com/spf13/cobra v1.0.0 github.com/spf13/viper v1.6.3 - github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969 // indirect + github.com/status-im/keycard-go v0.0.0-20200402102358-957c09536969 github.com/stretchr/testify v1.6.1 github.com/tendermint/tendermint v0.33.8 // indirect github.com/terra-project/terra.go v1.0.1-0.20201113170042-b3bffdc6fd06 diff --git a/bridge/pkg/common/guardianset.go b/bridge/pkg/common/guardianset.go index 270086e8..4c99b364 100644 --- a/bridge/pkg/common/guardianset.go +++ b/bridge/pkg/common/guardianset.go @@ -4,6 +4,15 @@ import ( "github.com/ethereum/go-ethereum/common" ) +// TODO: this should be 20, https://github.com/certusone/wormhole/issues/86 +// +// Matching constants: +// - MAX_LEN_GUARDIAN_KEYS in Solana contract +// +// The Eth and Terra contracts do not specify a maximum number and support more than 20, +// but presumably, chain-specific transaction size limits will apply at some point (untested). +const MaxGuardianCount = 19 + type GuardianSet struct { // Guardian's public keys truncated by the ETH standard hashing mechanism (20 bytes). Keys []common.Address diff --git a/bridge/pkg/processor/injection.go b/bridge/pkg/processor/injection.go new file mode 100644 index 00000000..6fa607e5 --- /dev/null +++ b/bridge/pkg/processor/injection.go @@ -0,0 +1,48 @@ +package processor + +import ( + "context" + "encoding/hex" + + "github.com/ethereum/go-ethereum/crypto" + "go.uber.org/zap" + + "github.com/certusone/wormhole/bridge/pkg/supervisor" + "github.com/certusone/wormhole/bridge/pkg/vaa" +) + +// handleInjection processes a pre-populated VAA injected locally. +func (p *Processor) handleInjection(ctx context.Context, v *vaa.VAA) { + // Check if we're in the guardian set. + us, ok := p.gs.KeyIndex(p.ourAddr) + if !ok { + p.logger.Error("we're not in the guardian set - refusing to sign", + zap.Uint32("index", p.gs.Index), + zap.Stringer("our_addr", p.ourAddr), + zap.Any("set", p.gs.KeysAsHexStrings())) + return + } + + // Generate digest of the unsigned VAA. + digest, err := v.SigningMsg() + if err != nil { + panic(err) + } + + // The internal originator is responsible for logging the full VAA, just log the digest here. + supervisor.Logger(ctx).Info("signing injected VAA", + zap.Stringer("digest", digest)) + + // Sign the digest using our node's guardian key. + s, err := crypto.Sign(digest.Bytes(), p.gk) + if err != nil { + panic(err) + } + + p.logger.Info("observed and signed injected VAA", + zap.String("digest", hex.EncodeToString(digest.Bytes())), + zap.String("signature", hex.EncodeToString(s)), + zap.Int("our_index", us)) + + p.broadcastSignature(v, s) +} diff --git a/bridge/pkg/processor/observation.go b/bridge/pkg/processor/observation.go index 0409e92f..205ad809 100644 --- a/bridge/pkg/processor/observation.go +++ b/bridge/pkg/processor/observation.go @@ -138,43 +138,50 @@ func (p *Processor) handleObservation(ctx context.Context, m *gossipv1.LockupObs panic(err) } - if t, ok := v.Payload.(*vaa.BodyTransfer); ok { + // Submit every VAA to Solana for data availability. + p.logger.Info("submitting signed VAA to Solana", + zap.String("digest", hash), + zap.Any("vaa", signed), + zap.String("bytes", hex.EncodeToString(vaaBytes))) + p.vaaC <- signed + + switch t := v.Payload.(type) { + case *vaa.BodyTransfer: + // Depending on the target chain, guardians submit VAAs directly to the chain. switch t.TargetChain { - case vaa.ChainIDEthereum, - vaa.ChainIDSolana, - vaa.ChainIDTerra: - // Submit to Solana if target is Solana, but also cross-submit all other targets to Solana for data availability - p.logger.Info("submitting signed VAA to Solana", - zap.String("digest", hash), - zap.Any("vaa", signed), - zap.String("bytes", hex.EncodeToString(vaaBytes))) - - // Check whether we run in devmode and submit the VAA ourselves, if so. - switch t.TargetChain { - case vaa.ChainIDEthereum: - p.devnetVAASubmission(ctx, signed, hash) - case vaa.ChainIDTerra: - p.terraVAASubmission(ctx, signed, hash) - } - - p.vaaC <- signed + case vaa.ChainIDSolana: + // No-op. + case vaa.ChainIDEthereum: + // Ethereum is special because it's expensive, and guardians cannot + // be expected to pay the fees. We only submit to Ethereum in devnet mode. + p.devnetVAASubmission(ctx, signed, hash) + case vaa.ChainIDTerra: + p.terraVAASubmission(ctx, signed, hash) default: - p.logger.Error("we don't know how to submit this VAA", + p.logger.Error("unknown target chain ID", zap.String("digest", hash), zap.Any("vaa", signed), zap.String("bytes", hex.EncodeToString(vaaBytes)), zap.Stringer("target_chain", t.TargetChain)) } - - p.state.vaaSignatures[hash].submitted = true - } else { + case *vaa.BodyGuardianSetUpdate: + // A guardian set update is broadcast to every chain that we talk to. + p.devnetVAASubmission(ctx, signed, hash) + p.terraVAASubmission(ctx, signed, hash) + default: panic(fmt.Sprintf("unknown VAA payload type: %+v", v)) } + + p.state.vaaSignatures[hash].submitted = true } else { p.logger.Info("quorum not met or already submitted, doing nothing", zap.String("digest", hash)) } + } else { + p.logger.Info("we have not yet seen this VAA - temporarily storing signature", + zap.String("digest", hash)) + } } diff --git a/bridge/pkg/processor/processor.go b/bridge/pkg/processor/processor.go index 9782a5d9..353953fc 100644 --- a/bridge/pkg/processor/processor.go +++ b/bridge/pkg/processor/processor.go @@ -50,6 +50,9 @@ type Processor struct { // vaaC is a channel of VAAs to submit to store on Solana (either as target, or for data availability) vaaC chan *vaa.VAA + // injectC is a channel of VAAs injected locally. + injectC chan *vaa.VAA + // gk is the node's guardian private key gk *ecdsa.PrivateKey @@ -84,6 +87,7 @@ func NewProcessor( sendC chan []byte, obsvC chan *gossipv1.LockupObservation, vaaC chan *vaa.VAA, + injectC chan *vaa.VAA, gk *ecdsa.PrivateKey, devnetMode bool, devnetNumGuardians uint, @@ -99,6 +103,7 @@ func NewProcessor( sendC: sendC, obsvC: obsvC, vaaC: vaaC, + injectC: injectC, gk: gk, devnetMode: devnetMode, devnetNumGuardians: devnetNumGuardians, @@ -134,6 +139,8 @@ func (p *Processor) Run(ctx context.Context) error { } case k := <-p.lockC: p.handleLockup(ctx, k) + case v := <-p.injectC: + p.handleInjection(ctx, v) case m := <-p.obsvC: p.handleObservation(ctx, m) case <-p.cleanup.C: diff --git a/proto/gossip/v1/gossip.proto b/proto/gossip/v1/gossip.proto index c4348432..b0b3cec6 100644 --- a/proto/gossip/v1/gossip.proto +++ b/proto/gossip/v1/gossip.proto @@ -40,6 +40,8 @@ message Heartbeat { // guardians submitting valid signatures for a given hash, they can be assembled into a VAA. // // Messages without valid signature are dropped unceremoniously. +// +// TODO: rename? we also use it for governance VAAs message LockupObservation { // Guardian pubkey as truncated eth address. bytes addr = 1; diff --git a/proto/node/v1/node.proto b/proto/node/v1/node.proto index 376c786c..d62e40b0 100644 --- a/proto/node/v1/node.proto +++ b/proto/node/v1/node.proto @@ -6,9 +6,58 @@ option go_package = "proto/node/v1;nodev1"; import "google/api/annotations.proto"; -service Node { +// NodePrivileged exposes an administrative API. It runs on a UNIX socket and is authenticated +// using Linux filesystem permissions. +service NodePrivileged { + // SubmitGuardianSetVAA injects a guardian set change VAA into the guardian node. + // The node will inject the VAA into the aggregator and sign/broadcast the VAA signature. + // + // A consensus majority of nodes on the network will have to inject the VAA within the + // VAA timeout window for it to reach consensus. + // + rpc SubmitGuardianSetVAA (SubmitGuardianSetVAARequest) returns (SubmitGuardianSetVAAResponse); } +// GuardianSet represents a new guardian set to be submitted to and signed by the node. +// During the genesis procedure, this data structure will be assembled using off-chain collaborative tooling +// like GitHub using a human-readable encoding, so readability is a concern. +message GuardianSetUpdate { + // Index of the current guardian set to be replaced. + uint32 current_set_index = 1; + + // UNIX timestamp (s) of the VAA to be created. The timestamp is informational and will be part + // of the VAA submitted to the chain. It's part of the VAA digest and has to be identical across nodes. + // + // For lockups, the timestamp identifies the block that the lockup belongs to. For guardian set updates, + // we create the VAA manually. Best practice is to pick a timestamp which roughly matches the expected + // genesis ceremony data. + // + // The actual on-chain guardian set creation timestamp will be set when the VAA is accepted on each chain. + // + // This is a uint32 to match the on-chain timestamp representation. This becomes a problem in 2106 (sorry). + uint32 timestamp = 2; + + // List of guardian set members. + message Guardian { + // Guardian key pubkey. Stored as hex string with 0x prefix for human readability - + // this is the canonical Ethereum representation. + string pubkey = 1; + // Optional descriptive name. Not stored on any chain, purely informational. + string name = 2; + }; + repeated Guardian guardians = 3; +} + +message SubmitGuardianSetVAARequest { + GuardianSetUpdate guardian_set = 1; +} + +message SubmitGuardianSetVAAResponse { + // Canonical digest of the submitted VAA. + bytes digest = 1; +} + +// GuardianKey specifies the on-disk format for a node's guardian key. message GuardianKey { // description is an optional, free-form description text set by the operator. string description = 1; diff --git a/scripts/test-injection.sh b/scripts/test-injection.sh new file mode 100755 index 00000000..c7c7b488 --- /dev/null +++ b/scripts/test-injection.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# This script submits a guardian set update using the VAA injection admin command. +# First argument is node to submit to. Second argument is current set index. +set -e + +node=$1 +idx=$2 +path=/tmp/new-guardianset.prototxt +sock=/tmp/admin.sock + +# Create a no-op update that sets the same 1-node guardian set again. +kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-template --num=1 --idx=${idx} $path + +# Verify and print resulting result. The digest incorporates the current time and is NOT deterministic. +kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-verify $path + +# Submit to node +kubectl exec guardian-${node} -c guardiand -- /guardiand admin guardian-set-update-inject --socket $sock $path