Skip to content

Commit a235d91

Browse files
committed
Enforce KMS identity in admission policy
1 parent 66e8565 commit a235d91

6 files changed

Lines changed: 274 additions & 11 deletions

File tree

consul-postgres-ha/ATTESTATION.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ Enable it in `cluster-example/terraform.tfvars`:
99
enable_attestation_admission = true
1010
consul_management_token = "<consul-acl-management-token>"
1111
dstack_verifier_image = "dstacktee/dstack-verifier:0.5.9"
12+
kms = "phala"
13+
# admission_expected_kms_public_key defaults to the public key for the
14+
# default phala KMS backend. Override it when changing kms.
1215
```
1316

1417
Use a versioned verifier tag and pin it by digest for production.
@@ -32,8 +35,9 @@ current dstack guest agents: `quote`, `event_log`, and `vm_config`.
3235
sends `{identity, binding, nonce, quote, event_log, vm_config}` to
3336
the broker.
3437
6. The broker verifies the quote through `dstack-verifier`, checks
35-
report-data binding and nonce freshness, then matches identity,
36-
peer id, and compose hash against the Terraform policy.
38+
report-data binding, nonce freshness, and KMS key-provider
39+
identity, then matches identity, peer id, and compose hash against
40+
the Terraform policy.
3741
7. On success the broker mints a scoped Consul ACL token. Worker
3842
agents receive a node-identity token; service workloads receive
3943
service-identity tokens plus any declared Consul permissions.
@@ -103,11 +107,25 @@ as:
103107
- `event_log_verified == true`
104108
- `report_data` present and equal to the broker-computed digest
105109
- `app_info.compose_hash` present and policy-allowed
110+
- `app_info.key_provider_info` present and equal to the policy-pinned
111+
KMS provider id
106112

107113
KMS identity should be policy-controlled because it defines the key
108-
lifecycle. A production policy should pin the long-term KMS public
109-
key or an equivalent well-known KMS identity that is hard-coded in
110-
Terraform and checked against verified dstack evidence.
114+
lifecycle. This example pins the long-term KMS public key in
115+
Terraform as `admission_expected_kms_public_key` and checks it
116+
against the verifier-returned `app_info.key_provider_info`. The
117+
verifier field is a hex-encoded JSON payload:
118+
119+
```json
120+
{
121+
"name": "kms",
122+
"id": "<public-key-hex>"
123+
}
124+
```
125+
126+
The pinned key must match the `kms` backend used in the Phala launch
127+
configuration. The Terraform default matches the example's default
128+
`kms = "phala"` backend; override it when changing KMS backend.
111129

112130
For production profiles, OS image identity should also be checked.
113131
The expected OS image hash should be both a Terraform launch setting

consul-postgres-ha/PROGRESS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ deployment model, trust model, or operational status.
3636
- Terraform slot API: integrate the newly implemented Phala Cloud
3737
slot API into the Terraform deployment flow. Tracking note:
3838
`design/terraform-slot-api.md`.
39-
- KMS policy: pin the long-term KMS public key or equivalent KMS
40-
identity in Terraform and require the verifier-proven KMS evidence
41-
to match it.
39+
- KMS policy: Terraform now carries the selected KMS backend and the
40+
expected long-term KMS public key; admission requires the verifier
41+
returned key-provider evidence to match it.
4242
- Production OS policy: when a production profile is enabled, include
4343
the expected OS image hash in both Phala launch config and admission
4444
policy.

consul-postgres-ha/admission-broker/main.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,17 @@ func main() {
7878
type Policy struct {
7979
Cluster string `json:"cluster"`
8080
PolicyEpoch int `json:"policy_epoch"`
81+
KMS *KMSPolicy `json:"kms,omitempty"`
8182
Workloads []Workload `json:"workloads"`
8283

8384
byIdentity map[string][]Workload
8485
}
8586

87+
type KMSPolicy struct {
88+
Name string `json:"name"`
89+
ID string `json:"id"`
90+
}
91+
8692
type Workload struct {
8793
WorkloadID string `json:"workload_id"`
8894
Identity string `json:"identity"`
@@ -115,6 +121,11 @@ func ParsePolicy(b []byte) (*Policy, error) {
115121
if p.PolicyEpoch <= 0 {
116122
return nil, errors.New("policy_epoch must be positive")
117123
}
124+
if p.KMS != nil {
125+
if err := normalizeKeyProviderInfo(p.KMS); err != nil {
126+
return nil, fmt.Errorf("kms: %w", err)
127+
}
128+
}
118129
if len(p.Workloads) == 0 {
119130
return nil, errors.New("workloads must not be empty")
120131
}
@@ -185,6 +196,21 @@ func (p *Policy) Match(identity, composeHash, peerID string) (*Workload, error)
185196
return nil, fmt.Errorf("identity %q is not allowed for compose_hash %s", identity, normalized)
186197
}
187198

199+
func (p *Policy) CheckKMS(verified *VerifiedQuote) error {
200+
if p.KMS == nil {
201+
return nil
202+
}
203+
if verified.KeyProvider == nil {
204+
return errors.New("verifier response missing app_info.key_provider_info")
205+
}
206+
if verified.KeyProvider.Name != p.KMS.Name || verified.KeyProvider.ID != p.KMS.ID {
207+
return fmt.Errorf("kms key provider mismatch: got %s/%s want %s/%s",
208+
verified.KeyProvider.Name, shortHex(verified.KeyProvider.ID),
209+
p.KMS.Name, shortHex(p.KMS.ID))
210+
}
211+
return nil
212+
}
213+
188214
func normalizeComposeHash(h string) (string, error) {
189215
h = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(h), "0x"))
190216
if len(h) != 64 {
@@ -196,6 +222,33 @@ func normalizeComposeHash(h string) (string, error) {
196222
return h, nil
197223
}
198224

225+
func normalizeKeyProviderInfo(k *KMSPolicy) error {
226+
k.Name = strings.ToLower(strings.TrimSpace(k.Name))
227+
if k.Name == "" {
228+
return errors.New("name is required")
229+
}
230+
id, err := normalizeHexBytes(k.ID)
231+
if err != nil {
232+
return fmt.Errorf("id: %w", err)
233+
}
234+
k.ID = id
235+
return nil
236+
}
237+
238+
func normalizeHexBytes(s string) (string, error) {
239+
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "0x"))
240+
if s == "" {
241+
return "", errors.New("must not be empty")
242+
}
243+
if len(s)%2 != 0 {
244+
return "", fmt.Errorf("hex length must be even, got %d", len(s))
245+
}
246+
if _, err := hex.DecodeString(s); err != nil {
247+
return "", fmt.Errorf("must be hex: %w", err)
248+
}
249+
return s, nil
250+
}
251+
199252
type Server struct {
200253
policy *Policy
201254
nonces *NonceStore
@@ -292,6 +345,10 @@ func (s *Server) handleAttest(w http.ResponseWriter, r *http.Request) {
292345
writeError(w, http.StatusForbidden, "REPORT_DATA_MISMATCH", "attestation report_data does not match binding statement and nonce")
293346
return
294347
}
348+
if err := s.policy.CheckKMS(verified); err != nil {
349+
writeError(w, http.StatusForbidden, "KMS_POLICY_MISMATCH", err.Error())
350+
return
351+
}
295352

296353
workload, err := s.policy.Match(req.Identity, verified.ComposeHash, bindingStatement.PeerID)
297354
if err != nil {
@@ -444,6 +501,7 @@ type VerifiedQuote struct {
444501
ComposeHash string
445502
AppID string
446503
ReportData string
504+
KeyProvider *KMSPolicy
447505
}
448506

449507
type VerifierClient struct {
@@ -485,6 +543,7 @@ func (c *VerifierClient) Verify(ctx context.Context, req VerifyRequest) (*Verifi
485543
AppInfo struct {
486544
AppID string `json:"app_id"`
487545
ComposeHash string `json:"compose_hash"`
546+
KeyProvider string `json:"key_provider_info"`
488547
} `json:"app_info"`
489548
Details struct {
490549
QuoteVerified bool `json:"quote_verified"`
@@ -493,6 +552,7 @@ func (c *VerifierClient) Verify(ctx context.Context, req VerifyRequest) (*Verifi
493552
AppInfo struct {
494553
AppID string `json:"app_id"`
495554
ComposeHash string `json:"compose_hash"`
555+
KeyProvider string `json:"key_provider_info"`
496556
} `json:"app_info"`
497557
} `json:"details"`
498558
}
@@ -505,6 +565,7 @@ func (c *VerifierClient) Verify(ctx context.Context, req VerifyRequest) (*Verifi
505565
reportData := firstNonEmpty(parsed.ReportData, parsed.Details.ReportData)
506566
appID := firstNonEmpty(parsed.AppInfo.AppID, parsed.Details.AppInfo.AppID)
507567
composeHashRaw := firstNonEmpty(parsed.AppInfo.ComposeHash, parsed.Details.AppInfo.ComposeHash)
568+
keyProviderRaw := firstNonEmpty(parsed.AppInfo.KeyProvider, parsed.Details.AppInfo.KeyProvider)
508569

509570
valid := parsed.IsValid && quoteVerified && eventLogVerified
510571
reason := firstNonEmpty(parsed.Reason, parsed.Error)
@@ -524,15 +585,42 @@ func (c *VerifierClient) Verify(ctx context.Context, req VerifyRequest) (*Verifi
524585
return nil, fmt.Errorf("verifier app_info.compose_hash: %w", err)
525586
}
526587
}
588+
var keyProvider *KMSPolicy
589+
if keyProviderRaw != "" {
590+
keyProvider, err = decodeKeyProviderInfo(keyProviderRaw)
591+
if err != nil {
592+
return nil, fmt.Errorf("verifier app_info.key_provider_info: %w", err)
593+
}
594+
}
527595
return &VerifiedQuote{
528596
Valid: valid,
529597
Reason: reason,
530598
ComposeHash: composeHash,
531599
AppID: appID,
532600
ReportData: strings.TrimPrefix(strings.ToLower(strings.TrimSpace(reportData)), "0x"),
601+
KeyProvider: keyProvider,
533602
}, nil
534603
}
535604

605+
func decodeKeyProviderInfo(raw string) (*KMSPolicy, error) {
606+
raw, err := normalizeHexBytes(raw)
607+
if err != nil {
608+
return nil, err
609+
}
610+
b, err := hex.DecodeString(raw)
611+
if err != nil {
612+
return nil, err
613+
}
614+
var k KMSPolicy
615+
if err := json.Unmarshal(b, &k); err != nil {
616+
return nil, fmt.Errorf("decoded payload is not key-provider JSON: %w", err)
617+
}
618+
if err := normalizeKeyProviderInfo(&k); err != nil {
619+
return nil, err
620+
}
621+
return &k, nil
622+
}
623+
536624
type TokenRequest struct {
537625
Description string
538626
ConsulService string
@@ -771,6 +859,13 @@ func shortSHA256(s string, hexChars int) string {
771859
return out[:hexChars]
772860
}
773861

862+
func shortHex(s string) string {
863+
if len(s) <= 16 {
864+
return s
865+
}
866+
return s[:16]
867+
}
868+
774869
func writeJSON(w http.ResponseWriter, status int, v any) {
775870
w.Header().Set("Content-Type", "application/json")
776871
w.WriteHeader(status)

consul-postgres-ha/admission-broker/main_test.go

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
)
1414

1515
const testHash = "1434154969cb663afc5a73393b84cc31a1319ab6c65c9766fadd0c86bb59ef37"
16+
const testKMSKey = "aabbccddeeff00112233445566778899"
1617

1718
func TestPolicyMatchAllowsSharedComposeHashPerIdentity(t *testing.T) {
1819
policy := mustPolicy(t, `{
@@ -111,6 +112,41 @@ func TestPolicyMatchesDeclaredPeerID(t *testing.T) {
111112
}
112113
}
113114

