From 9cc02d1a0da4feb04d32a7731e9dbb21d55e485f Mon Sep 17 00:00:00 2001 From: kfox1111 Date: Mon, 5 Aug 2024 20:37:45 -0700 Subject: [PATCH] Add http node attestor (#4909) Signed-off-by: Kevin Fox --- ...lugin_agent_nodeattestor_http_challenge.md | 49 ++ ...ugin_server_nodeattestor_http_challenge.md | 55 +++ pkg/agent/catalog/nodeattestor.go | 2 + .../httpchallenge/httpchallenge.go | 234 ++++++++++ .../httpchallenge/httpchallenge_test.go | 211 +++++++++ .../plugin/httpchallenge/httpchallenge.go | 115 +++++ .../httpchallenge/httpchallenge_test.go | 100 ++++ pkg/server/catalog/nodeattestor.go | 2 + .../httpchallenge/httpchallenge.go | 288 ++++++++++++ .../httpchallenge/httpchallenge_test.go | 433 ++++++++++++++++++ 10 files changed, 1489 insertions(+) create mode 100644 doc/plugin_agent_nodeattestor_http_challenge.md create mode 100644 doc/plugin_server_nodeattestor_http_challenge.md create mode 100644 pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go create mode 100644 pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge_test.go create mode 100644 pkg/common/plugin/httpchallenge/httpchallenge.go create mode 100644 pkg/common/plugin/httpchallenge/httpchallenge_test.go create mode 100644 pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go create mode 100644 pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go diff --git a/doc/plugin_agent_nodeattestor_http_challenge.md b/doc/plugin_agent_nodeattestor_http_challenge.md new file mode 100644 index 00000000000..5fd14c1e3d8 --- /dev/null +++ b/doc/plugin_agent_nodeattestor_http_challenge.md @@ -0,0 +1,49 @@ +# Agent plugin: NodeAttestor "http_challenge" + +*Must be used in conjunction with the server-side http_challenge plugin* + +The `http_challenge` plugin handshakes via http to ensure the agent is running on a valid +dns name. + +The SPIFFE ID produced by the server-side `http_challenge` plugin is based on the dns name of the agent. +The SPIFFE ID has the form: + +```xml +spiffe:///spire/agent/http_challenge/ +``` + +| Configuration | Description | Default | +|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|-----------| +| `hostname` | Hostname to use for handshaking. If unset, it will be automatically detected. | | +| `agentname` | Name of this agent on the host. Useful if you have multiple agents bound to different spire servers on the same host and sharing the same port. | "default" | +| `port` | The port to listen on. If unspecified, a random value will be used. | random | +| `advertised_port` | The port to tell the server to call back on. | $port | + +If `advertised_port` != `port`, you will need to setup an http proxy between the two ports. This is useful if you already run a webserver on port 80. + +A sample configuration: + +```hcl + NodeAttestor "http_challenge" { + plugin_data { + port = 80 + } + } +``` + +## Proxies + +Say you want to validate using port 80 to be internet firewall friendly. If you already have a webserver on port 80 or want to use multiple agents with different SPIRE servers and use the same port, +you can have your webserver proxy over to the SPIRE agent(s) by setting up a proxy on `/.well-known/spiffe/nodeattestor/http_challenge/$agentname` to +`http://localhost:$port/.well-known/spiffe/nodeattestor/http_challenge/$agentname`. + +Example spire agent configuration: + +```hcl + NodeAttestor "http_challenge" { + plugin_data { + port = 8080 + advertised_port = 80 + } + } +``` diff --git a/doc/plugin_server_nodeattestor_http_challenge.md b/doc/plugin_server_nodeattestor_http_challenge.md new file mode 100644 index 00000000000..515adf500ef --- /dev/null +++ b/doc/plugin_server_nodeattestor_http_challenge.md @@ -0,0 +1,55 @@ +# Server plugin: NodeAttestor "http_challenge" + +*Must be used in conjunction with the agent-side http_challenge plugin* + +The `http_challenge` plugin handshakes via http to ensure the agent is running on a valid +dns name. + +The SPIFFE ID produced by the plugin is based on the dns name attested. +The SPIFFE ID has the form: + +```xml +spiffe:///spire/agent/http_challenge/ +``` + +| Configuration | Description | Default | +|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| +| `allowed_dns_patterns` | A list of regular expressions to match to the hostname being attested. If none match, attestation will fail. If unset, all hostnames are allowed. | | +| `required_port` | Set to a port number to require clients to listen only on that port. If unset, all port numbers are allowed | | +| `allow_non_root_ports` | Set to true to allow ports >= 1024 to be used by the agents with the advertised_port | true | +| `tofu` | Trust on first use of the successful challenge. Can only be disabled if allow_non_root_ports=false or required_port < 1024 | true | + +A sample configuration: + +```hcl + NodeAttestor "http_challenge" { + plugin_data { + # Only match hosts that start with p, have a number, then end in example.com. Ex: 'p1.example.com' + allowed_dns_patterns = ["p[0-9]\.example\.com"] + + # Only allow clients to use port 80 + required_port = 80 + + # Change the agent's SPIFFE ID format + # agent_path_template = "/spire/agent/http_challenge/{{ .Hostname }}" + } + } +``` + +## Selectors + +| Selector | Example | Description | +|----------|------------------------------------------|------------------------| +| Hostname | `http_challenge:hostname:p1.example.com` | The Subject's Hostname | + +## Security Considerations + +Generally, TCP ports are accessible to any user of the node. As a result, it is possible for non-agent code running on a node to attest to the SPIRE Server, allowing it to obtain any workload identity that the node is authorized to run. + +The `http_challenge` node attestor implements multiple features to mitigate the risk. + +Trust On First Use (or TOFU) is one such option. For any given node, attestation may occur only once when enabled. Subsequent attestation attempts will be rejected. + +With TOFU, it is still possible for non-agent code to complete node attestation before SPIRE Agent can, however this condition is easily and quickly detectable as SPIRE Agent will fail to start, and both SPIRE Agent and SPIRE Server will log the occurrence. Such cases should be investigated as possible security incidents. + +You also can require the port to be a trusted port that only trusted user such as root can open (port number < 1024). diff --git a/pkg/agent/catalog/nodeattestor.go b/pkg/agent/catalog/nodeattestor.go index eee9a0ddda6..5db0ca8d264 100644 --- a/pkg/agent/catalog/nodeattestor.go +++ b/pkg/agent/catalog/nodeattestor.go @@ -5,6 +5,7 @@ import ( "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/awsiid" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/azuremsi" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/gcpiit" + "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/httpchallenge" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/jointoken" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8spsat" "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/k8ssat" @@ -37,6 +38,7 @@ func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn { awsiid.BuiltIn(), azuremsi.BuiltIn(), gcpiit.BuiltIn(), + httpchallenge.BuiltIn(), jointoken.BuiltIn(), k8spsat.BuiltIn(), k8ssat.BuiltIn(), diff --git a/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go b/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go new file mode 100644 index 00000000000..9f1b69754a3 --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go @@ -0,0 +1,234 @@ +package httpchallenge + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl" + nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/agent/nodeattestor/v1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/common/plugin/httpchallenge" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + pluginName = "http_challenge" +) + +func BuiltIn() catalog.BuiltIn { + return builtin(New()) +} + +func BuiltInWithHostname(hostname string) catalog.BuiltIn { + plugin := New() + plugin.hostname = hostname + return builtin(plugin) +} + +func builtin(p *Plugin) catalog.BuiltIn { + return catalog.MakeBuiltIn(pluginName, + nodeattestorv1.NodeAttestorPluginServer(p), + configv1.ConfigServiceServer(p)) +} + +type configData struct { + port int + advertisedPort int + hostName string + agentName string +} + +type Config struct { + HostName string `hcl:"hostname"` + AgentName string `hcl:"agentname"` + Port int `hcl:"port"` + AdvertisedPort int `hcl:"advertised_port"` +} + +type Plugin struct { + nodeattestorv1.UnsafeNodeAttestorServer + configv1.UnsafeConfigServer + + m sync.Mutex + c *Config + + log hclog.Logger + + hostname string +} + +func New() *Plugin { + return &Plugin{} +} + +func (p *Plugin) AidAttestation(stream nodeattestorv1.NodeAttestor_AidAttestationServer) (err error) { + data, err := p.loadConfigData() + if err != nil { + return err + } + + ctx := stream.Context() + + port := data.port + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return status.Errorf(codes.Internal, "could not listen on port %d: %v", port, err) + } + defer l.Close() + + advertisedPort := data.advertisedPort + if advertisedPort == 0 { + advertisedPort = l.Addr().(*net.TCPAddr).Port + } + + attestationPayload, err := json.Marshal(httpchallenge.AttestationData{ + HostName: data.hostName, + AgentName: data.agentName, + Port: advertisedPort, + }) + if err != nil { + return status.Errorf(codes.Internal, "unable to marshal attestation data: %v", err) + } + + // send the attestation data back to the agent + if err := stream.Send(&nodeattestorv1.PayloadOrChallengeResponse{ + Data: &nodeattestorv1.PayloadOrChallengeResponse_Payload{ + Payload: attestationPayload, + }, + }); err != nil { + return err + } + + // receive challenge + resp, err := stream.Recv() + if err != nil { + return err + } + + challenge := new(httpchallenge.Challenge) + if err := json.Unmarshal(resp.Challenge, challenge); err != nil { + return status.Errorf(codes.Internal, "unable to unmarshal challenge: %v", err) + } + + // due to https://github.com/spiffe/spire/blob/8f9fa036e182a2fab968e03cd25a7fdb2d8c88bb/pkg/agent/plugin/nodeattestor/v1.go#L63, we must respond with a non blank challenge response + responseBytes := []byte{'\n'} + if err := stream.Send(&nodeattestorv1.PayloadOrChallengeResponse{ + Data: &nodeattestorv1.PayloadOrChallengeResponse_ChallengeResponse{ + ChallengeResponse: responseBytes, + }, + }); err != nil { + return err + } + + err = p.serveNonce(ctx, l, data.agentName, challenge.Nonce) + if err != nil { + return status.Errorf(codes.Internal, "failed to start webserver: %v", err) + } + return nil +} + +func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + // Parse HCL config payload into config struct + config := new(Config) + if err := hcl.Decode(config, req.HclConfiguration); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "unable to decode configuration: %v", err) + } + + // Make sure the configuration produces valid data + if _, err := loadConfigData(p.hostname, config); err != nil { + return nil, err + } + + p.setConfig(config) + + return &configv1.ConfigureResponse{}, nil +} + +func (p *Plugin) serveNonce(ctx context.Context, l net.Listener, agentName string, nonce string) (err error) { + h := http.NewServeMux() + s := &http.Server{ + Handler: h, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + path := fmt.Sprintf("/.well-known/spiffe/nodeattestor/http_challenge/%s/challenge", agentName) + p.log.Debug("Setting up nonce handler", "path", path) + h.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, nonce) + }) + + go func() { + <-ctx.Done() + _ = s.Shutdown(context.Background()) + }() + + err = s.Serve(l) + if err == http.ErrServerClosed { + return nil + } + return err +} + +// SetLogger sets this plugin's logger +func (p *Plugin) SetLogger(log hclog.Logger) { + p.log = log +} + +func (p *Plugin) getConfig() *Config { + p.m.Lock() + defer p.m.Unlock() + return p.c +} + +func (p *Plugin) setConfig(c *Config) { + p.m.Lock() + defer p.m.Unlock() + p.c = c +} + +func (p *Plugin) loadConfigData() (*configData, error) { + config := p.getConfig() + if config == nil { + return nil, status.Error(codes.FailedPrecondition, "not configured") + } + return loadConfigData(p.hostname, config) +} + +func loadConfigData(hostname string, config *Config) (*configData, error) { + if config.HostName == "" { + if hostname != "" { + config.HostName = hostname + } else { + var err error + config.HostName, err = os.Hostname() + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "unable to fetch hostname: %v", err) + } + } + } + var agentName = "default" + if config.AgentName != "" { + agentName = config.AgentName + } + + if config.AdvertisedPort == 0 { + config.AdvertisedPort = config.Port + } + + return &configData{ + port: config.Port, + advertisedPort: config.AdvertisedPort, + hostName: config.HostName, + agentName: agentName, + }, nil +} diff --git a/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge_test.go b/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge_test.go new file mode 100644 index 00000000000..9f1fa144d3c --- /dev/null +++ b/pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge_test.go @@ -0,0 +1,211 @@ +//go:build !darwin + +package httpchallenge_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "testing" + + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor" + "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/httpchallenge" + nodeattestortest "github.com/spiffe/spire/pkg/agent/plugin/nodeattestor/test" + common_httpchallenge "github.com/spiffe/spire/pkg/common/plugin/httpchallenge" + "github.com/spiffe/spire/test/plugintest" + "github.com/stretchr/testify/require" +) + +var ( + streamBuilder = nodeattestortest.ServerStream("http_challenge") +) + +func TestConfigureCommon(t *testing.T) { + tests := []struct { + name string + hclConf string + expErr string + }{ + { + name: "Configure fails if receives wrong HCL configuration", + hclConf: "not HCL conf", + expErr: "rpc error: code = InvalidArgument desc = unable to decode configuration", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + plugin := httpchallenge.New() + + resp, err := plugin.Configure(context.Background(), &configv1.ConfigureRequest{HclConfiguration: tt.hclConf}) + if tt.expErr != "" { + require.Contains(t, err.Error(), tt.expErr) + require.Nil(t, resp) + return + } + + require.NoError(t, err) + require.NotNil(t, resp) + }) + } +} + +func TestAidAttestationFailures(t *testing.T) { + tests := []struct { + name string + config string + expErr string + serverStream nodeattestor.ServerStream + }{ + { + name: "AidAttestation fails if server does not sends a challenge", + config: "", + expErr: "the error", + serverStream: streamBuilder.FailAndBuild(errors.New("the error")), + }, + { + name: "AidAttestation fails if agent cannot unmarshal server challenge", + config: "", + expErr: "rpc error: code = Internal desc = nodeattestor(http_challenge): unable to unmarshal challenge: invalid character 'o' in literal null (expecting 'u')", + serverStream: streamBuilder.IgnoreThenChallenge([]byte("not-a-challenge")).Build(), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var err error + p := loadAndConfigurePlugin(t, tt.config) + + err = p.Attest(context.Background(), tt.serverStream) + if tt.expErr != "" { + require.Contains(t, err.Error(), tt.expErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestAidAttestationSucceeds(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := l.Addr().(*net.TCPAddr).Port + defer l.Close() + + tests := []struct { + name string + config string + attestationData common_httpchallenge.AttestationData + serverStream func(attestationData *common_httpchallenge.AttestationData, challenge []byte, expectPayload []byte, challengeobj *common_httpchallenge.Challenge, port int) nodeattestor.ServerStream + }{ + { + name: "Check for random port", + config: "", + attestationData: common_httpchallenge.AttestationData{ + HostName: "spire-dev", + AgentName: "default", + }, + serverStream: func(attestationData *common_httpchallenge.AttestationData, challenge []byte, expectPayload []byte, challengeobj *common_httpchallenge.Challenge, port int) nodeattestor.ServerStream { + return streamBuilder. + Handle(func(challenge []byte) ([]byte, error) { + attestationData := new(common_httpchallenge.AttestationData) + if err := json.Unmarshal(challenge, attestationData); err != nil { + return nil, err + } + if attestationData.Port == port { + return nil, errors.New("random port failed") + } + return nil, nil + }).Build() + }, + }, + { + name: "Check for advertised port", + config: fmt.Sprintf("advertised_port = %d", port), + attestationData: common_httpchallenge.AttestationData{ + HostName: "spire-dev", + AgentName: "default", + }, + serverStream: func(attestationData *common_httpchallenge.AttestationData, challenge []byte, expectPayload []byte, challengeobj *common_httpchallenge.Challenge, port int) nodeattestor.ServerStream { + return streamBuilder. + Handle(func(challenge []byte) ([]byte, error) { + attestationData := new(common_httpchallenge.AttestationData) + if err := json.Unmarshal(challenge, attestationData); err != nil { + return nil, err + } + if attestationData.Port != port { + return nil, errors.New("advertised port failed") + } + return nil, nil + }).Build() + }, + }, + { + name: "Test with defaults except port", + config: "port=9999", + attestationData: common_httpchallenge.AttestationData{ + HostName: "localhost", + AgentName: "default", + Port: 9999, + }, + serverStream: func(attestationData *common_httpchallenge.AttestationData, challenge []byte, expectPayload []byte, challengeobj *common_httpchallenge.Challenge, port int) nodeattestor.ServerStream { + return streamBuilder.IgnoreThenChallenge(challenge). + Handle(func(challengeResponse []byte) ([]byte, error) { + err := common_httpchallenge.VerifyChallenge(context.Background(), http.DefaultClient, attestationData, challengeobj) + return nil, err + }).Build() + }, + }, + { + name: "Full test with all the settings", + config: "hostname=\"localhost\"\nagentname=\"test\"\nport=9999\nadvertised_port=9999", + attestationData: common_httpchallenge.AttestationData{ + HostName: "localhost", + AgentName: "test", + Port: 9999, + }, + serverStream: func(attestationData *common_httpchallenge.AttestationData, challenge []byte, expectPayload []byte, challengeobj *common_httpchallenge.Challenge, port int) nodeattestor.ServerStream { + return streamBuilder.ExpectThenChallenge(expectPayload, challenge). + Handle(func(challengeResponse []byte) ([]byte, error) { + err := common_httpchallenge.VerifyChallenge(context.Background(), http.DefaultClient, attestationData, challengeobj) + return nil, err + }).Build() + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var err error + expectPayload, err := json.Marshal(&tt.attestationData) + require.NoError(t, err) + + challengeobj, err := common_httpchallenge.GenerateChallenge("") + require.NoError(t, err) + + challenge, err := json.Marshal(challengeobj) + require.NoError(t, err) + + p := loadAndConfigurePlugin(t, tt.config) + + err = p.Attest(context.Background(), tt.serverStream(&tt.attestationData, challenge, expectPayload, challengeobj, port)) + require.NoError(t, err) + }) + } +} + +func loadAndConfigurePlugin(t *testing.T, config string) nodeattestor.NodeAttestor { + return loadPlugin(t, plugintest.Configure(config)) +} + +func loadPlugin(t *testing.T, options ...plugintest.Option) nodeattestor.NodeAttestor { + na := new(nodeattestor.V1) + plugintest.Load(t, httpchallenge.BuiltInWithHostname("localhost"), na, options...) + return na +} diff --git a/pkg/common/plugin/httpchallenge/httpchallenge.go b/pkg/common/plugin/httpchallenge/httpchallenge.go new file mode 100644 index 00000000000..2f3e7e36f8c --- /dev/null +++ b/pkg/common/plugin/httpchallenge/httpchallenge.go @@ -0,0 +1,115 @@ +package httpchallenge + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire/pkg/common/idutil" +) + +const ( + nonceLen = 32 + + // PluginName for http based attestor + PluginName = "http_challenge" +) + +type AttestationData struct { + HostName string `json:"hostname"` + AgentName string `json:"agentname"` + Port int `json:"port"` +} + +type Challenge struct { + Nonce string `json:"nonce"` +} + +type Response struct { +} + +func GenerateChallenge(forceNonce string) (*Challenge, error) { + nonce := forceNonce + if nonce == "" { + var err error + nonce, err = generateNonce() + if err != nil { + return nil, err + } + } + return &Challenge{Nonce: nonce}, nil +} + +func CalculateResponse(_ *Challenge) (*Response, error) { + return &Response{}, nil +} + +func VerifyChallenge(ctx context.Context, client *http.Client, attestationData *AttestationData, challenge *Challenge) error { + if attestationData.HostName == "" { + return fmt.Errorf("hostname must be set") + } + if attestationData.AgentName == "" { + return fmt.Errorf("agentname must be set") + } + if attestationData.Port <= 0 { + return fmt.Errorf("port is invalid") + } + if strings.Contains(attestationData.HostName, "/") { + return fmt.Errorf("hostname can not contain a slash") + } + if strings.Contains(attestationData.HostName, ":") { + return fmt.Errorf("hostname can not contain a colon") + } + if strings.Contains(attestationData.AgentName, ".") { + return fmt.Errorf("agentname can not contain a dot") + } + turl := url.URL{ + Scheme: "http", + Host: net.JoinHostPort(attestationData.HostName, strconv.Itoa(attestationData.Port)), + Path: fmt.Sprintf("/.well-known/spiffe/nodeattestor/http_challenge/%s/challenge", attestationData.AgentName), + } + + req, err := http.NewRequestWithContext(ctx, "GET", turl.String(), nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return err + } + nonce := strings.TrimSpace(string(body)) + if nonce != challenge.Nonce { + return fmt.Errorf("expected nonce %q but got %q", challenge.Nonce, body) + } + return nil +} + +// MakeAgentID creates an agent ID +func MakeAgentID(td spiffeid.TrustDomain, hostName string) (spiffeid.ID, error) { + agentPath := fmt.Sprintf("/http_challenge/%s", hostName) + + return idutil.AgentID(td, agentPath) +} + +func generateNonce() (string, error) { + b := make([]byte, nonceLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/pkg/common/plugin/httpchallenge/httpchallenge_test.go b/pkg/common/plugin/httpchallenge/httpchallenge_test.go new file mode 100644 index 00000000000..0ec27f320ad --- /dev/null +++ b/pkg/common/plugin/httpchallenge/httpchallenge_test.go @@ -0,0 +1,100 @@ +package httpchallenge + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateChallenge(t *testing.T) { + tests := []struct { + desc string + hostName string + agentName string + nonce string + testNonce string + expectErr string + }{ + { + desc: "bad hostName", + hostName: "foo/bar", + agentName: "ok", + nonce: "1234", + testNonce: "1234", + expectErr: "hostname can not contain a slash", + }, + { + desc: "bad hostName", + hostName: "foo:bar", + agentName: "ok", + nonce: "1234", + testNonce: "1234", + expectErr: "hostname can not contain a colon", + }, + { + desc: "bad agentName", + hostName: "foo.bar", + agentName: "not.ok", + nonce: "1234", + testNonce: "1234", + expectErr: "agentname can not contain a dot", + }, + { + desc: "fail nonce", + hostName: "foo.bar", + agentName: "ok", + nonce: "1234", + testNonce: "1235", + expectErr: "expected nonce \"1235\" but got \"1234\"", + }, + { + desc: "success", + hostName: "foo.bar", + agentName: "ok", + nonce: "1234", + testNonce: "1234", + expectErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ad := &AttestationData{ + HostName: tt.hostName, + AgentName: tt.agentName, + Port: 80, + } + c := &Challenge{ + Nonce: tt.testNonce, + } + + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + _, err := res.Write([]byte(tt.nonce)) + require.NoError(t, err) + })) + defer func() { testServer.Close() }() + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + if addr == "foo.bar:80" { + addr = strings.TrimPrefix(testServer.URL, "http://") + } + dialer := &net.Dialer{} + return dialer.DialContext(ctx, network, addr) + } + + err := VerifyChallenge(context.Background(), &http.Client{Transport: transport}, ad, c) + if tt.expectErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectErr) + return + } + require.NoError(t, err) + }) + } +} diff --git a/pkg/server/catalog/nodeattestor.go b/pkg/server/catalog/nodeattestor.go index 1340d5b3257..fe743889dcb 100644 --- a/pkg/server/catalog/nodeattestor.go +++ b/pkg/server/catalog/nodeattestor.go @@ -6,6 +6,7 @@ import ( "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/awsiid" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/azuremsi" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/gcpiit" + "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/httpchallenge" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/jointoken" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8spsat" "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/k8ssat" @@ -37,6 +38,7 @@ func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn { awsiid.BuiltIn(), azuremsi.BuiltIn(), gcpiit.BuiltIn(), + httpchallenge.BuiltIn(), jointoken.BuiltIn(), k8spsat.BuiltIn(), k8ssat.BuiltIn(), diff --git a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go new file mode 100644 index 00000000000..c4ba141a42a --- /dev/null +++ b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge.go @@ -0,0 +1,288 @@ +package httpchallenge + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "sync" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl" + "github.com/spiffe/go-spiffe/v2/spiffeid" + nodeattestorv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/nodeattestor/v1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/common/plugin/httpchallenge" + nodeattestorbase "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/base" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + pluginName = "http_challenge" +) + +var ( + agentNamePattern = regexp.MustCompile("^[a-zA-z]+[a-zA-Z0-9-]$") +) + +func BuiltIn() catalog.BuiltIn { + return builtin(New()) +} + +func BuiltInTesting(client *http.Client, forceNonce string) catalog.BuiltIn { + plugin := New() + plugin.client = client + plugin.forceNonce = forceNonce + return builtin(plugin) +} + +func builtin(p *Plugin) catalog.BuiltIn { + return catalog.MakeBuiltIn(pluginName, + nodeattestorv1.NodeAttestorPluginServer(p), + configv1.ConfigServiceServer(p), + ) +} + +type configuration struct { + trustDomain spiffeid.TrustDomain + requiredPort *int + allowNonRootPorts bool + dnsPatterns []*regexp.Regexp + tofu bool +} + +type Config struct { + AllowedDNSPatterns []string `hcl:"allowed_dns_patterns"` + RequiredPort *int `hcl:"required_port"` + AllowNonRootPorts *bool `hcl:"allow_non_root_ports"` + TOFU *bool `hcl:"tofu"` +} + +type Plugin struct { + nodeattestorbase.Base + nodeattestorv1.UnsafeNodeAttestorServer + configv1.UnsafeConfigServer + + m sync.Mutex + config *configuration + + log hclog.Logger + + client *http.Client + forceNonce string +} + +func New() *Plugin { + return &Plugin{ + client: http.DefaultClient, + } +} + +func (p *Plugin) Attest(stream nodeattestorv1.NodeAttestor_AttestServer) error { + req, err := stream.Recv() + if err != nil { + return err + } + + config, err := p.getConfig() + if err != nil { + return err + } + + payload := req.GetPayload() + if payload == nil { + return status.Error(codes.InvalidArgument, "missing attestation payload") + } + + attestationData := new(httpchallenge.AttestationData) + if err := json.Unmarshal(payload, attestationData); err != nil { + return status.Errorf(codes.InvalidArgument, "failed to unmarshal data: %v", err) + } + + if config.requiredPort != nil && attestationData.Port != *config.requiredPort { + return status.Errorf(codes.InvalidArgument, "port %d is not allowed to be used by this server", attestationData.Port) + } + if (!config.allowNonRootPorts) && attestationData.Port >= 1024 { + return status.Errorf(codes.InvalidArgument, "port %d is not allowed to be >= 1024", attestationData.Port) + } + + if err = validateAgentName(attestationData.AgentName); err != nil { + return err + } + + if err = validateHostName(attestationData.HostName, config.dnsPatterns); err != nil { + return err + } + + challenge, err := httpchallenge.GenerateChallenge(p.forceNonce) + if err != nil { + return status.Errorf(codes.Internal, "unable to generate challenge: %v", err) + } + + challengeBytes, err := json.Marshal(challenge) + if err != nil { + return status.Errorf(codes.Internal, "unable to marshal challenge: %v", err) + } + + if err := stream.Send(&nodeattestorv1.AttestResponse{ + Response: &nodeattestorv1.AttestResponse_Challenge{ + Challenge: challengeBytes, + }, + }); err != nil { + return err + } + + // receive the response. We dont really care what it is but the plugin system requiries it. + _, err = stream.Recv() + if err != nil { + return err + } + + p.log.Debug("Verifying challenge") + + timeoutctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := httpchallenge.VerifyChallenge(timeoutctx, p.client, attestationData, challenge); err != nil { + return status.Errorf(codes.PermissionDenied, "challenge verification failed: %v", err) + } + + spiffeid, err := httpchallenge.MakeAgentID(config.trustDomain, attestationData.HostName) + if err != nil { + return status.Errorf(codes.Internal, "failed to make spiffe id: %v", err) + } + + if config.tofu { + if err := p.AssessTOFU(stream.Context(), spiffeid.String(), p.log); err != nil { + return err + } + } + + return stream.Send(&nodeattestorv1.AttestResponse{ + Response: &nodeattestorv1.AttestResponse_AgentAttributes{ + AgentAttributes: &nodeattestorv1.AgentAttributes{ + SpiffeId: spiffeid.String(), + SelectorValues: buildSelectorValues(attestationData.HostName), + CanReattest: !config.tofu, + }, + }, + }) +} + +func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + hclConfig := new(Config) + if err := hcl.Decode(hclConfig, req.HclConfiguration); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "unable to decode configuration: %v", err) + } + + if req.CoreConfiguration == nil { + return nil, status.Error(codes.InvalidArgument, "core configuration is required") + } + + if req.CoreConfiguration.TrustDomain == "" { + return nil, status.Error(codes.InvalidArgument, "trust_domain is required") + } + + trustDomain, err := spiffeid.TrustDomainFromString(req.CoreConfiguration.TrustDomain) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "trust_domain is invalid: %v", err) + } + + var dnsPatterns []*regexp.Regexp + for _, r := range hclConfig.AllowedDNSPatterns { + re := regexp.MustCompile(r) + dnsPatterns = append(dnsPatterns, re) + } + + allowNonRootPorts := true + if hclConfig.AllowNonRootPorts != nil { + allowNonRootPorts = *hclConfig.AllowNonRootPorts + } + + tofu := true + if hclConfig.TOFU != nil { + tofu = *hclConfig.TOFU + } + + mustUseTOFU := false + switch { + // User has explicitly asked for a required port that is untrusted + case hclConfig.RequiredPort != nil && *hclConfig.RequiredPort >= 1024: + mustUseTOFU = true + // User has just chosen the defaults, any port is allowed + case hclConfig.AllowNonRootPorts == nil && hclConfig.RequiredPort == nil: + mustUseTOFU = true + // User explicitly set AllowNonRootPorts to true and no required port specified + case hclConfig.AllowNonRootPorts != nil && *hclConfig.AllowNonRootPorts && hclConfig.RequiredPort == nil: + mustUseTOFU = true + } + + if !tofu && mustUseTOFU { + return nil, status.Errorf(codes.InvalidArgument, "you can not turn off trust on first use (TOFU) when non-root ports are allowed") + } + + p.setConfiguration(&configuration{ + trustDomain: trustDomain, + dnsPatterns: dnsPatterns, + requiredPort: hclConfig.RequiredPort, + allowNonRootPorts: allowNonRootPorts, + tofu: tofu, + }) + + return &configv1.ConfigureResponse{}, nil +} + +// SetLogger sets this plugin's logger +func (p *Plugin) SetLogger(log hclog.Logger) { + p.log = log +} + +func (p *Plugin) getConfig() (*configuration, error) { + p.m.Lock() + defer p.m.Unlock() + if p.config == nil { + return nil, status.Errorf(codes.FailedPrecondition, "not configured") + } + return p.config, nil +} + +func (p *Plugin) setConfiguration(config *configuration) { + p.m.Lock() + defer p.m.Unlock() + p.config = config +} + +func buildSelectorValues(hostName string) []string { + var selectorValues []string + + selectorValues = append(selectorValues, "hostname:"+hostName) + + return selectorValues +} + +func validateAgentName(agentName string) error { + l := agentNamePattern.FindAllStringSubmatch(agentName, -1) + if len(l) != 1 || len(l[0]) == 0 || len(l[0]) > 32 { + return status.Error(codes.InvalidArgument, "agent name is not valid") + } + return nil +} + +func validateHostName(hostName string, dnsPatterns []*regexp.Regexp) error { + if hostName == "localhost" { + return status.Errorf(codes.PermissionDenied, "you can not use localhost as a hostname") + } + if len(dnsPatterns) == 0 { + return nil + } + for _, re := range dnsPatterns { + l := re.FindAllStringSubmatch(hostName, -1) + if len(l) > 0 { + return nil + } + } + return status.Errorf(codes.PermissionDenied, "the requested hostname is not allowed to connect") +} diff --git a/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go new file mode 100644 index 00000000000..cdde03f8e98 --- /dev/null +++ b/pkg/server/plugin/nodeattestor/httpchallenge/httpchallenge_test.go @@ -0,0 +1,433 @@ +package httpchallenge_test + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + neturl "net/url" + "testing" + + "github.com/spiffe/go-spiffe/v2/spiffeid" + agentstorev1 "github.com/spiffe/spire-plugin-sdk/proto/spire/hostservice/server/agentstore/v1" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + common_httpchallenge "github.com/spiffe/spire/pkg/common/plugin/httpchallenge" + "github.com/spiffe/spire/pkg/server/plugin/nodeattestor" + "github.com/spiffe/spire/pkg/server/plugin/nodeattestor/httpchallenge" + "github.com/spiffe/spire/proto/spire/common" + "github.com/spiffe/spire/test/fakes/fakeagentstore" + "github.com/spiffe/spire/test/plugintest" + "github.com/stretchr/testify/require" +) + +func TestConfigure(t *testing.T) { + tests := []struct { + name string + hclConf string + coreConf *configv1.CoreConfiguration + expErr string + }{ + { + name: "Configure fails if core config is not provided", + expErr: "rpc error: code = InvalidArgument desc = core configuration is required", + }, + { + name: "Configure fails if trust domain is empty", + expErr: "rpc error: code = InvalidArgument desc = trust_domain is required", + coreConf: &configv1.CoreConfiguration{}, + }, + { + name: "Configure fails if HCL config cannot be decoded", + expErr: "rpc error: code = InvalidArgument desc = unable to decode configuration", + coreConf: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + hclConf: "not an HCL configuration", + }, + { + name: "Configure fails if tofu and allow_non_root_ports", + expErr: "rpc error: code = InvalidArgument desc = you can not turn off trust on first use (TOFU) when non-root ports are allowed", + coreConf: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + hclConf: "tofu = false\nallow_non_root_ports = true", + }, + { + name: "Configure fails if tofu and required port >= 1024", + expErr: "rpc error: code = InvalidArgument desc = you can not turn off trust on first use (TOFU) when non-root ports are allowed", + coreConf: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + hclConf: "tofu = false\nrequired_port = 1024", + }, + { + name: "Configure fails if tofu and no other args", + expErr: "rpc error: code = InvalidArgument desc = you can not turn off trust on first use (TOFU) when non-root ports are allowed", + coreConf: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + hclConf: "tofu = false", + }, + { + name: "Configure fails if tofu and allow root ports is true", + expErr: "rpc error: code = InvalidArgument desc = you can not turn off trust on first use (TOFU) when non-root ports are allowed", + coreConf: &configv1.CoreConfiguration{TrustDomain: "example.org"}, + hclConf: "tofu = false\nallow_non_root_ports = true", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + plugin := httpchallenge.New() + resp, err := plugin.Configure(context.Background(), &configv1.ConfigureRequest{ + HclConfiguration: tt.hclConf, + CoreConfiguration: tt.coreConf, + }) + if tt.expErr != "" { + require.Contains(t, err.Error(), tt.expErr) + require.Nil(t, resp) + return + } + require.NoError(t, err) + require.NotNil(t, resp) + }) + } +} + +func TestAttestFailures(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/spiffe/nodeattestor/http_challenge/default/challenge" { + t.Errorf("Expected to request '/.well-known/spiffe/nodeattestor/http_challenge/default/challenge', got: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`123456789abcdefghijklmnopqrstuvwxyz`)) + })) + defer server.Close() + + client := newClientWithLocalIntercept(server.URL) + + challengeFnNil := func(ctx context.Context, challenge []byte) ([]byte, error) { + return nil, nil + } + + tests := []struct { + name string + hclConf string + expErr string + payload []byte + challengeFn func(ctx context.Context, challenge []byte) ([]byte, error) + tofu bool + }{ + { + name: "Attest fails if payload doesnt exist", + expErr: "rpc error: code = InvalidArgument desc = payload cannot be empty", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: nil, + }, + { + name: "Attest fails if payload cannot be unmarshalled", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): failed to unmarshal data: invalid character 'o' in literal null (expecting 'u')", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: []byte("not a payload"), + }, + { + name: "Attest fails if hostname is blank", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): challenge verification failed: hostname must be set", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails if agentname is blank", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): agent name is not valid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "", + Port: 80, + }), + }, + { + name: "Attest fails if hostname is localhost", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): you can not use localhost as a hostname", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "localhost", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails if port is 0", + expErr: "port is invalid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 0, + }), + }, + { + name: "Attest fails if port is negative", + expErr: "port is invalid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: -1, + }), + }, + { + name: "Attest fails if hostname has a slash", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): challenge verification failed: hostname can not contain a slash", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "fo/o", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails if hostname has a colon", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): challenge verification failed: hostname can not contain a colon", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo:1", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails if agentname has a dot", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): agent name is not valid", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "def.ault", + Port: 80, + }), + }, + { + name: "Attest fails if required port is different from given one", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): port 81 is not allowed to be used by this server", + hclConf: "required_port = 80", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 81, + }), + }, + { + name: "Attest fails if non root ports are disallowed and port is >= 1024", + expErr: "rpc error: code = InvalidArgument desc = nodeattestor(http_challenge): port 1024 is not allowed to be >= 1024", + hclConf: "allow_non_root_ports = false", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 1024, + }), + }, + { + name: "Attest fails if hostname is not valid by dns pattern", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): the requested hostname is not allowed to connect", + hclConf: `allowed_dns_patterns = ["p[0-9][.]example[.]com"]`, + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails if nonce does not match", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): challenge verification failed: expected nonce \"bad123456789abcdefghijklmnopqrstuvwxyz\" but got \"123456789abcdefghijklmnopqrstuvwxyz\"", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 80, + }), + }, + { + name: "Attest fails when reattesting with tofu", + expErr: "rpc error: code = PermissionDenied desc = nodeattestor(http_challenge): attestation data has already been used to attest an agent", + hclConf: "", + tofu: false, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 80, + }), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var testNonce string + if tt.tofu { + testNonce = "bad123456789abcdefghijklmnopqrstuvwxyz" + } else { + testNonce = "123456789abcdefghijklmnopqrstuvwxyz" + } + plugin := loadPlugin(t, tt.hclConf, !tt.tofu, client, testNonce) + result, err := plugin.Attest(context.Background(), tt.payload, tt.challengeFn) + require.Contains(t, err.Error(), tt.expErr) + require.Nil(t, result) + }) + } +} + +func TestAttestSucceeds(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/spiffe/nodeattestor/http_challenge/default/challenge" { + t.Errorf("Expected to request '/.well-known/spiffe/nodeattestor/http_challenge/default/challenge', got: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`123456789abcdefghijklmnopqrstuvwxyz`)) + })) + defer server.Close() + + client := newClientWithLocalIntercept(server.URL) + + challengeFnNil := func(ctx context.Context, challenge []byte) ([]byte, error) { + return nil, nil + } + + tests := []struct { + name string + hclConf string + payload []byte + challengeFn func(ctx context.Context, challenge []byte) ([]byte, error) + expectedAgentID string + expectedSelectors []*common.Selector + tofu bool + }{ + { + name: "Attest succeeds for defaults", + hclConf: "", + tofu: true, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 80, + }), + expectedAgentID: "spiffe://example.org/spire/agent/http_challenge/foo", + expectedSelectors: []*common.Selector{ + { + Type: "http_challenge", + Value: "hostname:foo", + }, + }, + }, + { + name: "Attest succeeds for reattest without tofu", + hclConf: "tofu = false\nallow_non_root_ports = false", + tofu: false, + challengeFn: challengeFnNil, + payload: marshalPayload(t, &common_httpchallenge.AttestationData{ + HostName: "foo", + AgentName: "default", + Port: 80, + }), + expectedAgentID: "spiffe://example.org/spire/agent/http_challenge/foo", + expectedSelectors: []*common.Selector{ + { + Type: "http_challenge", + Value: "hostname:foo", + }, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + testNonce := "123456789abcdefghijklmnopqrstuvwxyz" + plugin := loadPlugin(t, tt.hclConf, !tt.tofu, client, testNonce) + result, err := plugin.Attest(context.Background(), tt.payload, tt.challengeFn) + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, tt.expectedAgentID, result.AgentID) + requireSelectorsMatch(t, tt.expectedSelectors, result.Selectors) + }) + } +} + +func loadPlugin(t *testing.T, config string, testTOFU bool, client *http.Client, testNonce string) nodeattestor.NodeAttestor { + v1 := new(nodeattestor.V1) + agentStore := fakeagentstore.New() + var configureErr error + if testTOFU { + agentStore.SetAgentInfo(&agentstorev1.AgentInfo{ + AgentId: "spiffe://example.org/spire/agent/http_challenge/foo", + }) + } + opts := []plugintest.Option{ + plugintest.Configure(config), + plugintest.CaptureConfigureError(&configureErr), + plugintest.HostServices(agentstorev1.AgentStoreServiceServer(agentStore)), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + } + plugintest.Load(t, httpchallenge.BuiltInTesting(client, testNonce), v1, opts...) + return v1 +} + +func marshalPayload(t *testing.T, attReq *common_httpchallenge.AttestationData) []byte { + attReqBytes, err := json.Marshal(attReq) + require.NoError(t, err) + return attReqBytes +} + +func requireSelectorsMatch(t *testing.T, expected []*common.Selector, actual []*common.Selector) { + require.Equal(t, len(expected), len(actual)) + for idx, expSel := range expected { + require.Equal(t, expSel.Type, actual[idx].Type) + require.Equal(t, expSel.Value, actual[idx].Value) + } +} + +func newClientWithLocalIntercept(url string) *http.Client { + u, _ := neturl.Parse(url) + _, port, _ := net.SplitHostPort(u.Host) + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + defaultDialContext := http.DefaultTransport.(*http.Transport).DialContext + if addr == "foo:80" { + addr = fmt.Sprintf("127.0.0.1:%s", port) + } + return defaultDialContext(ctx, network, addr) + }, + }, + } +}