Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add http node attestor #4909

Open
wants to merge 53 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b322773
Add http challenge node attestor
kfox1111 May 10, 2024
f08d9d2
Fix various issues so it works again after refactor
kfox1111 May 11, 2024
009166f
Fix some issues
kfox1111 May 11, 2024
7caac6f
Fix some issues
kfox1111 May 11, 2024
3e68d07
Fix some issues
kfox1111 May 11, 2024
baaa4f6
Fix some issues
kfox1111 May 11, 2024
029f117
Fix some issues
kfox1111 May 11, 2024
82af10b
Fix some issues
kfox1111 May 11, 2024
86fbf7c
Fix some issues
kfox1111 May 11, 2024
727c5f9
Implement tofu. Incorperate feedback
kfox1111 May 15, 2024
9f42248
Fix some lint bits
kfox1111 May 16, 2024
76d761f
More lint
kfox1111 May 16, 2024
a693947
More lint
kfox1111 May 16, 2024
28dc797
Merge branch 'main' into http
kfox1111 May 16, 2024
6416f01
Merge branch 'main' into http
kfox1111 May 30, 2024
9e8897e
Apply suggestions from code review
kfox1111 Jun 5, 2024
fdfc837
Incorperate feedback
kfox1111 Jun 5, 2024
f8bc768
Merge branch 'http' of https://github.com/kfox1111/spire into http
kfox1111 Jun 5, 2024
b5a439e
Incorperate feedback
kfox1111 Jun 5, 2024
0656513
Incorperate feedback
kfox1111 Jun 5, 2024
6a94dfd
Fix example
kfox1111 Jun 5, 2024
4e8133e
Incorperate feedback
kfox1111 Jun 10, 2024
b0df1d6
Incorperate feedback
kfox1111 Jun 10, 2024
0c1d254
Incorperate feedback
kfox1111 Jun 11, 2024
effb6b3
Fix lint issues
kfox1111 Jun 12, 2024
d6f3b6a
Fix lint issues
kfox1111 Jun 12, 2024
6cdb65b
Fix 404 issue
kfox1111 Jun 17, 2024
98d7363
Fix lint issue
kfox1111 Jun 17, 2024
623ed12
Fix issue with protocol
kfox1111 Jun 18, 2024
968e90e
Merge branch 'main' into http
kfox1111 Jun 18, 2024
0e7dd47
Remove tempate
kfox1111 Jun 22, 2024
b687049
Apply suggestions from code review
kfox1111 Jun 22, 2024
1caaaec
Merge branch 'main' into http
kfox1111 Jun 22, 2024
069bb2b
Fix typo
kfox1111 Jun 22, 2024
69edbe1
Add some unit tests
kfox1111 Jun 24, 2024
41f1e74
Add a bunch of httpchallenge server plugin tests
kfox1111 Jul 16, 2024
92e72d5
Add some more tests
kfox1111 Jul 16, 2024
151d3c3
Test server works
kfox1111 Jul 17, 2024
b6c4a41
Add tofu checks
kfox1111 Jul 17, 2024
f00d02f
Add httpchallenge agent tests
kfox1111 Jul 19, 2024
81b205a
Incorperate feedback
kfox1111 Jul 19, 2024
501a957
gofmt files
kfox1111 Jul 19, 2024
a404aa3
Apply suggestions from code review
kfox1111 Jul 19, 2024
5ab497d
Fix lint issues
kfox1111 Jul 19, 2024
b5caee3
Fix lint issues
kfox1111 Jul 19, 2024
1af2187
Fix test
kfox1111 Jul 19, 2024
a2c0385
Incorperate feedback. Add localhost block.
kfox1111 Jul 20, 2024
be2b414
Fix test
kfox1111 Jul 20, 2024
2bec5df
Add test for localhost test. Incorperate feedback
kfox1111 Jul 20, 2024
4c95d25
Incorperate feedback
kfox1111 Jul 20, 2024
2ac078f
Fix lint issue
kfox1111 Jul 20, 2024
32f8f04
Merge branch 'main' into http
kfox1111 Jul 20, 2024
f47af5c
Merge branch 'main' into http
kfox1111 Jul 23, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have two sample configurations in this file....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed an issue with it. but the intention was for the second example to be specifically for the Proxies section... I can see how that could be confusing though. Maybe include "proxy" in the example string for it?


```hcl
NodeAttestor "http_challenge" {
plugin_data {
port = 80
}
}
```

## Proxies

kfox1111 marked this conversation as resolved.
Show resolved Hide resolved
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
222 changes: 222 additions & 0 deletions pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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 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
}

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(config); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the configData returned by this function should be the object passed into setConfig and returned from getConfig, instead of storing the Config struct and then reloading it on each call to p.loadConfigData().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code copied verbatim from:
https://github.com/spiffe/spire/blob/main/pkg/agent/plugin/nodeattestor/x509pop/x509pop.go#L102-L124

With the cert config bits removed.

Should we still make this change or keep it the same for consistency? I'm good either way. Just curious.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather change it. I think it makes it all easier to reason about.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to figure out how to do this... Looking at the code, the setConfig/getConfig is about the hcl config. The loadConfigData/struct are objects derived from that config. So, I think both structs are actually needed? How might we restructure it?

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(config)
}

func loadConfigData(config *Config) (*configData, error) {
if config.HostName == "" {
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
Loading