115+
func TestPolicyNormalizesKMSKeyProvider(t *testing.T) {
116+
policy := mustPolicy(t, `{
117+
"cluster": "demo",
118+
"policy_epoch": 1,
119+
"kms": {"name": "KMS", "id": "0x`+testKMSKey+`"},
120+
"workloads": [{
121+
"workload_id": "demo/worker/0/webdemo",
122+
"identity": "spiffe://demo/webdemo",
123+
"consul_service": "webdemo",
124+
"allowed_compose_hashes": ["`+testHash+`"]
125+
}]
126+
}`)
127+
128+
if policy.KMS == nil {
129+
t.Fatal("kms policy was not parsed")
130+
}
131+
if policy.KMS.Name != "kms" {
132+
t.Fatalf("wrong kms name: %s", policy.KMS.Name)
133+
}
134+
if policy.KMS.ID != testKMSKey {
135+
t.Fatalf("wrong kms id: %s", policy.KMS.ID)
136+
}
137+
}
138+
139+
func TestDecodeKeyProviderInfo(t *testing.T) {
140+
raw := hex.EncodeToString([]byte(`{"name":"kms","id":"0x` + testKMSKey + `"}`))
141+
got, err := decodeKeyProviderInfo(raw)
142+
if err != nil {
143+
t.Fatal(err)
144+
}
145+
if got.Name != "kms" || got.ID != testKMSKey {
146+
t.Fatalf("wrong key provider: %+v", got)
147+
}
148+
}
149+
114150
func TestReportDataHexBindsStatementAndNonce(t *testing.T) {
115151
binding := "abcd"
116152
nonce := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
@@ -144,7 +180,11 @@ func TestAttestFlow(t *testing.T) {
144180
"quote_verified": true,
145181
"event_log_verified": true,
146182
"report_data": expectedReportData,
147-
"app_info": map[string]string{"compose_hash": testHash, "app_id": "app_abc"},
183+
"app_info": map[string]string{
184+
"compose_hash": testHash,
185+
"app_id": "app_abc",
186+
"key_provider_info": hex.EncodeToString([]byte(`{"name":"kms","id":"` + testKMSKey + `"}`)),
187+
},
148188
},
149189
})
150190
}))
@@ -153,7 +193,7 @@ func TestAttestFlow(t *testing.T) {
153193
issuer := &recordingIssuer{token: "secret-token"}
154194
now := time.Unix(100, 0).UTC()
155195
s := &Server{
156-
policy: mustPolicy(t, `{"cluster":"demo","policy_epoch":7,"workloads":[{"workload_id":"demo/worker/0/webdemo","identity":"spiffe://demo/webdemo","consul_service":"webdemo","allowed_compose_hashes":["`+testHash+`"]}]}`),
196+
policy: mustPolicy(t, `{"cluster":"demo","policy_epoch":7,"kms":{"name":"kms","id":"`+testKMSKey+`"},"workloads":[{"workload_id":"demo/worker/0/webdemo","identity":"spiffe://demo/webdemo","consul_service":"webdemo","allowed_compose_hashes":["`+testHash+`"]}]}`),
157197
nonces: NewNonceStore(time.Minute),
158198
verifier: &VerifierClient{URL: verifier.URL, HTTP: verifier.Client()},
159199
issuer: issuer,
@@ -215,6 +255,76 @@ func TestAttestFlow(t *testing.T) {
215255
}
216256
}
217257

258+
func TestAttestRejectsKMSMismatch(t *testing.T) {
259+
var expectedReportData string
260+
verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261+
writeJSON(w, http.StatusOK, map[string]any{
262+
"is_valid": true,
263+
"details": map[string]any{
264+
"quote_verified": true,
265+
"event_log_verified": true,
266+
"report_data": expectedReportData,
267+
"app_info": map[string]string{
268+
"compose_hash": testHash,
269+
"key_provider_info": hex.EncodeToString([]byte(`{"name":"kms","id":"3059301306072a8648ce3d020106082a8648ce3d030107034200040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}`)),
270+
},
271+
},
272+
})
273+
}))
274+
defer verifier.Close()
275+
276+
s := &Server{
277+
policy: mustPolicy(t, `{"cluster":"demo","policy_epoch":1,"kms":{"name":"kms","id":"`+testKMSKey+`"},"workloads":[{"workload_id":"demo/worker/0/webdemo","identity":"spiffe://demo/webdemo","consul_service":"webdemo","allowed_compose_hashes":["`+testHash+`"]}]}`),
278+
nonces: NewNonceStore(time.Minute),
279+
verifier: &VerifierClient{URL: verifier.URL, HTTP: verifier.Client()},
280+
issuer: &recordingIssuer{token: "secret-token"},
281+
tokenTTL: time.Hour,
282+
now: func() time.Time { return time.Unix(100, 0).UTC() },
283+
nonceRandom: bytes.NewReader(bytes.Repeat([]byte{0x55}, 32)),
284+
}
285+
ts := httptest.NewServer(s.routes())
286+
defer ts.Close()
287+
288+
challengeResp, err := http.Post(ts.URL+"/v1/admission/challenge", "application/json", nil)
289+
if err != nil {
290+
t.Fatal(err)
291+
}
292+
var challenge struct {
293+
Nonce string `json:"nonce"`
294+
}
295+
if err := json.NewDecoder(challengeResp.Body).Decode(&challenge); err != nil {
296+
t.Fatal(err)
297+
}
298+
_ = challengeResp.Body.Close()
299+
300+
statement := `{"identity":"spiffe://demo/webdemo","peer_id":"worker-1"}`
301+
expectedReportData, err = reportDataHex(hex.EncodeToString([]byte(statement)), challenge.Nonce)
302+
if err != nil {
303+
t.Fatal(err)
304+
}
305+
buf, _ := json.Marshal(map[string]any{
306+
"identity": "spiffe://demo/webdemo",
307+
"binding": hex.EncodeToString([]byte(statement)),
308+
"nonce": challenge.Nonce,
309+
"attestation": "aabbcc",
310+
})
311+
resp, err := http.Post(ts.URL+"/v1/admission/attest", "application/json", bytes.NewReader(buf))
312+
if err != nil {
313+
t.Fatal(err)
314+
}
315+
defer resp.Body.Close()
316+
if resp.StatusCode != http.StatusForbidden {
317+
t.Fatalf("status = %d", resp.StatusCode)
318+
}
319+
var body map[string]string
320+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
321+
t.Fatal(err)
322+
}
323+
if body["code"] != "KMS_POLICY_MISMATCH" {
324+
t.Fatalf("wrong error body: %+v", body)
325+
}
326+
}
327+
218328
func TestAttestCarriesConsulPermissionsFromPolicy(t *testing.T) {
219329
var expectedReportData string
220330
verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)