From 99b5fbdd26ce67f7d3c68e92066896a0097be5a6 Mon Sep 17 00:00:00 2001 From: yanchengchen Date: Thu, 2 Jul 2026 18:48:06 +0000 Subject: [PATCH] Implement GCE PZID verification to validate TDX quote MR_OWNER against instance identity --- gce/metadata.go | 224 +++++++++++++++ gce/metadata_test.go | 186 ++++++++++++ gce/provenance.go | 168 +++++++++++ gce/provenance_test.go | 173 +++++++++++ gce/pzid.go | 106 +++++++ gce/pzid_test.go | 108 +++++++ go.sum | 2 - tools/gceprovenance/README.md | 131 +++++++++ tools/gceprovenance/main.go | 475 +++++++++++++++++++++++++++++++ tools/gceprovenance/main_test.go | 156 ++++++++++ tools/provenance/README.md | 49 ---- tools/provenance/main.go | 189 ------------ tools/provenance/main_test.go | 110 ------- verify/verify.go | 2 +- 14 files changed, 1728 insertions(+), 351 deletions(-) create mode 100644 gce/metadata.go create mode 100644 gce/metadata_test.go create mode 100644 gce/provenance.go create mode 100644 gce/provenance_test.go create mode 100644 gce/pzid.go create mode 100644 gce/pzid_test.go create mode 100644 tools/gceprovenance/README.md create mode 100644 tools/gceprovenance/main.go create mode 100644 tools/gceprovenance/main_test.go delete mode 100644 tools/provenance/README.md delete mode 100644 tools/provenance/main.go delete mode 100644 tools/provenance/main_test.go diff --git a/gce/metadata.go b/gce/metadata.go new file mode 100644 index 0000000..13b0b03 --- /dev/null +++ b/gce/metadata.go @@ -0,0 +1,224 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gce contains Google Compute Engine helpers for TDX guests. +package gce + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +const defaultMetadataBaseURL = "http://metadata.google.internal/computeMetadata/v1/" + +var ( + defaultMetadataHTTPClient = &http.Client{Timeout: 2 * time.Second} + gceInstancePattern = regexp.MustCompile(`^projects/([^/]*)/zones/([^/]*)/instances/([^/]*)$`) +) + +// InstanceInfo identifies the GCE VM described by the metadata server. +type InstanceInfo struct { + Zone string + ProjectID string + ProjectNumber uint64 + InstanceName string + InstanceID uint64 +} + +// ResourceString returns the canonical GCE instance resource string when the +// numeric project and instance IDs are available. +func (i *InstanceInfo) ResourceString() string { + if i == nil { + return "" + } + return fmt.Sprintf("projects/%d/zones/%s/instances/%d", i.ProjectNumber, i.Zone, i.InstanceID) +} + +// LogString returns a compact, stable representation of the collected instance +// identity used for CLI diagnostics. +func (i *InstanceInfo) LogString() string { + if i == nil { + return "" + } + parts := []string{ + fmt.Sprintf("gce_instance=%s", i.ResourceString()), + } + if i.ProjectID != "" { + parts = append(parts, fmt.Sprintf("project_id=%s", i.ProjectID)) + } + if i.InstanceName != "" { + parts = append(parts, fmt.Sprintf("instance_name=%s", i.InstanceName)) + } + return strings.Join(parts, " ") +} + +// ResolveInstanceInfo returns GCE instance identity from either an explicit +// resource string or the metadata server. When gceInstance is empty, metadata is +// used by default; mdsRequested exists to reject callers that explicitly request +// metadata while also providing an instance resource. +func ResolveInstanceInfo(ctx context.Context, gceInstance string, mdsRequested bool) (*InstanceInfo, error) { + if gceInstance != "" && mdsRequested { + return nil, errors.New("set only one GCE instance identity source") + } + if gceInstance != "" { + info, err := InstanceInfoFromGCEInstance(gceInstance) + if err != nil { + return nil, fmt.Errorf("parse GCE instance resource string: %w", err) + } + return info, nil + } + info, err := InstanceInfoFromMetadata(ctx) + if err != nil { + return nil, fmt.Errorf("resolve GCE instance identity from metadata server: %w", err) + } + return info, nil +} + +// InstanceInfoFromGCEInstance parses a GCE instance resource string in the form +// projects//zones//instances/. +func InstanceInfoFromGCEInstance(value string) (*InstanceInfo, error) { + matches := gceInstancePattern.FindStringSubmatch(strings.TrimSpace(value)) + if matches == nil { + return nil, fmt.Errorf("GCE instance must be in form projects//zones//instances/") + } + projectNumber, err := parseUintField("project number", matches[1]) + if err != nil { + return nil, err + } + zone := matches[2] + if err := validateZone(zone); err != nil { + return nil, fmt.Errorf("GCE instance %w", err) + } + instanceID, err := parseUintField("instance ID", matches[3]) + if err != nil { + return nil, err + } + return &InstanceInfo{ + Zone: zone, + ProjectNumber: projectNumber, + InstanceID: instanceID, + }, nil +} + +// InstanceInfoFromMetadata reads GCE instance identity from the metadata server. +func InstanceInfoFromMetadata(ctx context.Context) (*InstanceInfo, error) { + return instanceInfoFromMetadata(ctx, defaultMetadataBaseURL, defaultMetadataHTTPClient) +} + +func instanceInfoFromMetadata(ctx context.Context, baseURL string, client *http.Client) (*InstanceInfo, error) { + if client == nil { + client = defaultMetadataHTTPClient + } + zone, err := getMetadata(ctx, client, baseURL, "instance/zone") + if err != nil { + return nil, fmt.Errorf("fetch zone from metadata server: %w", err) + } + projectID, err := getMetadata(ctx, client, baseURL, "project/project-id") + if err != nil { + return nil, fmt.Errorf("fetch project ID from metadata server: %w", err) + } + projectNumber, err := getMetadataUint(ctx, client, baseURL, "project/numeric-project-id") + if err != nil { + return nil, err + } + instanceName, err := getMetadata(ctx, client, baseURL, "instance/name") + if err != nil { + return nil, fmt.Errorf("fetch instance name from metadata server: %w", err) + } + instanceID, err := getMetadataUint(ctx, client, baseURL, "instance/id") + if err != nil { + return nil, err + } + + return &InstanceInfo{ + Zone: lastMetadataPathComponent(zone), + ProjectID: projectID, + ProjectNumber: projectNumber, + InstanceName: instanceName, + InstanceID: instanceID, + }, nil +} + +func getMetadataUint(ctx context.Context, client *http.Client, baseURL, suffix string) (uint64, error) { + value, err := getMetadata(ctx, client, baseURL, suffix) + if err != nil { + return 0, fmt.Errorf("fetch %s from metadata server: %w", suffix, err) + } + return parseUintField("metadata "+suffix, value) +} + +func getMetadata(ctx context.Context, client *http.Client, baseURL, suffix string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL(baseURL, suffix), nil) + if err != nil { + return "", err + } + req.Header.Set("Metadata-Flavor", "Google") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("metadata server returned %s for %s", resp.Status, suffix) + } + if resp.Header.Get("Metadata-Flavor") != "Google" { + return "", fmt.Errorf("metadata server response for %s missing Metadata-Flavor: Google", suffix) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + value := strings.TrimSpace(string(body)) + if value == "" { + return "", fmt.Errorf("metadata server returned empty value for %s", suffix) + } + return value, nil +} + +func metadataURL(baseURL, suffix string) string { + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(suffix, "/") +} + +func lastMetadataPathComponent(value string) string { + parts := strings.Split(value, "/") + return parts[len(parts)-1] +} + +func parseUintField(name, value string) (uint64, error) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s value %q as uint64: %w", name, value, err) + } + return parsed, nil +} + +func validateZone(zone string) error { + if zone == "" { + return errors.New("zone is empty") + } + for _, r := range zone { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { + return fmt.Errorf("zone %q contains non-GCE zone characters", zone) + } + } + return nil +} diff --git a/gce/metadata_test.go b/gce/metadata_test.go new file mode 100644 index 0000000..92ee292 --- /dev/null +++ b/gce/metadata_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gce + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInstanceInfoFromMetadata(t *testing.T) { + responses := map[string]string{ + "instance/zone": "projects/123456789012/zones/us-central1-a", + "project/project-id": "test-project", + "project/numeric-project-id": "123456789012", + "instance/name": "test-instance", + "instance/id": "112233445566778899", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Metadata-Flavor"); got != "Google" { + t.Errorf("Metadata-Flavor request header = %q, want Google", got) + } + const prefix = "/computeMetadata/v1/" + suffix := r.URL.Path[len(prefix):] + value, ok := responses[suffix] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Metadata-Flavor", "Google") + fmt.Fprint(w, value) + })) + defer server.Close() + + got, err := instanceInfoFromMetadata(context.Background(), server.URL+"/computeMetadata/v1/", server.Client()) + if err != nil { + t.Fatalf("instanceInfoFromMetadata() failed: %v", err) + } + want := &InstanceInfo{ + Zone: "us-central1-a", + ProjectID: "test-project", + ProjectNumber: 123456789012, + InstanceName: "test-instance", + InstanceID: 112233445566778899, + } + if *got != *want { + t.Errorf("instanceInfoFromMetadata() = %+v, want %+v", got, want) + } +} + +func TestInstanceInfoLogString(t *testing.T) { + info := &InstanceInfo{ + Zone: "us-central1-a", + ProjectID: "test-project", + ProjectNumber: 123456789012, + InstanceName: "test-instance", + InstanceID: 112233445566778899, + } + + got := info.LogString() + want := "gce_instance=projects/123456789012/zones/us-central1-a/instances/112233445566778899 project_id=test-project instance_name=test-instance" + if got != want { + t.Errorf("InstanceInfo.LogString() = %q, want %q", got, want) + } +} + +func TestInstanceInfoFromMetadataRejectsInvalidProjectNumber(t *testing.T) { + responses := map[string]string{ + "instance/zone": "projects/123456789012/zones/us-central1-a", + "project/project-id": "test-project", + "project/numeric-project-id": "not-a-number", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + const prefix = "/computeMetadata/v1/" + suffix := r.URL.Path[len(prefix):] + value, ok := responses[suffix] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Metadata-Flavor", "Google") + fmt.Fprint(w, value) + })) + defer server.Close() + + if _, err := instanceInfoFromMetadata(context.Background(), server.URL+"/computeMetadata/v1/", server.Client()); err == nil { + t.Error("instanceInfoFromMetadata() succeeded, want error") + } +} + +func TestInstanceInfoFromGCEInstance(t *testing.T) { + got, err := InstanceInfoFromGCEInstance("projects/123456789012/zones/us-central1-a/instances/112233445566778899") + if err != nil { + t.Fatalf("InstanceInfoFromGCEInstance() failed: %v", err) + } + want := &InstanceInfo{ + Zone: "us-central1-a", + ProjectNumber: 123456789012, + InstanceID: 112233445566778899, + } + if *got != *want { + t.Errorf("InstanceInfoFromGCEInstance() = %+v, want %+v", got, want) + } +} + +func TestInstanceInfoFromGCEInstanceRejectsMalformedInput(t *testing.T) { + testCases := []struct { + name string + value string + }{ + { + name: "Wrong resource shape", + value: "projects/123456789012/instances/112233445566778899", + }, + { + name: "Non-numeric project number", + value: "projects/test-project/zones/us-central1-a/instances/112233445566778899", + }, + { + name: "Non-numeric instance ID", + value: "projects/123456789012/zones/us-central1-a/instances/test-instance", + }, + { + name: "Empty zone", + value: "projects/123456789012/zones//instances/112233445566778899", + }, + { + name: "Invalid zone characters", + value: "projects/123456789012/zones/us_central1-a/instances/112233445566778899", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := InstanceInfoFromGCEInstance(tc.value); err == nil { + t.Error("InstanceInfoFromGCEInstance() succeeded, want error") + } + }) + } +} + +func TestResolveInstanceInfoRejectsConflictingSources(t *testing.T) { + if _, err := ResolveInstanceInfo(context.Background(), "projects/123/zones/us-central1-a/instances/456", true); err == nil { + t.Error("ResolveInstanceInfo() succeeded with two identity sources, want error") + } +} + +func TestResolveInstanceInfoRejectsMalformedGCEInstance(t *testing.T) { + _, err := ResolveInstanceInfo(context.Background(), "not-a-resource", false) + if err == nil { + t.Fatal("ResolveInstanceInfo() succeeded with malformed instance resource, want error") + } + if got, want := err.Error(), "parse GCE instance resource string:"; !strings.Contains(got, want) { + t.Errorf("ResolveInstanceInfo() error = %q, want prefix %q", got, want) + } +} + +func TestResolveInstanceInfoFromGCEInstance(t *testing.T) { + got, err := ResolveInstanceInfo(context.Background(), "projects/123/zones/us-central1-a/instances/456", false) + if err != nil { + t.Fatalf("ResolveInstanceInfo() failed: %v", err) + } + want := &InstanceInfo{ + Zone: "us-central1-a", + ProjectNumber: 123, + InstanceID: 456, + } + if *got != *want { + t.Errorf("ResolveInstanceInfo() = %+v, want %+v", got, want) + } +} diff --git a/gce/provenance.go b/gce/provenance.go new file mode 100644 index 0000000..6e3de36 --- /dev/null +++ b/gce/provenance.go @@ -0,0 +1,168 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gce + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/google/go-tdx-guest/abi" + tg "github.com/google/go-tdx-guest/client" + "github.com/google/go-tdx-guest/pcs" + pb "github.com/google/go-tdx-guest/proto/tdx" + "github.com/google/go-tdx-guest/verify" +) + +// GCSBaseURL is the base URL for Google Cloud Storage APIs. +const GCSBaseURL = "https://storage.googleapis.com" + +var ( + validPPID = regexp.MustCompile(`^[a-fA-F0-9]{32}$`) + validBucket = regexp.MustCompile(`^[a-z0-9_.-]{3,63}$`) +) + +// PPIDFromQuote extracts the PPID from the PCK certificate embedded in a TDX +// quote. +func PPIDFromQuote(quote any) (string, error) { + if err := requireQuoteV4(quote); err != nil { + return "", err + } + chain, err := verify.ExtractChainFromQuote(quote) + if err != nil { + return "", fmt.Errorf("could not extract PCK certificate chain from quote: %w", err) + } + if chain == nil || chain.PCKCertificate == nil { + return "", errors.New("PCK certificate is missing in the quote") + } + exts, err := pcs.PckCertificateExtensions(chain.PCKCertificate) + if err != nil { + return "", fmt.Errorf("could not extract PCK extensions: %w", err) + } + if exts.PPID == "" { + return "", errors.New("PPID is empty in PCK extensions") + } + return exts.PPID, nil +} + +// ResolveRawQuote returns raw TDX quote bytes and the parsed quote from a raw +// binary quote file or the local quote provider when path is empty. +func ResolveRawQuote(path string, reportData [64]byte) ([]byte, any, error) { + if path != "" { + quoteBytes, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("read quote file at %s: %w", path, err) + } + quote, err := abi.QuoteToProto(quoteBytes) + if err != nil { + return nil, nil, fmt.Errorf("parse quote bytes from %s: %w", path, err) + } + if err := requireQuoteV4(quote); err != nil { + return nil, nil, err + } + return quoteBytes, quote, nil + } + + qp, err := tg.GetQuoteProvider() + if err != nil { + return nil, nil, fmt.Errorf("get quote provider: %w", err) + } + if err := qp.IsSupported(); err != nil { + return nil, nil, fmt.Errorf("TDX quote provider not supported on this platform: %w", err) + } + quoteBytes, err := tg.GetRawQuote(qp, reportData) + if err != nil { + return nil, nil, fmt.Errorf("fetch local TDX quote: %w", err) + } + quote, err := abi.QuoteToProto(quoteBytes) + if err != nil { + return nil, nil, fmt.Errorf("parse local TDX quote: %w", err) + } + if err := requireQuoteV4(quote); err != nil { + return nil, nil, err + } + return quoteBytes, quote, nil +} + +func requireQuoteV4(quote any) error { + if _, ok := quote.(*pb.QuoteV4); !ok { + return fmt.Errorf("GCE provenance supports QuoteV4 only, got %T", quote) + } + return nil +} + +// FetchProvenanceData fetches a provenance JSON object from the configured +// public GCS bucket by PPID. +func FetchProvenanceData(baseURL, ppid, bucket string, debugf func(string, ...any)) ([]byte, error) { + if !validPPID.MatchString(ppid) { + return nil, errors.New("invalid PPID format") + } + if !validBucket.MatchString(bucket) { + return nil, errors.New("invalid bucket name format") + } + + if debugf != nil { + debugf("Using PPID: %s\n", ppid) + debugf("Using GCS Bucket: %s\n", bucket) + } + + url := fmt.Sprintf("%s/%s/%s", baseURL, bucket, ppid) + if debugf != nil { + debugf("Fetching from URL: %s\n", url) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch from GCS: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseGCSError(resp, bodyBytes, ppid, bucket) + } + if !json.Valid(bodyBytes) { + return nil, errors.New("received invalid JSON from GCS") + } + return bodyBytes, nil +} + +// ParseGCSError converts known GCS XML error bodies into compact CLI errors. +func ParseGCSError(resp *http.Response, bodyBytes []byte, ppid string, bucket string) error { + bodyStr := string(bodyBytes) + if strings.Contains(bodyStr, "NoSuchBucket") { + return fmt.Errorf("gcs request failed: bucket '%s' not found", bucket) + } + if strings.Contains(bodyStr, "NoSuchKey") { + return fmt.Errorf("gcs request failed: file '%s' not found in bucket '%s'", ppid, bucket) + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("gcs request failed: access denied to bucket '%s' (403 Forbidden); the bucket may be private or not exist", bucket) + } + return fmt.Errorf("gcs request failed with status: %s", resp.Status) +} diff --git a/gce/provenance_test.go b/gce/provenance_test.go new file mode 100644 index 0000000..b867339 --- /dev/null +++ b/gce/provenance_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gce + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-tdx-guest/abi" + "github.com/google/go-tdx-guest/testing/testdata" +) + +func TestFetchProvenanceData(t *testing.T) { + const ( + ppid = "abcdef1234567890abcdef1234567890" + bucket = "test-bucket" + body = `{"location":"us-east1"}` + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantPath := fmt.Sprintf("/%s/%s", bucket, ppid) + if r.URL.Path != wantPath { + t.Errorf("request path = %q, want %q", r.URL.Path, wantPath) + http.NotFound(w, r) + return + } + fmt.Fprint(w, body) + })) + defer server.Close() + + got, err := FetchProvenanceData(server.URL, ppid, bucket, nil) + if err != nil { + t.Fatalf("FetchProvenanceData() failed: %v", err) + } + if string(got) != body { + t.Errorf("FetchProvenanceData() = %s, want %s", got, body) + } +} + +func TestFetchProvenanceDataRejectsInvalidInputs(t *testing.T) { + testCases := []struct { + name string + ppid string + bucket string + }{ + { + name: "Invalid PPID", + ppid: "not-a-ppid", + bucket: "test-bucket", + }, + { + name: "Invalid bucket", + ppid: "abcdef1234567890abcdef1234567890", + bucket: "Invalid_Bucket", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := FetchProvenanceData("https://example.test", tc.ppid, tc.bucket, nil); err == nil { + t.Fatal("FetchProvenanceData() succeeded, want error") + } + }) + } +} + +func TestFetchProvenanceDataRejectsInvalidJSON(t *testing.T) { + const ( + ppid = "abcdef1234567890abcdef1234567890" + bucket = "test-bucket" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "not json") + })) + defer server.Close() + + if _, err := FetchProvenanceData(server.URL, ppid, bucket, nil); err == nil { + t.Fatal("FetchProvenanceData() succeeded, want error") + } +} + +func TestResolveRawQuoteRejectsQuoteV5(t *testing.T) { + path := filepath.Join(t.TempDir(), "quote-v5.bin") + if err := os.WriteFile(path, testdata.RawQuoteV5, 0644); err != nil { + t.Fatalf("WriteFile(%q) failed: %v", path, err) + } + var reportData [64]byte + if _, _, err := ResolveRawQuote(path, reportData); err == nil { + t.Fatal("ResolveRawQuote() succeeded, want QuoteV5 rejection") + } else if !strings.Contains(err.Error(), "supports QuoteV4 only") { + t.Fatalf("ResolveRawQuote() error = %q, want QuoteV4-only error", err.Error()) + } +} + +func TestPPIDFromQuoteRejectsQuoteV5(t *testing.T) { + quote, err := abi.QuoteToProto(testdata.RawQuoteV5) + if err != nil { + t.Fatalf("QuoteToProto(RawQuoteV5) failed: %v", err) + } + if _, err := PPIDFromQuote(quote); err == nil { + t.Fatal("PPIDFromQuote() succeeded, want QuoteV5 rejection") + } else if !strings.Contains(err.Error(), "supports QuoteV4 only") { + t.Fatalf("PPIDFromQuote() error = %q, want QuoteV4-only error", err.Error()) + } +} + +func TestParseGCSError(t *testing.T) { + testCases := []struct { + name string + status int + body string + want string + }{ + { + name: "No such bucket", + status: http.StatusNotFound, + body: "NoSuchBucket", + want: "gcs request failed: bucket 'test-bucket' not found", + }, + { + name: "No such key", + status: http.StatusNotFound, + body: "NoSuchKey", + want: "gcs request failed: file 'abcdef1234567890abcdef1234567890' not found in bucket 'test-bucket'", + }, + { + name: "Forbidden", + status: http.StatusForbidden, + body: "forbidden", + want: "gcs request failed: access denied to bucket 'test-bucket' (403 Forbidden); the bucket may be private or not exist", + }, + { + name: "Other status", + status: http.StatusInternalServerError, + body: "internal error", + want: "gcs request failed with status: 500 Internal Server Error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resp := &http.Response{StatusCode: tc.status, Status: http.StatusText(tc.status)} + if resp.Status == "" { + resp.Status = fmt.Sprintf("%d status code", tc.status) + } else { + resp.Status = fmt.Sprintf("%d %s", tc.status, resp.Status) + } + err := ParseGCSError(resp, []byte(tc.body), "abcdef1234567890abcdef1234567890", "test-bucket") + if err == nil { + t.Fatal("ParseGCSError() returned nil, want error") + } + if err.Error() != tc.want { + t.Errorf("ParseGCSError() = %q, want %q", err.Error(), tc.want) + } + }) + } +} diff --git a/gce/pzid.go b/gce/pzid.go new file mode 100644 index 0000000..63d5e9a --- /dev/null +++ b/gce/pzid.go @@ -0,0 +1,106 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gce + +import ( + "bytes" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "strconv" + + "github.com/google/go-tdx-guest/abi" + pb "github.com/google/go-tdx-guest/proto/tdx" +) + +// PZIDPayload returns the canonical ASCII JSON payload used to bind GCE project, +// zone, and instance identity into TD quote MR_OWNER. +func PZIDPayload(info *InstanceInfo) (string, error) { + if info == nil { + return "", errors.New("instance info is nil") + } + if err := validateZone(info.Zone); err != nil { + return "", fmt.Errorf("instance info %w", err) + } + // Zone validation keeps strconv.Quote equivalent to JSON string + // encoding for the canonical PZID payload. + return fmt.Sprintf( + `{"instanceId":%d,"numericalProjectId":%d,"zone":%s}`, + info.InstanceID, + info.ProjectNumber, + strconv.Quote(info.Zone), + ), nil +} + +// PZIDDigest returns the SHA-384 digest that the GCE infrastructure writes to +// TD quote MR_OWNER. +func PZIDDigest(info *InstanceInfo) ([]byte, string, error) { + payload, err := PZIDPayload(info) + if err != nil { + return nil, "", err + } + digest := sha512.Sum384([]byte(payload)) + return digest[:], payload, nil +} + +// MROwnerFromQuote returns the MR_OWNER bytes from a supported TDX quote. +func MROwnerFromQuote(quote any) ([]byte, error) { + q, ok := quote.(*pb.QuoteV4) + if !ok { + return nil, fmt.Errorf("GCE provenance supports QuoteV4 only, got %T", quote) + } + if q.GetTdQuoteBody() == nil { + return nil, errors.New("QuoteV4 TD quote body is nil") + } + mrOwner := q.GetTdQuoteBody().GetMrOwner() + if len(mrOwner) != abi.MrOwnerSize { + return nil, fmt.Errorf("MR_OWNER length is %d bytes, want %d", len(mrOwner), abi.MrOwnerSize) + } + return mrOwner, nil +} + +// PZIDVerification contains the values used while comparing GCE PZID identity +// against TD quote MR_OWNER. +type PZIDVerification struct { + Payload string + ExpectedMROwner []byte + AttestedMROwner []byte + ExpectedMROwnerHX string + AttestedMROwnerHX string +} + +// VerifyPZID compares the SHA-384 PZID digest with TD quote MR_OWNER. +func VerifyPZID(quote any, info *InstanceInfo) (*PZIDVerification, error) { + expected, payload, err := PZIDDigest(info) + if err != nil { + return nil, err + } + actual, err := MROwnerFromQuote(quote) + if err != nil { + return nil, err + } + result := &PZIDVerification{ + Payload: payload, + ExpectedMROwner: expected, + AttestedMROwner: actual, + ExpectedMROwnerHX: hex.EncodeToString(expected), + AttestedMROwnerHX: hex.EncodeToString(actual), + } + if !bytes.Equal(actual, expected) { + return result, fmt.Errorf("MR_OWNER does not match PZID digest: got %s, want %s", result.AttestedMROwnerHX, result.ExpectedMROwnerHX) + } + return result, nil +} diff --git a/gce/pzid_test.go b/gce/pzid_test.go new file mode 100644 index 0000000..cec4ed0 --- /dev/null +++ b/gce/pzid_test.go @@ -0,0 +1,108 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gce + +import ( + "bytes" + "crypto/sha512" + "strings" + "testing" + + pb "github.com/google/go-tdx-guest/proto/tdx" +) + +func TestPZIDPayload(t *testing.T) { + info := &InstanceInfo{ + Zone: "us-central1-a", + ProjectNumber: 123456789012, + InstanceID: 112233445566778899, + } + got, err := PZIDPayload(info) + if err != nil { + t.Fatalf("PZIDPayload() failed: %v", err) + } + want := `{"instanceId":112233445566778899,"numericalProjectId":123456789012,"zone":"us-central1-a"}` + if got != want { + t.Errorf("PZIDPayload() = %q, want %q", got, want) + } +} + +func TestPZIDPayloadRejectsInvalidZone(t *testing.T) { + testCases := []struct { + name string + zone string + }{ + { + name: "Empty zone", + }, + { + name: "Invalid zone characters", + zone: `us-central1-a"`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if _, err := PZIDPayload(&InstanceInfo{Zone: tc.zone}); err == nil { + t.Error("PZIDPayload() succeeded, want error") + } + }) + } +} + +func TestVerifyPZID(t *testing.T) { + info := &InstanceInfo{ + Zone: "us-central1-a", + ProjectNumber: 123456789012, + InstanceID: 112233445566778899, + } + payload, err := PZIDPayload(info) + if err != nil { + t.Fatalf("PZIDPayload() failed: %v", err) + } + digest := sha512.Sum384([]byte(payload)) + quote := &pb.QuoteV4{TdQuoteBody: &pb.TDQuoteBody{MrOwner: digest[:]}} + + got, err := VerifyPZID(quote, info) + if err != nil { + t.Fatalf("VerifyPZID() failed: %v", err) + } + if got.Payload != payload { + t.Errorf("VerifyPZID().Payload = %q, want %q", got.Payload, payload) + } + if !bytes.Equal(got.ExpectedMROwner, digest[:]) { + t.Errorf("VerifyPZID().ExpectedMROwner = %x, want %x", got.ExpectedMROwner, digest) + } +} + +func TestVerifyPZIDRejectsMismatch(t *testing.T) { + info := &InstanceInfo{ + Zone: "us-central1-a", + ProjectNumber: 123456789012, + InstanceID: 112233445566778899, + } + quote := &pb.QuoteV4{TdQuoteBody: &pb.TDQuoteBody{MrOwner: make([]byte, sha512.Size384)}} + if _, err := VerifyPZID(quote, info); err == nil { + t.Fatal("VerifyPZID() succeeded, want mismatch error") + } +} + +func TestMROwnerFromQuoteRejectsQuoteV5(t *testing.T) { + if _, err := MROwnerFromQuote(&pb.QuoteV5{}); err == nil { + t.Fatal("MROwnerFromQuote() succeeded, want error") + } else if !strings.Contains(err.Error(), "supports QuoteV4 only") { + t.Fatalf("MROwnerFromQuote() error = %q, want QuoteV4-only error", err.Error()) + } +} diff --git a/go.sum b/go.sum index 0cffc34..f7bbc36 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-configfs-tsm v0.3.2 h1:ZYmHkdQavfsvVGDtX7RRda0gamelUNUhu0A9fbiuLmE= github.com/google/go-configfs-tsm v0.3.2/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= -github.com/google/go-eventlog v0.0.1 h1:7lV3gf61LNDhfS9gQplqaJc/j9ztLhKKgZk/lR6vv4Q= -github.com/google/go-eventlog v0.0.1/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= github.com/google/go-eventlog v0.0.2-0.20241213203620-f921bdc3aeb0 h1:270O3tFxca1lAXm3JVWqUU4fHlK3EEIEYIfk4koWMkM= github.com/google/go-eventlog v0.0.2-0.20241213203620-f921bdc3aeb0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= github.com/google/go-sev-guest v0.8.0 h1:IIZIqdcMJXgTm1nMvId442OUpYebbWDWa9bi9/lUUwc= diff --git a/tools/gceprovenance/README.md b/tools/gceprovenance/README.md new file mode 100644 index 0000000..55f522d --- /dev/null +++ b/tools/gceprovenance/README.md @@ -0,0 +1,131 @@ +# `gceprovenance` CLI tool + +`gceprovenance` verifies GCE TDX provenance by reusing the quote verification +library in this repository, then checking GCE-specific host and instance +bindings. + +## Build + +```bash +go build ./tools/gceprovenance +``` + +## Full Verification + +Verify the local VM: + +```bash +sudo ./gceprovenance verify +``` + +This: + +1. Fetches a local TDX quote. +2. Verifies the quote. +3. Extracts PPID from the verified quote. +4. Fetches the host registry JSON from the configured bucket. +5. Reads GCE instance identity from the metadata server. +6. Checks the quote `MR_OWNER` against the expected PZID digest. +7. Writes `tdx_quote.bin` and `host_registry.json`. + +Verify a supplied QuoteV4 against explicit GCE instance identity: + +```bash +./gceprovenance verify \ + -quote /path/to/quote.bin \ + -instance projects//zones//instances/ +``` + +## Host Verification + +```bash +sudo ./gceprovenance verify-host +``` + +This verifies a QuoteV4, extracts PPID, fetches the host registry document, and +writes the raw quote and host registry JSON. + +## Instance Verification + +```bash +sudo ./gceprovenance verify-instance +``` + +This verifies a QuoteV4, resolves GCE instance identity, and compares the expected +PZID digest with quote `MR_OWNER`. + +Verify a supplied QuoteV4: + +```bash +./gceprovenance verify-instance \ + -quote /path/to/quote.bin \ + -instance projects//zones//instances/ +``` + +## Challenge + +`-challenge` accepts 64 bytes as 128 hex characters. + +When fetching a local quote, the challenge is used as quote `REPORT_DATA`. For +both local and supplied quotes, the CLI verifies that the quote `REPORT_DATA` +matches the challenge after quote verification succeeds. + +```bash +sudo ./gceprovenance verify -challenge <128-hex-character-challenge> +``` + +Without `-challenge`, quote verification still runs, but quote freshness is not +checked. + +## Quote Verification Scope + +Quote verification authenticates the QuoteV4 using the Intel root certificate +embedded in this repository. It checks the quote structure, PCK certificate +chain and validity period, quote signature, QE report signature, and the binding +between the attestation key and QE report. When `-challenge` is set, it also +checks the authenticated quote `REPORT_DATA` against that value. + +The command does not download Intel PCS collateral or evaluate TDX or QE TCB +status. It also does not check certificate revocation, workload measurements, +RTMRs, debug or migration attributes, or minimum SVN policy. Use `tools/check` +for customer-configurable TCB and TD evaluation policy. + +## Output Files + +By default, outputs are written under `-out-dir`: + +```text +/tdx_quote.bin +/host_registry.json +``` + +Defaults: + +```text +-out-dir . +-quote-out /tdx_quote.bin +-host-registry-out /host_registry.json +``` + +Use explicit output paths when needed: + +```bash +sudo ./gceprovenance verify \ + -quote-out quote.bin \ + -host-registry-out host_registry.json +``` + +`-quote-out` writes the exact raw quote bytes used by the command. +`-host-registry-out` writes the exact fetched host registry JSON. + +## Flags + +- `-bucket`: public GCS bucket containing host registry documents. Defaults to `confidential-host-registry`. +- `-quote`: raw binary TDX QuoteV4. If unset, a local QuoteV4 is fetched. +- `-instance`: GCE instance resource string in the form `projects//zones//instances/`. +- `-MDS`: use the metadata server to collect GCE instance identity. This is the default when `-instance` is unset. +- `-challenge`: expected quote `REPORT_DATA` as 128 hex characters. +- `-out-dir`: directory for default output files. +- `-quote-out`: path for the raw quote output file. +- `-host-registry-out`: path for the host registry JSON output file. +- `-verbose`: enable verbose output. diff --git a/tools/gceprovenance/main.go b/tools/gceprovenance/main.go new file mode 100644 index 0000000..1a83182 --- /dev/null +++ b/tools/gceprovenance/main.go @@ -0,0 +1,475 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package main implements the GCE TDX provenance CLI. +package main + +import ( + "context" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/google/go-tdx-guest/abi" + "github.com/google/go-tdx-guest/gce" + "github.com/google/go-tdx-guest/validate" + "github.com/google/go-tdx-guest/verify" +) + +const ( + defaultBucketName = "confidential-host-registry" + defaultOutDir = "." + defaultQuoteOutName = "tdx_quote.bin" + defaultHostRegistryOutName = "host_registry.json" +) + +type commandConfig struct { + Quote string + Bucket string + Instance string + UseMetadata bool + Challenge string + OutDir string + QuoteOut string + HostRegistryOut string + Verbose bool +} + +type quoteEvidence struct { + Raw []byte + Quote any + Challenge []byte +} + +type hostResult struct { + PPID string + HostRegistryPath string +} + +type instanceResult struct { + Info *gce.InstanceInfo + PZID *gce.PZIDVerification +} + +type reportedError struct { + err error +} + +func (e *reportedError) Error() string { + return e.err.Error() +} + +func (e *reportedError) Unwrap() error { + return e.err +} + +func main() { + if err := runCLI(os.Args[1:], gce.GCSBaseURL, os.Stdout, os.Stderr); err != nil { + var reported *reportedError + if !errors.As(err, &reported) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + } + os.Exit(1) + } +} + +func runCLI(args []string, baseURL string, stdout, stderr io.Writer) error { + if len(args) == 0 { + printUsage(stderr) + return errors.New("missing command; use \"verify\", \"verify-host\", or \"verify-instance\"") + } + + switch args[0] { + case "verify": + return runVerifyCLI(args[1:], baseURL, stdout, stderr) + case "verify-host": + return runVerifyHostCLI(args[1:], baseURL, stdout, stderr) + case "verify-instance": + return runVerifyInstanceCLI(args[1:], stdout, stderr) + case "help", "-h", "--help": + printUsage(stdout) + return nil + default: + if strings.HasPrefix(args[0], "-") { + return fmt.Errorf("missing command before %q; use \"gceprovenance verify\", \"gceprovenance verify-host\", or \"gceprovenance verify-instance\"", args[0]) + } + return fmt.Errorf("unknown command %q; use \"verify\", \"verify-host\", or \"verify-instance\"", args[0]) + } +} + +func runVerifyCLI(args []string, baseURL string, stdout, stderr io.Writer) error { + cfg, err := parseCommandFlags("gceprovenance verify", args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return runVerify(baseURL, cfg, stdout, stderr) +} + +func runVerifyHostCLI(args []string, baseURL string, stdout, stderr io.Writer) error { + cfg, err := parseCommandFlags("gceprovenance verify-host", args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return runVerifyHost(baseURL, cfg, stdout, stderr) +} + +func runVerifyInstanceCLI(args []string, stdout, stderr io.Writer) error { + cfg, err := parseCommandFlags("gceprovenance verify-instance", args, stderr) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return runVerifyInstance(cfg, stdout, stderr) +} + +func parseCommandFlags(name string, args []string, stderr io.Writer) (commandConfig, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + cfg := commandConfig{ + Bucket: defaultBucketName, + OutDir: defaultOutDir, + } + fs.StringVar(&cfg.Quote, "quote", "", "Path to a raw binary TDX quote. If unset, fetches a local quote.") + fs.StringVar(&cfg.Bucket, "bucket", defaultBucketName, "Public GCS bucket containing host registry documents.") + fs.StringVar(&cfg.Instance, "instance", "", "GCE instance resource string projects//zones//instances/.") + fs.BoolVar(&cfg.UseMetadata, "MDS", false, "Use the metadata server to collect GCE instance identity. This is the default when -instance is unset.") + fs.StringVar(&cfg.Challenge, "challenge", "", "Expected REPORT_DATA as 64 bytes encoded in 128 hex characters.") + fs.StringVar(&cfg.OutDir, "out-dir", defaultOutDir, "Directory for default output files.") + fs.StringVar(&cfg.QuoteOut, "quote-out", "", "Path to write the exact raw TDX quote used. Defaults to /tdx_quote.bin.") + fs.StringVar(&cfg.HostRegistryOut, "host-registry-out", "", "Path to write fetched host registry JSON. Defaults to /host_registry.json.") + fs.BoolVar(&cfg.Verbose, "verbose", false, "Enable verbose output.") + if err := fs.Parse(args); err != nil { + return cfg, err + } + if fs.NArg() != 0 { + return cfg, fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return cfg, nil +} + +func runVerify(baseURL string, cfg commandConfig, stdout, stderr io.Writer) error { + evidence, err := resolveAndVerifyQuote(context.Background(), cfg) + if err != nil { + return err + } + quotePath, err := writeQuoteOutput(cfg, evidence.Raw) + if err != nil { + return err + } + host, err := verifyHost(baseURL, cfg, evidence, stderr) + if err != nil { + return err + } + instance, err := verifyInstance(cfg, evidence) + if err != nil { + if instance != nil && instance.PZID != nil { + writeVerifyFailure(stderr, instance.Info, host.PPID, instance.PZID, cfg.Verbose) + return &reportedError{err: err} + } + return err + } + writeVerifySuccess(stdout, instance.Info, host.PPID, quotePath, host.HostRegistryPath, evidence.Challenge != nil) + return nil +} + +func runVerifyHost(baseURL string, cfg commandConfig, stdout, stderr io.Writer) error { + evidence, err := resolveAndVerifyQuote(context.Background(), cfg) + if err != nil { + return err + } + quotePath, err := writeQuoteOutput(cfg, evidence.Raw) + if err != nil { + return err + } + host, err := verifyHost(baseURL, cfg, evidence, stderr) + if err != nil { + return err + } + writeHostSuccess(stdout, host.PPID, quotePath, host.HostRegistryPath, evidence.Challenge != nil) + return nil +} + +func runVerifyInstance(cfg commandConfig, stdout, stderr io.Writer) error { + evidence, err := resolveAndVerifyQuote(context.Background(), cfg) + if err != nil { + return err + } + quotePath, err := writeQuoteOutput(cfg, evidence.Raw) + if err != nil { + return err + } + instance, err := verifyInstance(cfg, evidence) + if err != nil { + if instance != nil && instance.PZID != nil { + writeInstanceFailure(stderr, instance.Info, instance.PZID, cfg.Verbose) + return &reportedError{err: err} + } + return err + } + writeInstanceSuccess(stdout, instance.Info, quotePath, evidence.Challenge != nil) + return nil +} + +func resolveAndVerifyQuote(ctx context.Context, cfg commandConfig) (*quoteEvidence, error) { + challenge, reportData, err := parseChallenge(cfg.Challenge) + if err != nil { + return nil, err + } + raw, quote, err := gce.ResolveRawQuote(cfg.Quote, reportData) + if err != nil { + return nil, err + } + if err := verifyTDXQuote(ctx, quote); err != nil { + return nil, fmt.Errorf("quote verification failed: %w", err) + } + if challenge != nil { + if err := validateReportData(quote, challenge); err != nil { + return nil, fmt.Errorf("REPORT_DATA challenge verification failed: %w", err) + } + } + return "eEvidence{ + Raw: raw, + Quote: quote, + Challenge: challenge, + }, nil +} + +func verifyHost(baseURL string, cfg commandConfig, evidence *quoteEvidence, stderr io.Writer) (*hostResult, error) { + ppid, err := gce.PPIDFromQuote(evidence.Quote) + if err != nil { + return nil, err + } + hostRegistry, err := gce.FetchProvenanceData(baseURL, ppid, cfg.Bucket, debugf(stderr, cfg.Verbose)) + if err != nil { + return nil, fmt.Errorf("host registry document lookup failed: %w", err) + } + path := hostRegistryOutputPath(cfg) + if err := writeOutputFile(path, hostRegistry); err != nil { + return nil, fmt.Errorf("write host registry data to %s: %w", path, err) + } + return &hostResult{ + PPID: ppid, + HostRegistryPath: path, + }, nil +} + +func verifyInstance(cfg commandConfig, evidence *quoteEvidence) (*instanceResult, error) { + info, err := gce.ResolveInstanceInfo(context.Background(), cfg.Instance, cfg.UseMetadata) + if err != nil { + return nil, err + } + pzid, err := gce.VerifyPZID(evidence.Quote, info) + return &instanceResult{Info: info, PZID: pzid}, err +} + +func verifyTDXQuote(ctx context.Context, quote any) error { + return verify.TdxQuoteContext(ctx, quote, verify.DefaultOptions()) +} + +func validateReportData(quote any, challenge []byte) error { + opts := &validate.Options{ + TdQuoteBodyOptions: validate.TdQuoteBodyOptions{ + ReportData: challenge, + }, + } + return validate.TdxQuote(quote, opts) +} + +func parseChallenge(value string) ([]byte, [64]byte, error) { + var reportData [64]byte + if value == "" { + return nil, reportData, nil + } + challenge, err := hex.DecodeString(strings.TrimSpace(value)) + if err != nil { + return nil, reportData, fmt.Errorf("-challenge must be 64 bytes encoded as 128 hex characters: %w", err) + } + if len(challenge) != abi.ReportDataSize { + return nil, reportData, fmt.Errorf("-challenge must be 64 bytes encoded as 128 hex characters; got %d bytes", len(challenge)) + } + copy(reportData[:], challenge) + return challenge, reportData, nil +} + +func writeQuoteOutput(cfg commandConfig, quoteBytes []byte) (string, error) { + path := quoteOutputPath(cfg) + if err := writeOutputFile(path, quoteBytes); err != nil { + return "", fmt.Errorf("write raw quote to %s: %w", path, err) + } + return path, nil +} + +func quoteOutputPath(cfg commandConfig) string { + if cfg.QuoteOut != "" { + return cfg.QuoteOut + } + return filepath.Join(outputDir(cfg), defaultQuoteOutName) +} + +func hostRegistryOutputPath(cfg commandConfig) string { + if cfg.HostRegistryOut != "" { + return cfg.HostRegistryOut + } + return filepath.Join(outputDir(cfg), defaultHostRegistryOutName) +} + +func outputDir(cfg commandConfig) string { + if cfg.OutDir == "" { + return defaultOutDir + } + return cfg.OutDir +} + +func writeOutputFile(path string, contents []byte) error { + dir := filepath.Dir(path) + if dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return os.WriteFile(path, contents, 0644) +} + +func writeVerifySuccess(out io.Writer, info *gce.InstanceInfo, ppid, quotePath, hostRegistryPath string, challengeChecked bool) { + fmt.Fprintln(out, "GCE TDX provenance verification: OK") + fmt.Fprintln(out) + writeInstance(out, info) + fmt.Fprintln(out) + fmt.Fprintln(out, "Checks") + fmt.Fprintln(out, " Quote verification: OK") + writeChallengeCheck(out, challengeChecked) + fmt.Fprintln(out, " Host registry document: found") + fmt.Fprintln(out, " PZID binding: OK") + fmt.Fprintln(out) + fmt.Fprintf(out, "PPID: %s\n", ppid) + fmt.Fprintf(out, "Quote: %s\n", quotePath) + fmt.Fprintf(out, "Host registry: %s\n", hostRegistryPath) +} + +func writeHostSuccess(out io.Writer, ppid, quotePath, hostRegistryPath string, challengeChecked bool) { + fmt.Fprintln(out, "GCE TDX host verification: OK") + fmt.Fprintln(out) + fmt.Fprintln(out, "Checks") + fmt.Fprintln(out, " Quote verification: OK") + writeChallengeCheck(out, challengeChecked) + fmt.Fprintln(out, " Host registry document: found") + fmt.Fprintln(out) + fmt.Fprintf(out, "PPID: %s\n", ppid) + fmt.Fprintf(out, "Quote: %s\n", quotePath) + fmt.Fprintf(out, "Host registry: %s\n", hostRegistryPath) +} + +func writeInstanceSuccess(out io.Writer, info *gce.InstanceInfo, quotePath string, challengeChecked bool) { + fmt.Fprintln(out, "GCE TDX instance verification: OK") + fmt.Fprintln(out) + writeInstance(out, info) + fmt.Fprintln(out) + fmt.Fprintln(out, "Checks") + fmt.Fprintln(out, " Quote verification: OK") + writeChallengeCheck(out, challengeChecked) + fmt.Fprintln(out, " PZID binding: OK") + fmt.Fprintln(out) + fmt.Fprintf(out, "Quote: %s\n", quotePath) +} + +func writeChallengeCheck(out io.Writer, checked bool) { + if checked { + fmt.Fprintln(out, " REPORT_DATA challenge: OK") + return + } + fmt.Fprintln(out, " REPORT_DATA challenge: not checked") +} + +func writeInstanceFailure(out io.Writer, info *gce.InstanceInfo, result *gce.PZIDVerification, verbose bool) { + fmt.Fprintln(out, "GCE TDX instance verification: FAILED") + fmt.Fprintln(out) + writeInstance(out, info) + fmt.Fprintln(out) + fmt.Fprintln(out, "Checks") + fmt.Fprintln(out, " PZID binding: FAILED") + writePZIDDetails(out, result, verbose) +} + +func writeVerifyFailure(out io.Writer, info *gce.InstanceInfo, ppid string, result *gce.PZIDVerification, verbose bool) { + fmt.Fprintln(out, "GCE TDX provenance verification: FAILED") + fmt.Fprintln(out) + writeInstance(out, info) + fmt.Fprintln(out) + fmt.Fprintln(out, "Checks") + fmt.Fprintln(out, " Host registry document: found") + fmt.Fprintln(out, " PZID binding: FAILED") + writePZIDDetails(out, result, verbose) + fmt.Fprintln(out) + fmt.Fprintf(out, "PPID: %s\n", ppid) +} + +func writePZIDDetails(out io.Writer, result *gce.PZIDVerification, verbose bool) { + fmt.Fprintln(out) + fmt.Fprintln(out, "PZID binding") + if result == nil { + return + } + fmt.Fprintf(out, " Quote MR_OWNER: %s\n", result.AttestedMROwnerHX) + fmt.Fprintf(out, " Expected MR_OWNER: %s\n", result.ExpectedMROwnerHX) + if verbose { + fmt.Fprintf(out, " Payload: %s\n", result.Payload) + } +} + +func writeInstance(out io.Writer, info *gce.InstanceInfo) { + fmt.Fprintln(out, "Instance") + if info == nil { + fmt.Fprintln(out, " ") + return + } + if info.InstanceName != "" { + fmt.Fprintf(out, " Name: %s\n", info.InstanceName) + } + fmt.Fprintf(out, " Zone: %s\n", info.Zone) + if info.ProjectID != "" { + fmt.Fprintf(out, " Project: %s (%d)\n", info.ProjectID, info.ProjectNumber) + } else { + fmt.Fprintf(out, " Project: %d\n", info.ProjectNumber) + } + fmt.Fprintf(out, " Instance ID: %d\n", info.InstanceID) +} + +func debugf(stderr io.Writer, verbose bool) func(string, ...any) { + if !verbose { + return nil + } + return func(format string, a ...any) { + fmt.Fprintf(stderr, format, a...) + } +} + +func printUsage(out io.Writer) { + fmt.Fprintln(out, "Usage:") + fmt.Fprintln(out, " gceprovenance verify [flags] Verify quote, host registry, and GCE instance binding") + fmt.Fprintln(out, " gceprovenance verify-host [flags] Verify quote and host registry lookup") + fmt.Fprintln(out, " gceprovenance verify-instance [flags] Verify quote and GCE instance binding") +} diff --git a/tools/gceprovenance/main_test.go b/tools/gceprovenance/main_test.go new file mode 100644 index 0000000..400ba46 --- /dev/null +++ b/tools/gceprovenance/main_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-tdx-guest/gce" +) + +func TestParseCommandFlagsDefaults(t *testing.T) { + cfg, err := parseCommandFlags("gceprovenance verify", nil, io.Discard) + if err != nil { + t.Fatalf("parseCommandFlags() failed: %v", err) + } + if cfg.Bucket != defaultBucketName { + t.Errorf("Bucket = %q, want %q", cfg.Bucket, defaultBucketName) + } + if got := quoteOutputPath(cfg); got != defaultQuoteOutName { + t.Errorf("quoteOutputPath() = %q, want %q", got, defaultQuoteOutName) + } + if got := hostRegistryOutputPath(cfg); got != defaultHostRegistryOutName { + t.Errorf("hostRegistryOutputPath() = %q, want %q", got, defaultHostRegistryOutName) + } +} + +func TestParseCommandFlagsOutDirDefaults(t *testing.T) { + cfg, err := parseCommandFlags("gceprovenance verify", []string{"-out-dir", "/tmp/gceprovenance"}, io.Discard) + if err != nil { + t.Fatalf("parseCommandFlags() failed: %v", err) + } + if got, want := quoteOutputPath(cfg), filepath.Join("/tmp/gceprovenance", defaultQuoteOutName); got != want { + t.Errorf("quoteOutputPath() = %q, want %q", got, want) + } + if got, want := hostRegistryOutputPath(cfg), filepath.Join("/tmp/gceprovenance", defaultHostRegistryOutName); got != want { + t.Errorf("hostRegistryOutputPath() = %q, want %q", got, want) + } +} + +func TestRunCLIRejectsFlagsWithoutCommand(t *testing.T) { + err := runCLI([]string{"-quote", "quote.bin"}, "", io.Discard, io.Discard) + if err == nil { + t.Fatal("runCLI() succeeded, want error") + } + if !strings.Contains(err.Error(), `use "gceprovenance verify"`) { + t.Errorf("runCLI() error = %q, want verify guidance", err.Error()) + } +} + +func TestUnsupportedFlags(t *testing.T) { + for _, name := range []string{ + "ppid", + "get_collateral", + "check_crl", + "trusted_roots", + "timeout", + "max_retry_delay", + "disable_tcb_status_check", + } { + t.Run(name, func(t *testing.T) { + _, err := parseCommandFlags("gceprovenance verify", []string{"-" + name}, io.Discard) + if err == nil { + t.Fatal("parseCommandFlags() succeeded, want error") + } + if want := "flag provided but not defined: -" + name; !strings.Contains(err.Error(), want) { + t.Errorf("parseCommandFlags() error = %q, want %q", err.Error(), want) + } + }) + } +} + +func TestParseChallenge(t *testing.T) { + value := strings.Repeat("ab", 64) + challenge, reportData, err := parseChallenge(value) + if err != nil { + t.Fatalf("parseChallenge() failed: %v", err) + } + if len(challenge) != 64 { + t.Fatalf("challenge length = %d, want 64", len(challenge)) + } + if !bytes.Equal(challenge, reportData[:]) { + t.Fatal("reportData did not receive challenge bytes") + } +} + +func TestParseChallengeRejectsWrongLength(t *testing.T) { + if _, _, err := parseChallenge("abcd"); err == nil { + t.Fatal("parseChallenge() succeeded, want error") + } +} + +func TestWriteQuoteOutputPreservesRawBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "quote.bin") + raw := []byte{0, 1, 2, 3, 4} + got, err := writeQuoteOutput(commandConfig{QuoteOut: path}, raw) + if err != nil { + t.Fatalf("writeQuoteOutput() failed: %v", err) + } + if got != path { + t.Errorf("writeQuoteOutput() path = %q, want %q", got, path) + } + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) failed: %v", path, err) + } + if !bytes.Equal(contents, raw) { + t.Errorf("quote output = %v, want %v", contents, raw) + } +} + +func TestReportedErrorWrapsCause(t *testing.T) { + cause := errors.New("verification failed") + err := &reportedError{err: cause} + if !errors.Is(err, cause) { + t.Fatal("reportedError does not wrap cause") + } +} + +func TestWriteVerifyFailureIsFriendly(t *testing.T) { + var out bytes.Buffer + info := &gce.InstanceInfo{ + Zone: "us-central1-a", + ProjectID: "test-project", + ProjectNumber: 123456789012, + InstanceName: "test-instance", + InstanceID: 112233445566778899, + } + result := &gce.PZIDVerification{ + AttestedMROwnerHX: "0000", + ExpectedMROwnerHX: "abcd", + Payload: `{"raw":"payload"}`, + } + + writeVerifyFailure(&out, info, "abcdef1234567890abcdef1234567890", result, false) + got := out.String() + for _, want := range []string{ + "GCE TDX provenance verification: FAILED", + "Name: test-instance", + "Project: test-project (123456789012)", + "Host registry document: found", + "PZID binding: FAILED", + "Quote MR_OWNER: 0000", + "Expected MR_OWNER: abcd", + } { + if !strings.Contains(got, want) { + t.Errorf("failure output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "payload") { + t.Errorf("failure output included verbose payload without verbose:\n%s", got) + } +} diff --git a/tools/provenance/README.md b/tools/provenance/README.md deleted file mode 100644 index 92cf366..0000000 --- a/tools/provenance/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# `provenance` CLI tool - -This tool fetches a JSON file containing GCP machine info/provenance from a public GCS bucket based on a PPID(Platform Provisioning Id). - -## Building the Tool - -To create the executable, run the following command from the repository root: - -```console -go build ./tools/provenance -``` - -This will create a `provenance` executable in your current directory. - -Alternatively, you can run the tool directly without building an executable: - -```console -go run ./tools/provenance [options...] -``` - -## Usage -The PPID can be determined in three ways: - -### 1. Automatically from the local platform -Requires running inside a supported Confidential VM with TDX and root privileges (`sudo`): -```bash -sudo ./provenance -``` - -### 2. Extracted from a TDX Quote file -Path to a raw binary TDX quote file to extract PPID from. The quote must contain a PCK certificate chain from which the PPID is extracted. - -```bash -./provenance -quote /path/to/quote.bin -``` - -### 3. Provided directly via flag -```bash -./provenance -ppid -``` - -## Optional Flags - -- `-bucket`: The public GCS bucket name to fetch the provenance document from. -- `-out`: Path to output file to write provenance data to. Defaults to stdout. -- `-verbose`: Enable verbose output. -## PPID Resolution Order - -If neither `-ppid` nor `-quote` is provided, the tool will attempt to fetch a TDX quote from the local system. This usually requires root privileges (`sudo`). diff --git a/tools/provenance/main.go b/tools/provenance/main.go deleted file mode 100644 index f17e743..0000000 --- a/tools/provenance/main.go +++ /dev/null @@ -1,189 +0,0 @@ -// Package main implements a CLI tool to fetch provenance data from a GCS bucket using a PPID. -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/http" - "os" - "regexp" - "strings" - "time" - - "github.com/google/go-tdx-guest/abi" - tg "github.com/google/go-tdx-guest/client" - "github.com/google/go-tdx-guest/pcs" - "github.com/google/go-tdx-guest/verify" -) - -const gcsBaseURL = "https://storage.googleapis.com" - -var ( - ppidFlag = flag.String("ppid", "", "PPID as a 32-character hex string") - quoteFileFlag = flag.String("quote", "", "Path to a raw binary TDX quote file to extract PPID from") - bucketNameFlag = flag.String("bucket", "gca-placeholder-bucket", "The public GCS bucket name to fetch the provenance document from") - outputFlag = flag.String("out", "", "Path to output file to write provenance data to. Default is stdout.") - verboseFlag = flag.Bool("verbose", false, "Enable verbose output") - - validPPID = regexp.MustCompile(`^[a-fA-F0-9]{32}$`) - validBucket = regexp.MustCompile(`^[a-z0-9_.-]{3,63}$`) -) - -func debugf(format string, a ...any) { - if *verboseFlag { - fmt.Fprintf(os.Stderr, format, a...) - } -} - -// getPPIDFromQuote extracts the PPID from the PCK certificate embedded in the TDX quote. -// The quote must be a proto type accepted by verify.ExtractChainFromQuote (*pb.QuoteV4 or *pb.QuoteV5); -// raw bytes must be parsed via abi.QuoteToProto first. -func getPPIDFromQuote(quote any) (string, error) { - chain, err := verify.ExtractChainFromQuote(quote) - if err != nil { - return "", fmt.Errorf("could not extract PCK certificate chain from quote: %w", err) - } - if chain == nil || chain.PCKCertificate == nil { - return "", errors.New("PCK certificate is missing in the quote") - } - exts, err := pcs.PckCertificateExtensions(chain.PCKCertificate) - if err != nil { - return "", fmt.Errorf("could not extract PCK extensions: %w", err) - } - if exts.PPID == "" { - return "", errors.New("PPID is empty in PCK extensions") - } - return exts.PPID, nil -} - -func resolvePPID() (string, error) { - if *ppidFlag != "" { - return *ppidFlag, nil - } - if *quoteFileFlag != "" { - quoteBytes, err := os.ReadFile(*quoteFileFlag) - if err != nil { - return "", fmt.Errorf("failed to read quote file at %s: %w", *quoteFileFlag, err) - } - quoteProto, err := abi.QuoteToProto(quoteBytes) - if err != nil { - return "", fmt.Errorf("failed to parse quote bytes: %w", err) - } - ppid, err := getPPIDFromQuote(quoteProto) - if err != nil { - return "", fmt.Errorf("failed to extract PPID from quote file: %w", err) - } - return ppid, nil - } - - qp, err := tg.GetQuoteProvider() - if err != nil { - return "", fmt.Errorf("failed to get quote provider: %w", err) - } - if err := qp.IsSupported(); err != nil { - return "", fmt.Errorf("tdx quote provider not supported on this platform: %w", err) - } - - var tdxNonce [64]byte - quote, err := tg.GetQuote(qp, tdxNonce) - if err != nil { - return "", fmt.Errorf("failed to fetch local TDX quote: %w", err) - } - ppid, err := getPPIDFromQuote(quote) - if err != nil { - return "", fmt.Errorf("failed to extract PPID from local quote: %w", err) - } - return ppid, nil -} - -func fetchProvenanceData(baseURL, ppid, bucket string) ([]byte, error) { - if !validPPID.MatchString(ppid) { - return nil, errors.New("invalid PPID format") - } - if !validBucket.MatchString(bucket) { - return nil, errors.New("invalid bucket name format") - } - - debugf("Using PPID: %s\n", ppid) - debugf("Using GCS Bucket: %s\n", bucket) - - url := fmt.Sprintf("%s/%s/%s.json", baseURL, bucket, ppid) - debugf("Fetching from URL: %s\n", url) - - client := &http.Client{ - Timeout: 30 * time.Second, - } - resp, err := client.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch from GCS: %w", err) - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, parseGCSError(resp, bodyBytes, ppid, bucket) - } - - if !json.Valid(bodyBytes) { - return nil, errors.New("received invalid JSON from GCS") - } - - return bodyBytes, nil -} - -func parseGCSError(resp *http.Response, bodyBytes []byte, ppid string, bucket string) error { - bodyStr := string(bodyBytes) - if strings.Contains(bodyStr, "NoSuchBucket") { - return fmt.Errorf("gcs request failed: bucket '%s' not found", bucket) - } - if strings.Contains(bodyStr, "NoSuchKey") { - return fmt.Errorf("gcs request failed: file '%s.json' not found in bucket '%s'", ppid, bucket) - } - if resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("gcs request failed: access denied to bucket '%s' (403 Forbidden); the bucket may be private or not exist", bucket) - } - return fmt.Errorf("gcs request failed with status: %s", resp.Status) -} - -func run(baseURL string) error { - ppid, err := resolvePPID() - if err != nil { - return err - } - - bodyBytes, err := fetchProvenanceData(baseURL, ppid, *bucketNameFlag) - if err != nil { - return err - } - - out := os.Stdout - if *outputFlag != "" { - f, err := os.Create(*outputFlag) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer f.Close() - out = f - } - - if _, err := out.Write(bodyBytes); err != nil { - return fmt.Errorf("failed to write output: %w", err) - } - - return nil -} - -func main() { - flag.Parse() - if err := run(gcsBaseURL); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/tools/provenance/main_test.go b/tools/provenance/main_test.go deleted file mode 100644 index 1116645..0000000 --- a/tools/provenance/main_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" -) - -func TestProvenance(t *testing.T) { - tests := []struct { - name string - ppid string - bucket string - serverResponse string - serverStatus int - validOutput bool - wantErr string - }{ - { - name: "Direct PPID Success", - ppid: "abcdef1234567890abcdef1234567890", - bucket: "test-bucket", - serverResponse: `{"location": "us-east1"}`, - serverStatus: http.StatusOK, - validOutput: true, - }, - { - name: "No Such Bucket", - ppid: "abcdef1234567890abcdef1234567890", - bucket: "missing-bucket", - serverResponse: "NoSuchBucket", - serverStatus: http.StatusNotFound, - wantErr: "gcs request failed: bucket 'missing-bucket' not found", - }, - { - name: "No Such Key", - ppid: "0000000000000000000000000000000a", - bucket: "test-bucket", - serverResponse: "NoSuchKey", - serverStatus: http.StatusNotFound, - wantErr: "gcs request failed: file '0000000000000000000000000000000a.json' not found in bucket 'test-bucket'", - }, - { - name: "Server Error", - ppid: "abcdef1234567890abcdef1234567890", - bucket: "test-bucket", - serverResponse: "internal error", - serverStatus: http.StatusInternalServerError, - wantErr: "gcs request failed with status: 500 Internal Server Error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - expectedPath := fmt.Sprintf("/%s/%s.json", tt.bucket, tt.ppid) - if r.URL.Path != expectedPath { - t.Errorf("expected path %q, got %q", expectedPath, r.URL.Path) - http.Error(w, "not found", http.StatusNotFound) - return - } - w.WriteHeader(tt.serverStatus) - fmt.Fprint(w, tt.serverResponse) - })) - defer ts.Close() - - oldPpid := *ppidFlag - oldBucket := *bucketNameFlag - oldOut := *outputFlag - t.Cleanup(func() { - *ppidFlag = oldPpid - *bucketNameFlag = oldBucket - *outputFlag = oldOut - }) - - *ppidFlag = tt.ppid - *bucketNameFlag = tt.bucket - - outFile := filepath.Join(t.TempDir(), "output.json") - *outputFlag = outFile - - err := run(ts.URL) - - if tt.wantErr != "" { - if err == nil { - t.Fatalf("expected error, got nil") - } - if err.Error() != tt.wantErr { - t.Errorf("expected error %q, got %q", tt.wantErr, err.Error()) - } - } else { - if err != nil { - t.Fatalf("run failed: %v", err) - } - if tt.validOutput { - b, err := os.ReadFile(outFile) - if err != nil { - t.Fatalf("failed to read output file: %v", err) - } - if string(b) != tt.serverResponse { - t.Errorf("expected %s, got %s", tt.serverResponse, string(b)) - } - } - } - }) - } -} diff --git a/verify/verify.go b/verify/verify.go index 259ae1e..1bb43e6 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -891,7 +891,7 @@ func verifyPCKCertificationChain(options *Options) error { func x509Options(trustedRoots *x509.CertPool, intermediateCert *x509.Certificate, now time.Time) x509.VerifyOptions { if trustedRoots == nil { - logger.Warning("Using embedded Intel certificate for TDX attestation root of trust") + logger.V(1).Info("Using embedded Intel certificate for TDX attestation root of trust") trustedRoots = x509.NewCertPool() trustedRoots.AddCert(trustedRootCertificate) }