Skip to content

Commit

Permalink
Add http node attestor (spiffe#4909)
Browse files Browse the repository at this point in the history
Signed-off-by: Kevin Fox <Kevin.Fox@pnnl.gov>
  • Loading branch information
kfox1111 authored and Thiago Valverde de Souza committed Aug 18, 2024
1 parent 8a8168b commit 9cc02d1
Show file tree
Hide file tree
Showing 10 changed files with 1,489 additions and 0 deletions.
49 changes: 49 additions & 0 deletions doc/plugin_agent_nodeattestor_http_challenge.md
Original file line number Diff line number Diff line change
@@ -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://<trust_domain>/spire/agent/http_challenge/<hostname>
```

| 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
}
}
```
55 changes: 55 additions & 0 deletions doc/plugin_server_nodeattestor_http_challenge.md
Original file line number Diff line number Diff line change
@@ -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://<trust_domain>/spire/agent/http_challenge/<hostname>
```

| 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).
2 changes: 2 additions & 0 deletions pkg/agent/catalog/nodeattestor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -37,6 +38,7 @@ func (repo *nodeAttestorRepository) BuiltIns() []catalog.BuiltIn {
awsiid.BuiltIn(),
azuremsi.BuiltIn(),
gcpiit.BuiltIn(),
httpchallenge.BuiltIn(),
jointoken.BuiltIn(),
k8spsat.BuiltIn(),
k8ssat.BuiltIn(),
Expand Down
234 changes: 234 additions & 0 deletions pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 9cc02d1

Please sign in to comment.