-
Notifications
You must be signed in to change notification settings - Fork 464
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
base: main
Are you sure you want to change the base?
Add http node attestor #4909
Changes from 40 commits
b322773
f08d9d2
009166f
7caac6f
3e68d07
baaa4f6
029f117
82af10b
86fbf7c
727c5f9
9f42248
76d761f
a693947
28dc797
6416f01
9e8897e
fdfc837
f8bc768
b5a439e
0656513
6a94dfd
4e8133e
b0df1d6
0c1d254
effb6b3
d6f3b6a
6cdb65b
98d7363
623ed12
968e90e
0e7dd47
b687049
1caaaec
069bb2b
69edbe1
41f1e74
92e72d5
151d3c3
b6c4a41
f00d02f
81b205a
501a957
a404aa3
5ab497d
b5caee3
1af2187
a2c0385
be2b414
2bec5df
4c95d25
2ac078f
32f8f04
f47af5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
|
||
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 | ||
} | ||
} | ||
``` |
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). |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. code copied verbatim from: 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
There was a problem hiding this comment.
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....
There was a problem hiding this comment.
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?