synapsecns/sanguine

View on GitHub
ethergo/signer/config/signer.go

Summary

Maintainability
A
3 hrs
Test Coverage
package config

import (
    kms "cloud.google.com/go/kms/apiv1"
    "context"
    "errors"
    "fmt"
    "github.com/davecgh/go-spew/spew"
    "github.com/jftuga/ellipsis"
    "github.com/synapsecns/sanguine/core"
    "github.com/synapsecns/sanguine/ethergo/signer/signer"
    "github.com/synapsecns/sanguine/ethergo/signer/signer/awssigner"
    "github.com/synapsecns/sanguine/ethergo/signer/signer/gcpsigner"
    "github.com/synapsecns/sanguine/ethergo/signer/signer/localsigner"
    "github.com/synapsecns/sanguine/ethergo/signer/wallet"
    "google.golang.org/api/option"
    "gopkg.in/yaml.v2"
    "os"
    "path/filepath"
    "slices"
    "strings"
)

// SignerConfig contains a signer config. Currently this config
// only supports local based signers due to a lack of isomorphic types
// when we parse yaml.
type SignerConfig struct {
    // Type is the driver used for the signer
    Type string
    // File is the file used for the key.
    File string
}

// IsValid determines if the config is valid.
func (s SignerConfig) IsValid(_ context.Context) (ok bool, err error) {
    if !slices.ContainsFunc(AllSignerTypes, func(signerType SignerType) bool {
        return strings.EqualFold(signerType.String(), s.Type)
    }) {
        return false, fmt.Errorf("%w: %s. must be one of: %s", ErrUnsupportedSignerType, s.Type, allSignerTypesList())
    }

    if strings.EqualFold(s.Type, FileType.String()) {
        // TODO: we'll need to switch validity here based on type once we have more then one supported configuration type
        // alternatively, we could try to use an awsconfig type file, but this makes the virtual box setup more tedious. A third option is a json blob
        _, err = wallet.FromKeyFile(s.File)
        if err != nil {
            return false, fmt.Errorf("file %s invalid: %w", s.File, err)
        }
    }

    return true, nil
}

// ErrUnsupportedSignerType indicates the signer type being used is unsupported.
var ErrUnsupportedSignerType = errors.New("unsupported signer type")

// SignerType is the signer type
//
//go:generate go run golang.org/x/tools/cmd/stringer -type=SignerType -linecomment
type SignerType int

const (
    // FileType is a file-based signer.
    FileType SignerType = iota + 1 // File
    // AWSType is an aws kms based signer.
    AWSType // AWS
    // GCPType is a gcp cloud based signer.
    GCPType // GCP
)

// lString returns the lowercase string of the signer type.
func (s SignerType) lString() string {
    return strings.ToLower(s.String())
}

// AllSignerTypes is a list of all contract types. Since we use stringer and this is a testing library, instead
// of manually copying all these out we pull the names out of stringer. In order to make sure stringer is updated, we panic on
// any method called where the index is higher than the stringer array length.
var AllSignerTypes []SignerType

// set all contact types.
func init() {
    for i := 0; i < len(_SignerType_index); i++ {
        contractType := SignerType(i)
        AllSignerTypes = append(AllSignerTypes, contractType)
    }
}

// allSignerTypesList prints a list of all signer types. This is useful for returning errors.
func allSignerTypesList() string {
    var res []string
    for _, signerType := range AllSignerTypes {
        res = append(res, signerType.String())
    }

    return strings.Join(res, ",")
}

// SignerFromConfig creates a new signer from a signer config.
// TODO: this needs to be moved to some kind of common package.
// in the old code configs were split into responsible packages. Maybe something like that works here?
func SignerFromConfig(ctx context.Context, config SignerConfig) (signer.Signer, error) {
    switch strings.ToLower(config.Type) {
    case FileType.lString():
        wall, err := wallet.FromKeyFile(core.ExpandOrReturnPath(config.File))
        if err != nil {
            return nil, fmt.Errorf("could not add signer: %w", err)
        }

        res := localsigner.NewSigner(wall.PrivateKey())

        return res, nil
    case AWSType.lString():
        awsConfig, err := DecodeAWSConfig(config.File)
        if err != nil {
            return nil, fmt.Errorf("could not decode aws config: %w", err)
        }
        res, err := awssigner.NewKmsSigner(ctx, awsConfig.Region, awsConfig.AccessKey, awsConfig.AccessSecret, awsConfig.KeyID)
        if err != nil {
            return nil, fmt.Errorf("could not decode aws config: %w", err)
        }
        return res, nil
    case GCPType.lString():
        gcpConfig, err := DecodeGCPConfig(config.File)
        if err != nil {
            return nil, fmt.Errorf("could not decode gcp config: %w", err)
        }

        return makeGCPSigner(ctx, gcpConfig)
    default:
        return nil, fmt.Errorf("could not create signer: %w", ErrUnsupportedSignerType)
    }
}

