Skip to content

Commit 557fdff

Browse files
TeoSlayerteovl
andauthored
Authenticate confidential managed-node reads (#453)
* fix(managed): authenticate confidential node reads * deps: require fail-closed trusted-agent refresh --------- Co-authored-by: Teodor Calin <teodor@vulturelabs.io>
1 parent 3f077a1 commit 557fdff

10 files changed

Lines changed: 381 additions & 13 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ require (
1515
github.com/pilot-protocol/rendezvous v0.2.8
1616
github.com/pilot-protocol/runtime v0.3.2
1717
github.com/pilot-protocol/skillinject v0.2.4-0.20260717204101-902f745138da
18-
github.com/pilot-protocol/trustedagents v0.2.5
18+
github.com/pilot-protocol/trustedagents v0.2.6
1919
github.com/pilot-protocol/updater v0.2.4
2020
github.com/pilot-protocol/webhook v0.2.0
2121
golang.org/x/sys v0.47.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ github.com/pilot-protocol/runtime v0.3.2 h1:21lgUfYNvpls0Vd3V2v9lfs13G7mT8pcT3D2
255255
github.com/pilot-protocol/runtime v0.3.2/go.mod h1:CXEmjKF/HozhIxn9QZxO13Lxdnkok3XKEkYS/jFjQKs=
256256
github.com/pilot-protocol/skillinject v0.2.4-0.20260717204101-902f745138da h1:supBO6UzykRn5Ta5pCLXjTvAPqTKOgfr2ZpZQugBNuI=
257257
github.com/pilot-protocol/skillinject v0.2.4-0.20260717204101-902f745138da/go.mod h1:+jEW+uCFkA6TnmZc41Ev5oczXCbOHD0NqVWG8RzjwXQ=
258-
github.com/pilot-protocol/trustedagents v0.2.5 h1:zdeezxalidXanOkEcdsageyAp6jVbfhAnPumREbEZt0=
259-
github.com/pilot-protocol/trustedagents v0.2.5/go.mod h1:6P0pBKmjKlfiSsCbl7EAIr96LW/9RnoLzdl+rEqmN5E=
258+
github.com/pilot-protocol/trustedagents v0.2.6 h1:dFKr6V+lJr947v3iUyR7EflUTusGfKkItAmsqJfWc8g=
259+
github.com/pilot-protocol/trustedagents v0.2.6/go.mod h1:JAk89O4bg9NeXLnMJh4gUsCQ1R5BIHTQHjrGwNXaqZM=
260260
github.com/pilot-protocol/updater v0.2.4 h1:TOnmjv7KzzotSK1n4Nf+KB0VXagql+zqUVAURp3qBDo=
261261
github.com/pilot-protocol/updater v0.2.4/go.mod h1:yA3Qso83bSLNDe5Yg7L/mj/c2dTBzb2VNQc5i8iJNnA=
262262
github.com/pilot-protocol/webhook v0.2.0 h1:3UFU9X2yBb0iKlPbzVcism+Z6yCrBBaOgdo9+vd4Wf4=

internal/enterprisecontrol/control.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,13 @@ func Load(path string) (*Runtime, error) {
558558
if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) {
559559
return nil, fmt.Errorf("enterprise control: outbound decision seed does not match active agent intent key")
560560
}
561+
requestSigner, signerErr := authorityhttp.NewAgentRequestSigner(config.OutboundDecisions.AgentID, config.OutboundDecisions.IntentKeyID, privateKey)
562+
if signerErr != nil {
563+
return nil, fmt.Errorf("enterprise control: configure workflow request signer: %w", signerErr)
564+
}
565+
if signerErr = client.ConfigureWorkflowAgentRequestSigning(config.TenantID, config.OutboundDecisions.AgentID, requestSigner.Sign); signerErr != nil {
566+
return nil, fmt.Errorf("enterprise control: configure authenticated workflow reads: %w", signerErr)
567+
}
561568
runtime.outboundClient = client
562569
runtime.outboundAgentID = config.OutboundDecisions.AgentID
563570
runtime.outboundKeyID = config.OutboundDecisions.IntentKeyID
@@ -661,6 +668,9 @@ func Load(path string) (*Runtime, error) {
661668
if keyErr != nil || !bytes.Equal(publicKey, privateKey.Public().(ed25519.PublicKey)) {
662669
return nil, fmt.Errorf("enterprise control: rollout acknowledgement seed does not match active agent intent key")
663670
}
671+
if signErr := client.ConfigureAgentRequestSigning(config.Rollout.AgentID, config.Rollout.AcknowledgementKeyID, privateKey); signErr != nil {
672+
return nil, fmt.Errorf("enterprise control: configure authenticated node-plane reads: %w", signErr)
673+
}
664674
runtime.rolloutClient = client
665675
runtime.rolloutAgentID = config.Rollout.AgentID
666676
runtime.rolloutKeyID = config.Rollout.AcknowledgementKeyID
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package authorityhttp
4+
5+
import (
6+
"crypto/ed25519"
7+
"crypto/rand"
8+
"crypto/sha256"
9+
"encoding/base64"
10+
"encoding/hex"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strconv"
16+
"strings"
17+
"time"
18+
)
19+
20+
const (
21+
agentRequestDomain = "pilot-agent-request-v1"
22+
agentRequestAgentIDHeader = "X-Pilot-Agent-Id"
23+
agentRequestKeyIDHeader = "X-Pilot-Agent-Key-Id"
24+
agentRequestTimestampHeader = "X-Pilot-Agent-Timestamp"
25+
agentRequestNonceHeader = "X-Pilot-Agent-Nonce"
26+
agentRequestSignatureHeader = "X-Pilot-Agent-Signature"
27+
)
28+
29+
var emptyAgentRequestBodyHash = sha256.Sum256(nil)
30+
31+
// AgentRequestSigner proves which enrolled node is fetching account-specific
32+
// policy or control-plane state. It is intentionally limited to bodyless GETs:
33+
// node reports already carry their own signed, validated artifacts.
34+
type AgentRequestSigner struct {
35+
agentID string
36+
keyID string
37+
privateKey ed25519.PrivateKey
38+
now func() time.Time
39+
random io.Reader
40+
}
41+
42+
// NewAgentRequestSigner binds one active agent intent key to node-plane reads.
43+
func NewAgentRequestSigner(agentID, keyID string, privateKey ed25519.PrivateKey) (*AgentRequestSigner, error) {
44+
if !clientIdentifier(agentID) || !clientIdentifier(keyID) || len(privateKey) != ed25519.PrivateKeySize {
45+
return nil, fmt.Errorf("authorityhttp: invalid agent request signing credentials")
46+
}
47+
return &AgentRequestSigner{
48+
agentID: agentID, keyID: keyID,
49+
privateKey: append(ed25519.PrivateKey(nil), privateKey...),
50+
now: time.Now,
51+
random: rand.Reader,
52+
}, nil
53+
}
54+
55+
// ConfigureAgentRequestSigning enables authenticated policy, mandate, and
56+
// fleet reads. Configure the client before concurrent use.
57+
func (client *Client) ConfigureAgentRequestSigning(agentID, keyID string, privateKey ed25519.PrivateKey) error {
58+
if client == nil {
59+
return fmt.Errorf("authorityhttp: client is not initialized")
60+
}
61+
signer, err := NewAgentRequestSigner(agentID, keyID, privateKey)
62+
if err != nil {
63+
return err
64+
}
65+
client.agentRequestSigner = signer
66+
return nil
67+
}
68+
69+
// SignAgentRequest signs a bodyless GET for integrations that do not use
70+
// Client. tenantID and agentID must exactly match the request query.
71+
func SignAgentRequest(request *http.Request, tenantID, agentID, keyID string, privateKey ed25519.PrivateKey) error {
72+
signer, err := NewAgentRequestSigner(agentID, keyID, privateKey)
73+
if err != nil {
74+
return err
75+
}
76+
return signer.Sign(request, tenantID, agentID)
77+
}
78+
79+
func (client *Client) signAgentRequest(request *http.Request, tenantID, agentID string) error {
80+
// Optional signing preserves compatibility with older/self-hosted
81+
// authorities. Hosted node-plane endpoints require the signature.
82+
if client == nil || client.agentRequestSigner == nil {
83+
return nil
84+
}
85+
return client.agentRequestSigner.Sign(request, tenantID, agentID)
86+
}
87+
88+
// Sign binds the method, normalized path and query, tenant, node identity,
89+
// delegated key, short-lived timestamp, nonce, and empty-body digest.
90+
func (signer *AgentRequestSigner) Sign(request *http.Request, tenantID, agentID string) error {
91+
if signer == nil || request == nil || request.URL == nil || signer.now == nil || signer.random == nil || len(signer.privateKey) != ed25519.PrivateKeySize {
92+
return fmt.Errorf("authorityhttp: agent request signer is not initialized")
93+
}
94+
if request.Method != http.MethodGet || (request.Body != nil && request.Body != http.NoBody) {
95+
return fmt.Errorf("authorityhttp: agent request signing requires a bodyless GET")
96+
}
97+
if !clientIdentifier(tenantID) || agentID != signer.agentID || !clientIdentifier(agentID) {
98+
return fmt.Errorf("authorityhttp: agent request identity mismatch")
99+
}
100+
query, err := strictAgentRequestQuery(request.URL, tenantID, agentID)
101+
if err != nil {
102+
return err
103+
}
104+
nonceBytes := make([]byte, 16)
105+
if _, err := io.ReadFull(signer.random, nonceBytes); err != nil {
106+
return fmt.Errorf("authorityhttp: generate agent request nonce: %w", err)
107+
}
108+
timestamp := strconv.FormatInt(signer.now().UTC().Unix(), 10)
109+
nonce := hex.EncodeToString(nonceBytes)
110+
canonical, err := canonicalAgentRequest(request.Method, request.URL, query, tenantID, agentID, signer.keyID, timestamp, nonce)
111+
if err != nil {
112+
return err
113+
}
114+
signature := ed25519.Sign(signer.privateKey, canonical)
115+
request.Header.Set(agentRequestAgentIDHeader, agentID)
116+
request.Header.Set(agentRequestKeyIDHeader, signer.keyID)
117+
request.Header.Set(agentRequestTimestampHeader, timestamp)
118+
request.Header.Set(agentRequestNonceHeader, nonce)
119+
request.Header.Set(agentRequestSignatureHeader, base64.RawURLEncoding.EncodeToString(signature))
120+
return nil
121+
}
122+
123+
func strictAgentRequestQuery(target *url.URL, tenantID, agentID string) (string, error) {
124+
if target == nil {
125+
return "", fmt.Errorf("authorityhttp: agent request URL is required")
126+
}
127+
values, err := url.ParseQuery(target.RawQuery)
128+
if err != nil || len(values["tenant_id"]) != 1 || len(values["agent_id"]) != 1 || values.Get("tenant_id") != tenantID || values.Get("agent_id") != agentID {
129+
return "", fmt.Errorf("authorityhttp: agent request query identity mismatch")
130+
}
131+
return values.Encode(), nil
132+
}
133+
134+
func canonicalAgentRequest(method string, target *url.URL, query, tenantID, agentID, keyID, timestamp, nonce string) ([]byte, error) {
135+
path := ""
136+
if target != nil {
137+
path = target.EscapedPath()
138+
}
139+
if method != http.MethodGet || path == "" || !strings.HasPrefix(path, "/") || !clientIdentifier(tenantID) || !clientIdentifier(agentID) || !clientIdentifier(keyID) || timestamp == "" || len(nonce) != 32 {
140+
return nil, fmt.Errorf("authorityhttp: invalid canonical agent request")
141+
}
142+
fields := []string{
143+
agentRequestDomain,
144+
method,
145+
path,
146+
query,
147+
hex.EncodeToString(emptyAgentRequestBodyHash[:]),
148+
tenantID,
149+
agentID,
150+
keyID,
151+
timestamp,
152+
nonce,
153+
}
154+
return []byte(strings.Join(fields, "\n")), nil
155+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package authorityhttp
4+
5+
import (
6+
"crypto/ed25519"
7+
"crypto/rand"
8+
"encoding/base64"
9+
"net/http"
10+
"net/http/httptest"
11+
"testing"
12+
)
13+
14+
func TestClientSignsEveryConfidentialAgentRead(t *testing.T) {
15+
t.Parallel()
16+
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
seen := make(map[string]int)
21+
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
22+
agentID := request.Header.Get(agentRequestAgentIDHeader)
23+
keyID := request.Header.Get(agentRequestKeyIDHeader)
24+
timestamp := request.Header.Get(agentRequestTimestampHeader)
25+
nonce := request.Header.Get(agentRequestNonceHeader)
26+
query, queryErr := strictAgentRequestQuery(request.URL, "tenant-a", "agent-a")
27+
canonical, canonicalErr := canonicalAgentRequest(request.Method, request.URL, query, "tenant-a", agentID, keyID, timestamp, nonce)
28+
signature, signatureErr := base64.RawURLEncoding.DecodeString(request.Header.Get(agentRequestSignatureHeader))
29+
if queryErr != nil || canonicalErr != nil || signatureErr != nil || agentID != "agent-a" || keyID != "agent-key-a" || !ed25519.Verify(publicKey, canonical, signature) {
30+
t.Errorf("invalid authenticated request for %s", request.URL.String())
31+
writer.WriteHeader(http.StatusUnauthorized)
32+
return
33+
}
34+
seen[request.URL.Path]++
35+
switch request.URL.Path {
36+
case "/v1/fleet/commands":
37+
writer.Header().Set("Content-Type", "application/json")
38+
_, _ = writer.Write([]byte(`{"commands":[]}`))
39+
case "/v1/fleet/state/mutations":
40+
writer.Header().Set("Content-Type", "application/json")
41+
_, _ = writer.Write([]byte(`{"mutations":[]}`))
42+
case "/v1/trust-current":
43+
writer.WriteHeader(http.StatusNotFound)
44+
default:
45+
writer.WriteHeader(http.StatusNoContent)
46+
}
47+
}))
48+
defer server.Close()
49+
50+
client, err := New(server.URL, server.Client())
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
if err := client.ConfigureAgentRequestSigning("agent-a", "agent-key-a", privateKey); err != nil {
55+
t.Fatal(err)
56+
}
57+
ctx := t.Context()
58+
if _, _, err := client.CurrentTrust(ctx, "tenant-a"); err != nil {
59+
t.Fatal(err)
60+
}
61+
if _, _, err := client.Candidate(ctx, "tenant-a", "agent-a"); err != nil {
62+
t.Fatal(err)
63+
}
64+
if _, _, err := client.CurrentPolicy(ctx, "tenant-a", "agent-a"); err != nil {
65+
t.Fatal(err)
66+
}
67+
if _, _, err := client.CurrentMandateBundle(ctx, "tenant-a", "agent-a"); err != nil {
68+
t.Fatal(err)
69+
}
70+
if _, err := client.FleetCommands(ctx, "tenant-a", "agent-a"); err != nil {
71+
t.Fatal(err)
72+
}
73+
if _, _, err := client.FleetStateSnapshot(ctx, "tenant-a", "agent-a"); err != nil {
74+
t.Fatal(err)
75+
}
76+
if _, err := client.FleetStateMutations(ctx, "tenant-a", "agent-a"); err != nil {
77+
t.Fatal(err)
78+
}
79+
if _, _, err := client.FleetControl(ctx, "tenant-a", "agent-a"); err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
for _, path := range []string{
84+
"/v1/trust-current", "/v1/policy-candidate", "/v1/policy-current", "/v1/mandates-current",
85+
"/v1/fleet/commands", "/v1/fleet/state/current", "/v1/fleet/state/mutations", "/v1/fleet/control",
86+
} {
87+
if seen[path] != 1 {
88+
t.Errorf("%s signed requests = %d", path, seen[path])
89+
}
90+
}
91+
}

internal/managedsdk/authorityhttp/client.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import (
2222
)
2323

2424
type Client struct {
25-
endpoint *url.URL
26-
httpClient *http.Client
25+
endpoint *url.URL
26+
httpClient *http.Client
27+
agentRequestSigner *AgentRequestSigner
2728
}
2829

2930
// TrustPublicationResult is the durable identity returned after the authority
@@ -116,6 +117,9 @@ func (client *Client) Candidate(ctx context.Context, tenantID, agentID string) (
116117
return PublicationEnvelope{}, false, err
117118
}
118119
request.Header.Set("Accept", "application/json")
120+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
121+
return PublicationEnvelope{}, false, err
122+
}
119123
response, err := client.httpClient.Do(request)
120124
if err != nil {
121125
return PublicationEnvelope{}, false, fmt.Errorf("authorityhttp: candidate: %w", err)
@@ -150,11 +154,20 @@ func (client *Client) CurrentTrust(ctx context.Context, tenantID string) (author
150154
if !clientIdentifier(tenantID) {
151155
return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: tenant identifier is required")
152156
}
153-
request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/trust-current", url.Values{"tenant_id": {tenantID}}), nil)
157+
query := url.Values{"tenant_id": {tenantID}}
158+
if client.agentRequestSigner != nil {
159+
query.Set("agent_id", client.agentRequestSigner.agentID)
160+
}
161+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.route("/v1/trust-current", query), nil)
154162
if err != nil {
155163
return authority.TrustBundle{}, false, err
156164
}
157165
request.Header.Set("Accept", "application/json")
166+
if client.agentRequestSigner != nil {
167+
if err := client.signAgentRequest(request, tenantID, client.agentRequestSigner.agentID); err != nil {
168+
return authority.TrustBundle{}, false, err
169+
}
170+
}
158171
response, err := client.httpClient.Do(request)
159172
if err != nil {
160173
return authority.TrustBundle{}, false, fmt.Errorf("authorityhttp: current trust: %w", err)
@@ -191,6 +204,9 @@ func (client *Client) CurrentPolicy(ctx context.Context, tenantID, agentID strin
191204
return ActivePolicyEnvelope{}, false, err
192205
}
193206
request.Header.Set("Accept", "application/json")
207+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
208+
return ActivePolicyEnvelope{}, false, err
209+
}
194210
response, err := client.httpClient.Do(request)
195211
if err != nil {
196212
return ActivePolicyEnvelope{}, false, fmt.Errorf("authorityhttp: current policy: %w", err)
@@ -233,6 +249,9 @@ func (client *Client) CurrentMandateBundle(ctx context.Context, tenantID, agentI
233249
return decision.MandateBundle{}, false, err
234250
}
235251
request.Header.Set("Accept", "application/json")
252+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
253+
return decision.MandateBundle{}, false, err
254+
}
236255
response, err := client.httpClient.Do(request)
237256
if err != nil {
238257
return decision.MandateBundle{}, false, fmt.Errorf("authorityhttp: current mandates: %w", err)

internal/managedsdk/authorityhttp/fleet_client.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ func (client *Client) FleetCommands(ctx context.Context, tenantID, agentID strin
2828
return nil, err
2929
}
3030
request.Header.Set("Accept", "application/json")
31+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
32+
return nil, err
33+
}
3134
response, err := client.httpClient.Do(request)
3235
if err != nil {
3336
return nil, fmt.Errorf("authorityhttp: fleet commands: %w", err)
@@ -94,6 +97,9 @@ func (client *Client) FleetStateSnapshot(ctx context.Context, tenantID, agentID
9497
return authority.FleetStateSnapshot{}, false, err
9598
}
9699
request.Header.Set("Accept", "application/json")
100+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
101+
return authority.FleetStateSnapshot{}, false, err
102+
}
97103
response, err := client.httpClient.Do(request)
98104
if err != nil {
99105
return authority.FleetStateSnapshot{}, false, fmt.Errorf("authorityhttp: fleet state snapshot: %w", err)
@@ -124,6 +130,9 @@ func (client *Client) FleetStateMutations(ctx context.Context, tenantID, agentID
124130
return nil, err
125131
}
126132
request.Header.Set("Accept", "application/json")
133+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
134+
return nil, err
135+
}
127136
response, err := client.httpClient.Do(request)
128137
if err != nil {
129138
return nil, fmt.Errorf("authorityhttp: fleet state mutations: %w", err)
@@ -165,6 +174,9 @@ func (client *Client) FleetControl(ctx context.Context, tenantID, agentID string
165174
return authority.FleetNodeControl{}, false, err
166175
}
167176
request.Header.Set("Accept", "application/json")
177+
if err := client.signAgentRequest(request, tenantID, agentID); err != nil {
178+
return authority.FleetNodeControl{}, false, err
179+
}
168180
response, err := client.httpClient.Do(request)
169181
if err != nil {
170182
return authority.FleetNodeControl{}, false, fmt.Errorf("authorityhttp: fleet control: %w", err)

0 commit comments

Comments
 (0)