diff --git a/cmd/approve-list-svc/.gitignore b/cmd/approve-list-svc/.gitignore new file mode 100644 index 00000000..36367383 --- /dev/null +++ b/cmd/approve-list-svc/.gitignore @@ -0,0 +1 @@ +approve-list-svc \ No newline at end of file diff --git a/cmd/approve-list-svc/README.md b/cmd/approve-list-svc/README.md new file mode 100644 index 00000000..ce6f1ed7 --- /dev/null +++ b/cmd/approve-list-svc/README.md @@ -0,0 +1,17 @@ +# Approve List Service + +The reference implementation of an external policy service. + +For configuration see [an example](config_example.yaml) + +## Start + +``` +approve-list-svc -c config serve +``` + +## Print the public key + +``` +approve-list-svc -c config pub +``` \ No newline at end of file diff --git a/cmd/approve-list-svc/approve_list_svc.go b/cmd/approve-list-svc/approve_list_svc.go new file mode 100644 index 00000000..31ddbbc8 --- /dev/null +++ b/cmd/approve-list-svc/approve_list_svc.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/ecadlabs/signatory/cmd/approve-list-svc/server" + "github.com/ecadlabs/signatory/pkg/tezos" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "approve-list-svc", + Short: "Example IP approve list external policy service", +} + +var confFile string + +var pubCmd = &cobra.Command{ + Use: "pub", + Short: "Print the authentication public key", + RunE: func(cmd *cobra.Command, args []string) error { + conf, err := ReadConfig(confFile) + if err != nil { + return err + } + pk, err := conf.GetPrivateKey() + if err != nil { + return err + } + if pk == nil { + return errors.New("private key is not specified") + } + + pub, err := tezos.EncodePublicKey(pk.Public()) + if err != nil { + return err + } + + fmt.Printf("Public key: %s\n", pub) + return nil + }, +} + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the server", + RunE: func(cmd *cobra.Command, args []string) error { + conf, err := ReadConfig(confFile) + if err != nil { + return err + } + + pk, err := conf.GetPrivateKey() + if err != nil { + return err + } + + ips, nets, err := conf.Addresses() + if err != nil { + return err + } + + srv := server.Server{ + Address: conf.Address, + PrivateKey: pk, + Addresses: ips, + Nets: nets, + } + + s := srv.New() + log.Printf("HTTP server is listening for connections on %s", srv.Address) + log.Println(s.ListenAndServe()) + + return nil + }, +} + +func init() { + rootCmd.PersistentFlags().StringVarP(&confFile, "config", "c", "", "Config file") + rootCmd.AddCommand(pubCmd) + rootCmd.AddCommand(serveCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/approve-list-svc/config.go b/cmd/approve-list-svc/config.go new file mode 100644 index 00000000..592bb0a3 --- /dev/null +++ b/cmd/approve-list-svc/config.go @@ -0,0 +1,87 @@ +package main + +import ( + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "net" + + "github.com/ecadlabs/signatory/pkg/cryptoutils" + "github.com/ecadlabs/signatory/pkg/tezos" + yaml "gopkg.in/yaml.v3" +) + +type Config struct { + Address string `yaml:"address"` + PrivateKey string `yaml:"private_key"` + PrivateKeyFile string `yaml:"private_key_file"` + List []string `yaml:"list"` +} + +func (conf *Config) Addresses() ([]net.IP, []*net.IPNet, error) { + var ( + ips []net.IP + nets []*net.IPNet + ) + for _, addr := range conf.List { + if _, n, err := net.ParseCIDR(addr); err == nil { + nets = append(nets, n) + } else { + if ip := net.ParseIP(addr); ip != nil { + ips = append(ips, ip) + } else { + return nil, nil, fmt.Errorf("invalid address: %s", addr) + } + } + } + return ips, nets, nil +} + +func (conf *Config) GetPrivateKey() (cryptoutils.PrivateKey, error) { + var keyData []byte + if conf.PrivateKey != "" { + if pk, err := tezos.ParsePrivateKey(conf.PrivateKey, nil); err == nil { + return pk, nil + } else { + keyData = []byte(conf.PrivateKey) + } + } else { + if conf.PrivateKeyFile == "" { + return nil, nil + } + var err error + if keyData, err = ioutil.ReadFile(conf.PrivateKeyFile); err != nil { + return nil, err + } + } + + b, _ := pem.Decode(keyData) + if b == nil { + return nil, errors.New("can't parse private key PEM block") + } + + key, err := x509.ParsePKCS8PrivateKey(b.Bytes) + if err != nil { + return nil, err + } + pk, ok := key.(cryptoutils.PrivateKey) + if !ok { + return nil, errors.New("unexpected private key type") + } + return pk, nil +} + +func ReadConfig(file string) (*Config, error) { + yamlFile, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + var c Config + if err = yaml.Unmarshal(yamlFile, &c); err != nil { + return nil, err + } + + return &c, nil +} diff --git a/cmd/approve-list-svc/config_example.yaml b/cmd/approve-list-svc/config_example.yaml new file mode 100644 index 00000000..562a1d6e --- /dev/null +++ b/cmd/approve-list-svc/config_example.yaml @@ -0,0 +1,13 @@ +# host:port to listen on +address: :6733 + +# Optional private key for authenticated replies. Can be either Base58 encoded private key (see `signatory-tools gen-key`) +# of PEM encoded PKCS8 key data +private_key: edsk3YfMTdSzkFqLtmiZoFtEN6sR9jp64zin7f4jUTJiKbTLCqkkcJ + +# Alternatively PEM file can be stored separately +#private_key_file: path_to_pem_file + +# List of allowed addresses which can be IPv4/IPv6 addresses or CIDR network addresses +list: + - 192.168.88.254 diff --git a/cmd/approve-list-svc/server/server.go b/cmd/approve-list-svc/server/server.go new file mode 100644 index 00000000..941c9a3d --- /dev/null +++ b/cmd/approve-list-svc/server/server.go @@ -0,0 +1,119 @@ +package server + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + + "github.com/ecadlabs/signatory/pkg/cryptoutils" + "github.com/ecadlabs/signatory/pkg/signatory" + "github.com/ecadlabs/signatory/pkg/tezos" + "github.com/ecadlabs/signatory/pkg/tezos/utils" +) + +type Server struct { + Address string + PrivateKey cryptoutils.PrivateKey + Addresses []net.IP + Nets []*net.IPNet +} + +func (s *Server) Handler() (http.Handler, error) { + pub := s.PrivateKey.Public() + hash, err := tezos.EncodePublicKeyHash(pub) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req signatory.PolicyHookRequest + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var ok bool + for _, n := range s.Nets { + if n.Contains(req.Source) { + ok = true + break + } + } + if !ok { + for _, a := range s.Addresses { + if a.Equal(req.Source) { + ok = true + break + } + } + } + + if s.PrivateKey != nil { + var status int + if ok { + status = http.StatusOK + } else { + status = http.StatusForbidden + } + + replyPl := signatory.PolicyHookReplyPayload{ + Status: status, + PublicKeyHash: hash, + Nonce: req.Nonce, + } + + if !ok { + replyPl.Error = fmt.Sprintf("address %s is not allowed", req.Source) + } + + buf, err := json.Marshal(&replyPl) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + digest := utils.DigestFunc(buf) + sig, err := cryptoutils.Sign(s.PrivateKey, digest[:]) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + s, err := tezos.EncodeGenericSignature(sig) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + reply := signatory.PolicyHookReply{ + Payload: buf, + Signature: s, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(&reply) + } else { + var status int + if ok { + status = http.StatusNoContent + } else { + status = http.StatusForbidden + } + w.WriteHeader(status) + } + }), nil +} + +func (s *Server) New() *http.Server { + h, err := s.Handler() + if err != nil { + return nil + } + return &http.Server{ + Handler: h, + Addr: s.Address, + } +} diff --git a/cmd/approve-list-svc/server/server_test.go b/cmd/approve-list-svc/server/server_test.go new file mode 100644 index 00000000..fbcf6262 --- /dev/null +++ b/cmd/approve-list-svc/server/server_test.go @@ -0,0 +1,84 @@ +//go:build !integration + +package server_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "net" + "net/http/httptest" + "testing" + + "github.com/ecadlabs/signatory/cmd/approve-list-svc/server" + "github.com/ecadlabs/signatory/pkg/auth" + "github.com/ecadlabs/signatory/pkg/config" + "github.com/ecadlabs/signatory/pkg/signatory" + "github.com/ecadlabs/signatory/pkg/tezos" + "github.com/ecadlabs/signatory/pkg/vault" + "github.com/ecadlabs/signatory/pkg/vault/memory" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func testServer(t *testing.T, addr []net.IP) error { + // generate hook authentication key + _, pk, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + srv := server.Server{ + PrivateKey: pk, + Addresses: addr, + } + + handler, err := srv.Handler() + require.NoError(t, err) + + testSrv := httptest.NewServer(handler) + defer testSrv.Close() + + hookAuth, err := auth.StaticAuthorizedKeys(pk.Public()) + require.NoError(t, err) + + _, signPk, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signKeyHash, err := tezos.EncodePublicKeyHash(signPk.Public()) + require.NoError(t, err) + + conf := signatory.Config{ + Vaults: map[string]*config.VaultConfig{"mock": {Driver: "mock"}}, + Watermark: signatory.IgnoreWatermark{}, + VaultFactory: vault.FactoryFunc(func(ctx context.Context, name string, conf *yaml.Node) (vault.Vault, error) { + return memory.New([]*memory.PrivateKey{ + { + PrivateKey: signPk, + KeyID: signKeyHash, + }, + }, "Mock") + }), + Policy: map[string]*signatory.Policy{ + signKeyHash: nil, + }, + PolicyHook: &signatory.PolicyHook{ + Address: testSrv.URL, + Auth: hookAuth, + }, + } + + s, err := signatory.New(context.Background(), &conf) + require.NoError(t, err) + + msg, _ := hex.DecodeString("019caecab9000753d3029bc7d9a36b60cce68ade985a0a16929587166e0d3de61efff2fa31b7116bf670000000005ee3c23b04519d71c4e54089c56773c44979b3ba3d61078ade40332ad81577ae074f653e0e0000001100000001010000000800000000000753d2da051ba81185783e4cbc633cf2ba809139ef07c3e5f6c5867f930e7667b224430000cde7fbbb948e030000") + _, err = s.Sign(context.Background(), &signatory.SignRequest{PublicKeyHash: signKeyHash, Message: msg, Source: net.IPv6loopback}) + return err +} + +func TestServer(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + require.NoError(t, testServer(t, []net.IP{net.IPv6loopback})) + }) + t.Run("Deny", func(t *testing.T) { + require.EqualError(t, testServer(t, nil), "policy hook: address ::1 is not allowed") + }) +} diff --git a/cmd/commands/import.go b/cmd/commands/import.go index 686edfe8..af08e289 100644 --- a/cmd/commands/import.go +++ b/cmd/commands/import.go @@ -6,7 +6,7 @@ import ( "github.com/ecadlabs/signatory/pkg/utils" "github.com/spf13/cobra" - "golang.org/x/crypto/ssh/terminal" + terminal "golang.org/x/term" ) func NewImportCommand(c *Context) *cobra.Command { diff --git a/cmd/commands/root.go b/cmd/commands/root.go index e8b633c7..6b1397d1 100644 --- a/cmd/commands/root.go +++ b/cmd/commands/root.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/config" "github.com/ecadlabs/signatory/pkg/metrics" "github.com/ecadlabs/signatory/pkg/signatory" @@ -84,6 +85,19 @@ func NewRootCommand(c *Context, name string) *cobra.Command { Watermark: &signatory.FileWatermark{BaseDir: baseDir}, } + if conf.PolicyHook != nil && conf.PolicyHook.Address != "" { + sigConf.PolicyHook = &signatory.PolicyHook{ + Address: conf.PolicyHook.Address, + } + if conf.PolicyHook.AuthorizedKeys != nil { + ak, err := auth.StaticAuthorizedKeysFromString(conf.PolicyHook.AuthorizedKeys.List()...) + if err != nil { + return err + } + sigConf.PolicyHook.Auth = ak + } + } + sig, err := signatory.New(c.Context, &sigConf) if err != nil { return err diff --git a/cmd/commands/serve.go b/cmd/commands/serve.go index 6b5df869..30df67c2 100644 --- a/cmd/commands/serve.go +++ b/cmd/commands/serve.go @@ -5,8 +5,8 @@ import ( "net/http" "time" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/server" - "github.com/ecadlabs/signatory/pkg/server/auth" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) diff --git a/docs/remote_policy.md b/docs/remote_policy.md new file mode 100644 index 00000000..fdf7a86b --- /dev/null +++ b/docs/remote_policy.md @@ -0,0 +1,74 @@ +--- +id: remote_policy +title: Remote Policy Service +--- + +# Remote policy service + +The remote policy service feature allows custom policy schemes beyond simple request and operation lookup +to be implemented externally. + +The hook is called after the standard request type and operation checks. If the hook returned an error the sign operation is denied. + +The service response can be authenticated using a signature. To do so the service public key must be added to the `authorized_keys` list. + +## Configuration + +```yaml +# config root +policy_hook: + address: host:port + # List of authorized keys in Tezos Base58 format + authorized_keys: + - pub1 + - pub2 + # ... +``` + +## API + +### Request + +```jsonc +{ + // Base64 encoded raw incoming sign request + "request": "base64", + // Client address + "source": "ip_address", + // Requested public key hash in Tezos Base58 format + "public_key_hash": "base58", + // Client public key hash in Tezos Base58 format. Presents only if the incoming sign request was authenticated + "client_key_hash": "base58", + // One time nonce. Presents only if the policy service call is authenticated + "nonce": "string" +} +``` + +### Authenticated reply + +```jsonc +{ + "payload": { + // Must reflect the HTTP status code. The sign operation is allowed if the service returned 2xx + "status": 200, + // An optional error message is the status code is not 2xx + "error": "string", + // The key used to sign the reply + "public_key_hash": "base58", + // The request nonce + "nonce": "string" + }, + // Payload signature in Tezos Base58 format + "signature": "base58" +} +``` + +The signature is calculated from the `payload` JSON object **as it present in the request**. + +### Non authenticated reply + +Just the HTTP status code is inspected. The sign operation is allowed if the service returned 2xx + +## Reference implementation + +See [Approve List Service](/cmd/approve-list-svc) diff --git a/docs/start.md b/docs/start.md index 93ca70d2..d8ac9613 100644 --- a/docs/start.md +++ b/docs/start.md @@ -267,3 +267,13 @@ ID: https://signatory.vault.azure.net/keys/key0/fa9607734e584851 *DISABLED* ``` + +--- + +## External policy service + +The remote policy service feature allows custom policy schemes beyond simple request and operation lookup to be implemented externally. + +The hook is called after the standard request type and operation checks. If the hook returns an error, Signatory denies signing the operation. + +See [the documentation](remote_policy.md) for more information diff --git a/go.mod b/go.mod index d13eb5da..0da5047e 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( go.opencensus.io v0.23.0 // indirect golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect - golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect + golang.org/x/term v0.1.0 golang.org/x/text v0.3.6 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/grpc v1.44.0 // indirect diff --git a/go.sum b/go.sum index f5f96023..f2a22f01 100644 --- a/go.sum +++ b/go.sum @@ -502,7 +502,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -512,8 +511,9 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/integration_test/signatory_test.go b/integration_test/signatory_test.go index bfcc5b18..a5fa71dd 100644 --- a/integration_test/signatory_test.go +++ b/integration_test/signatory_test.go @@ -16,10 +16,10 @@ import ( log "github.com/sirupsen/logrus" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/config" "github.com/ecadlabs/signatory/pkg/cryptoutils" "github.com/ecadlabs/signatory/pkg/server" - "github.com/ecadlabs/signatory/pkg/server/auth" "github.com/ecadlabs/signatory/pkg/signatory" "github.com/ecadlabs/signatory/pkg/tezos" "github.com/ecadlabs/signatory/pkg/vault" diff --git a/pkg/server/auth/auth.go b/pkg/auth/auth.go similarity index 100% rename from pkg/server/auth/auth.go rename to pkg/auth/auth.go diff --git a/pkg/server/auth/static.go b/pkg/auth/static.go similarity index 100% rename from pkg/server/auth/static.go rename to pkg/auth/static.go diff --git a/pkg/config/config.go b/pkg/config/config.go index ce9347b3..df333e15 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,12 @@ import ( yaml "gopkg.in/yaml.v3" ) +// PolicyHook is an external service for secondary validation of sign requests +type PolicyHook struct { + Address string `yaml:"address"` + AuthorizedKeys *AuthorizedKeys `yaml:"authorized_keys"` +} + // ServerConfig contains the information necessary to the tezos signing server type ServerConfig struct { Address string `yaml:"address" validate:"hostname_port"` @@ -36,10 +42,11 @@ type VaultConfig struct { // Config contains all the configuration necessary to run the signatory type Config struct { - Vaults map[string]*VaultConfig `yaml:"vaults" validate:"dive,required"` - Tezos TezosConfig `yaml:"tezos" validate:"dive,keys,startswith=tz1|startswith=tz2|startswith=tz3,len=36,endkeys"` - Server ServerConfig `yaml:"server"` - BaseDir string `yaml:"base_dir" validate:"required"` + Vaults map[string]*VaultConfig `yaml:"vaults" validate:"dive,required"` + Tezos TezosConfig `yaml:"tezos" validate:"dive,keys,startswith=tz1|startswith=tz2|startswith=tz3,len=36,endkeys"` + Server ServerConfig `yaml:"server"` + PolicyHook *PolicyHook `yaml:"policy_hook"` + BaseDir string `yaml:"base_dir" validate:"required"` } var defaultConfig = Config{ diff --git a/pkg/server/server.go b/pkg/server/server.go index 891dbd24..f9965d23 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -6,11 +6,12 @@ import ( "encoding/json" stderr "errors" "io/ioutil" + "net" "net/http" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/cryptoutils" "github.com/ecadlabs/signatory/pkg/errors" - "github.com/ecadlabs/signatory/pkg/server/auth" "github.com/ecadlabs/signatory/pkg/signatory" "github.com/ecadlabs/signatory/pkg/tezos" "github.com/ecadlabs/signatory/pkg/tezos/utils" @@ -85,6 +86,11 @@ func (s *Server) signHandler(w http.ResponseWriter, r *http.Request) { signRequest := signatory.SignRequest{ PublicKeyHash: mux.Vars(r)["key"], } + source, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + panic(err) // shouldn't happen with Go standard library + } + signRequest.Source = net.ParseIP(source) defer r.Body.Close() body, err := ioutil.ReadAll(r.Body) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 9d7ee99f..3d31fede 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -13,8 +13,8 @@ import ( "strings" "testing" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/server" - "github.com/ecadlabs/signatory/pkg/server/auth" "github.com/ecadlabs/signatory/pkg/signatory" "github.com/stretchr/testify/require" ) diff --git a/pkg/signatory/policy_hook.go b/pkg/signatory/policy_hook.go new file mode 100644 index 00000000..8f8f3417 --- /dev/null +++ b/pkg/signatory/policy_hook.go @@ -0,0 +1,26 @@ +package signatory + +import ( + "encoding/json" + "net" +) + +type PolicyHookRequest struct { + Request []byte `json:"request"` + Source net.IP `json:"source"` + ClientKeyHash string `json:"client_key_hash,omitempty"` + PublicKeyHash string `json:"public_key_hash"` + Nonce []byte `json:"nonce"` +} + +type PolicyHookReplyPayload struct { + Status int `json:"status"` // reflects the HTTP status + Error string `json:"error"` + PublicKeyHash string `json:"public_key_hash"` // the key used to sign the reply + Nonce []byte `json:"nonce"` +} + +type PolicyHookReply struct { + Payload json.RawMessage `json:"payload"` + Signature string `json:"signature"` +} diff --git a/pkg/signatory/policy_hook_test.go b/pkg/signatory/policy_hook_test.go new file mode 100644 index 00000000..6839580b --- /dev/null +++ b/pkg/signatory/policy_hook_test.go @@ -0,0 +1,184 @@ +//go:build !integration + +package signatory_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ecadlabs/signatory/pkg/auth" + "github.com/ecadlabs/signatory/pkg/config" + "github.com/ecadlabs/signatory/pkg/cryptoutils" + "github.com/ecadlabs/signatory/pkg/signatory" + "github.com/ecadlabs/signatory/pkg/tezos" + "github.com/ecadlabs/signatory/pkg/tezos/utils" + "github.com/ecadlabs/signatory/pkg/vault" + "github.com/ecadlabs/signatory/pkg/vault/memory" + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" +) + +func serveHookAuth(status int, pk cryptoutils.PrivateKey) (func(w http.ResponseWriter, r *http.Request), error) { + pub := pk.Public() + hash, err := tezos.EncodePublicKeyHash(pub) + if err != nil { + return nil, err + } + + return func(w http.ResponseWriter, r *http.Request) { + var req signatory.PolicyHookRequest + dec := json.NewDecoder(r.Body) + if err := dec.Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + replyPl := signatory.PolicyHookReplyPayload{ + Status: status, + PublicKeyHash: hash, + Nonce: req.Nonce, + } + + buf, err := json.Marshal(&replyPl) + if err != nil { + panic(err) + } + + digest := utils.DigestFunc(buf) + sig, err := cryptoutils.Sign(pk, digest[:]) + if err != nil { + panic(err) + } + + s, err := tezos.EncodeGenericSignature(sig) + if err != nil { + panic(err) + } + + reply := signatory.PolicyHookReply{ + Payload: buf, + Signature: s, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(&reply) + }, nil +} + +func serveHook(status int) (func(w http.ResponseWriter, r *http.Request), error) { + return func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + }, nil +} + +func testPolicyHookAuth(t *testing.T, status int) error { + // generate hook authentication key + _, hookPk, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + hookPub := hookPk.Public() + + handler, err := serveHookAuth(status, hookPk) + require.NoError(t, err) + + testSrv := httptest.NewServer(http.HandlerFunc(handler)) + defer testSrv.Close() + + hookAuth, err := auth.StaticAuthorizedKeys(hookPub) + require.NoError(t, err) + + _, signPk, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signKeyHash, err := tezos.EncodePublicKeyHash(signPk.Public()) + require.NoError(t, err) + + conf := signatory.Config{ + Vaults: map[string]*config.VaultConfig{"mock": {Driver: "mock"}}, + Watermark: signatory.IgnoreWatermark{}, + VaultFactory: vault.FactoryFunc(func(ctx context.Context, name string, conf *yaml.Node) (vault.Vault, error) { + return memory.New([]*memory.PrivateKey{ + { + PrivateKey: signPk, + KeyID: signKeyHash, + }, + }, "Mock") + }), + Policy: map[string]*signatory.Policy{ + signKeyHash: nil, + }, + PolicyHook: &signatory.PolicyHook{ + Address: testSrv.URL, + Auth: hookAuth, + }, + } + + s, err := signatory.New(context.Background(), &conf) + require.NoError(t, err) + + msg := mustHex("019caecab9000753d3029bc7d9a36b60cce68ade985a0a16929587166e0d3de61efff2fa31b7116bf670000000005ee3c23b04519d71c4e54089c56773c44979b3ba3d61078ade40332ad81577ae074f653e0e0000001100000001010000000800000000000753d2da051ba81185783e4cbc633cf2ba809139ef07c3e5f6c5867f930e7667b224430000cde7fbbb948e030000") + _, err = s.Sign(context.Background(), &signatory.SignRequest{PublicKeyHash: signKeyHash, Message: msg}) + return err +} + +func testPolicyHook(t *testing.T, status int) error { + // generate hook authentication key + handler, err := serveHook(status) + require.NoError(t, err) + + testSrv := httptest.NewServer(http.HandlerFunc(handler)) + defer testSrv.Close() + + _, signPk, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signKeyHash, err := tezos.EncodePublicKeyHash(signPk.Public()) + require.NoError(t, err) + + conf := signatory.Config{ + Vaults: map[string]*config.VaultConfig{"mock": {Driver: "mock"}}, + Watermark: signatory.IgnoreWatermark{}, + VaultFactory: vault.FactoryFunc(func(ctx context.Context, name string, conf *yaml.Node) (vault.Vault, error) { + return memory.New([]*memory.PrivateKey{ + { + PrivateKey: signPk, + KeyID: signKeyHash, + }, + }, "Mock") + }), + Policy: map[string]*signatory.Policy{ + signKeyHash: nil, + }, + PolicyHook: &signatory.PolicyHook{ + Address: testSrv.URL, + }, + } + + s, err := signatory.New(context.Background(), &conf) + require.NoError(t, err) + + msg := mustHex("019caecab9000753d3029bc7d9a36b60cce68ade985a0a16929587166e0d3de61efff2fa31b7116bf670000000005ee3c23b04519d71c4e54089c56773c44979b3ba3d61078ade40332ad81577ae074f653e0e0000001100000001010000000800000000000753d2da051ba81185783e4cbc633cf2ba809139ef07c3e5f6c5867f930e7667b224430000cde7fbbb948e030000") + _, err = s.Sign(context.Background(), &signatory.SignRequest{PublicKeyHash: signKeyHash, Message: msg}) + return err +} + +func TestPolicyHookAuth(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + require.NoError(t, testPolicyHookAuth(t, http.StatusOK)) + }) + t.Run("Deny", func(t *testing.T) { + require.EqualError(t, testPolicyHookAuth(t, http.StatusForbidden), "policy hook: 403 Forbidden") + }) +} + +func TestPolicyHook(t *testing.T) { + t.Run("Ok", func(t *testing.T) { + require.NoError(t, testPolicyHook(t, http.StatusOK)) + }) + t.Run("Deny", func(t *testing.T) { + require.EqualError(t, testPolicyHook(t, http.StatusForbidden), "policy hook: 403 Forbidden") + }) +} diff --git a/pkg/signatory/signatory.go b/pkg/signatory/signatory.go index a6889657..6d2600f0 100644 --- a/pkg/signatory/signatory.go +++ b/pkg/signatory/signatory.go @@ -1,15 +1,20 @@ package signatory import ( + "bytes" "context" + "crypto/rand" "encoding/hex" + "encoding/json" stderr "errors" "fmt" + "net" "net/http" "sort" "strings" "sync" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/config" "github.com/ecadlabs/signatory/pkg/cryptoutils" "github.com/ecadlabs/signatory/pkg/errors" @@ -81,6 +86,7 @@ type Signatory struct { type SignRequest struct { ClientPublicKeyHash string // optional, see policy PublicKeyHash string + Source net.IP // optional caller address Message []byte } @@ -195,6 +201,96 @@ func matchFilter(policy *Policy, req *SignRequest, msg tezos.UnsignedMessage) er return nil } +func (s *Signatory) callPolicyHook(ctx context.Context, req *SignRequest) error { + if s.config.PolicyHook == nil { + return nil + } + var nonce [32]byte + if _, err := rand.Read(nonce[:]); err != nil { + return err + } + + hookReq := PolicyHookRequest{ + Request: req.Message, + Source: req.Source, + PublicKeyHash: req.PublicKeyHash, + ClientKeyHash: req.ClientPublicKeyHash, + Nonce: nonce[:], + } + body, err := json.Marshal(&hookReq) + if err != nil { + return err + } + r, err := http.NewRequestWithContext(ctx, "POST", s.config.PolicyHook.Address, bytes.NewReader(body)) + if err != nil { + return err + } + r.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(r) + if err != nil { + return err + } + defer resp.Body.Close() + + if s.config.PolicyHook.Auth != nil { + // authenticate the responce + var reply PolicyHookReply + if err := json.NewDecoder(resp.Body).Decode(&reply); err != nil { + return err + } + var pl PolicyHookReplyPayload + if err := json.Unmarshal(reply.Payload, &pl); err != nil { + return err + } + if pl.Status != resp.StatusCode { + return stderr.New("the policy hook reply status must match the HTTP header") + } + if !bytes.Equal(pl.Nonce, hookReq.Nonce) { + return errors.Wrap(errors.New("nonce mismatch"), http.StatusForbidden) + } + pub, err := s.config.PolicyHook.Auth.GetPublicKey(ctx, pl.PublicKeyHash) + if err != nil { + errors.Wrap(err, http.StatusForbidden) + } + sig, err := tezos.ParseSignature(reply.Signature, pub) + if err != nil { + return err + } + digest := utils.DigestFunc(reply.Payload) + if err = cryptoutils.Verify(pub, digest[:], sig); err != nil { + if stderr.Is(err, cryptoutils.ErrSignature) { + return errors.Wrap(errors.New("invalid hook reply signature"), http.StatusForbidden) + } + return err + } + if resp.StatusCode/100 != 2 { + var msg string + if pl.Error != "" { + msg = pl.Error + } else { + msg = resp.Status + } + var status int + if resp.StatusCode/100 == 4 { + status = http.StatusForbidden + } else { + status = http.StatusInternalServerError + } + return errors.Wrap(fmt.Errorf("policy hook: %s", msg), status) + } + } else if resp.StatusCode/100 != 2 { + var status int + if resp.StatusCode/100 == 4 { + status = http.StatusForbidden + } else { + status = http.StatusInternalServerError + } + return errors.Wrap(fmt.Errorf("policy hook: %s", resp.Status), status) + } + + return nil +} + // Sign ask the vault to sign a message with the private key associated to keyHash func (s *Signatory) Sign(ctx context.Context, req *SignRequest) (string, error) { l := s.logger().WithField(logPKH, req.PublicKeyHash) @@ -246,6 +342,11 @@ func (s *Signatory) Sign(ctx context.Context, req *SignRequest) (string, error) return "", errors.Wrap(err, http.StatusForbidden) } + if err = s.callPolicyHook(ctx, req); err != nil { + l.Error(err) + return "", err + } + l.Info("Requesting signing operation") level := log.DebugLevel @@ -419,6 +520,11 @@ func (s *Signatory) Unlock(ctx context.Context) error { return nil } +type PolicyHook struct { + Address string + Auth auth.AuthorizedKeysStorage +} + // Config represents Signatory configuration type Config struct { Policy map[string]*Policy @@ -427,6 +533,7 @@ type Config struct { Watermark Watermark Logger log.FieldLogger VaultFactory vault.Factory + PolicyHook *PolicyHook } // New returns Signatory instance diff --git a/test/auth_test.go b/test/auth_test.go index f6bfbbf4..99b622f1 100644 --- a/test/auth_test.go +++ b/test/auth_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" + "github.com/ecadlabs/signatory/pkg/auth" "github.com/ecadlabs/signatory/pkg/config" "github.com/ecadlabs/signatory/pkg/server" - "github.com/ecadlabs/signatory/pkg/server/auth" "github.com/ecadlabs/signatory/pkg/signatory" "github.com/ecadlabs/signatory/pkg/tezos" "github.com/ecadlabs/signatory/pkg/vault" diff --git a/website/sidebars.js b/website/sidebars.js index 8e8af437..ca50c5b4 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -22,7 +22,7 @@ const sidebars = { className: 'sidebarHeader', collapsed: false, collapsible: false, - items: ['start', 'file_based', 'yubihsm', 'azure_kms', 'gcp_kms', 'aws_kms', 'ledger', `cli`], + items: ['start', 'file_based', 'yubihsm', 'azure_kms', 'gcp_kms', 'aws_kms', 'ledger', `cli`, 'remote_policy'], }, ], };