func makeGCPSigner(ctx context.Context, gcpConfig GCPConfig) (signer.Signer, error) {
    var options []option.ClientOption
    if gcpConfig.CredentialFile != "" {
        options = append(options, option.WithCredentialsFile(gcpConfig.CredentialFile))
    }

    if gcpConfig.Endpoint != "" {
        options = append(options, option.WithEndpoint(gcpConfig.Endpoint))
    }

    keyClient, err := kms.NewKeyManagementClient(ctx, options...)
    if err != nil {
        return nil, fmt.Errorf("could not create key client: %w", err)
    }

    res, err := gcpsigner.NewManagedKey(ctx, keyClient, gcpConfig.KeyName)
    if err != nil {
        return nil, fmt.Errorf("could not create managed key: %w", err)
    }

    return res, nil
}

// GCPConfig is the config for a GCP signer.
type GCPConfig struct {
    // KeyName is the name of the key to use.
    KeyName string `yaml:"key_name"`
    // CredentialFile is the path to the credentials file.
    // note: this is not recommended for production use.
    // workload identity federation is recommended.
    CredentialFile string `yaml:"credential_file"`
    // Endpoint is the endpoint to use. This is useful for testing.
    Endpoint string `yaml:"endpoint"`
}

// Encode encodes the config to yaml.
func (a GCPConfig) Encode() ([]byte, error) {
    output, err := yaml.Marshal(&a)
    if err != nil {
        return nil, fmt.Errorf("could not unmarshall config %s: %w", ellipsis.Shorten(spew.Sdump(a), 20), err)
    }
    return output, nil
}

// DecodeGCPConfig decodes the config from a file.
func DecodeGCPConfig(filePath string) (cfg GCPConfig, err error) {
    input, err := os.ReadFile(filepath.Clean(filePath))
    if err != nil {
        return GCPConfig{}, fmt.Errorf("failed to read file: %w", err)
    }
    err = yaml.Unmarshal(input, &cfg)
    if err != nil {
        return GCPConfig{}, fmt.Errorf("could not unmarshall config %s: %w", ellipsis.Shorten(string(input), 30), err)
    }
    return cfg, nil
}

// AWSConfig is the config for an AWS signer.
// this should match the schema of the file passed in.
type AWSConfig struct {
    // Region is the region the signer is in.
    Region string `yaml:"region"`
    // AccessKey is the access key for the signer.
    AccessKey string `yaml:"access_key"`
    // AccessSecret is the access secret for the signer.
    AccessSecret string `yaml:"access_secret"`
    // KeyID is the key id for the signer.
    KeyID string `yaml:"key_id"`
}

// Encode encodes the config to yaml.
func (a AWSConfig) Encode() ([]byte, error) {
    output, err := yaml.Marshal(&a)
    if err != nil {
        return nil, fmt.Errorf("could not unmarshall config %s: %w", ellipsis.Shorten(spew.Sdump(a), 20), err)
    }
    return output, nil
}

// DecodeAWSConfig decodes the config from a file.
func DecodeAWSConfig(filePath string) (cfg AWSConfig, err error) {
    input, err := os.ReadFile(filepath.Clean(filePath))
    if err != nil {
        return AWSConfig{}, fmt.Errorf("failed to read file: %w", err)
    }
    err = yaml.Unmarshal(input, &cfg)
    if err != nil {
        return AWSConfig{}, fmt.Errorf("could not unmarshall config %s: %w", ellipsis.Shorten(string(input), 30), err)
    }
    return cfg, nil
}