forked from spiffe/spire
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add http node attestor (spiffe#4909)
Signed-off-by: Kevin Fox <Kevin.Fox@pnnl.gov>
- Loading branch information
Showing
10 changed files
with
1,489 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
234 changes: 234 additions & 0 deletions
234
pkg/agent/plugin/nodeattestor/httpchallenge/httpchallenge.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.