مصادقة vTPM عن بُعد على جهاز افتراضي سري

1. نظرة عامة

الأجهزة الافتراضية السرية (CVM) هي نوع من الأجهزة الافتراضية في Compute Engine التي تستخدم تشفير الذاكرة المستند إلى الأجهزة. يساعد ذلك في ضمان عدم إمكانية قراءة بياناتك وتطبيقاتك أو تعديلها أثناء استخدامها. في هذا الدليل التعليمي، يتم عرض عملية إثبات الهوية عن بُعد التي تسمح لجهة خارجية بالتحقّق من عقد CVM. بالإضافة إلى ذلك، ستستكشف تسجيلات السحابة الإلكترونية لإجراء المزيد من عمليات التدقيق.

fcc043c716119bd6.png

كما هو موضّح في الشكل أعلاه، يتضمّن هذا الدليل التعليمي للترميز الخطوات التالية:

  • إعداد CVM والتأكيد عن بُعد
  • استكشاف "تسجيل الدخول إلى السحابة الإلكترونية" في عملية المصادقة عن بُعد لوحدة التحكّم في حدود الحساب
  • إعداد خادم الويب لإصدارات التطبيقات السرية

تشمل المكوّنات في الشكل أعلاه أداة go-tpm وGoogle Cloud Attestation:

  • أداة go-tpm: أداة مفتوحة المصدر لجلب دليل الإثبات من وحدة vTPM (وحدة النظام الأساسي الموثوق الافتراضي) على وحدة التحكّم في حدود الحساب وإرساله إلى Google Cloud Attestation للحصول على رمز تعريف إثبات الهوية.
  • شهادة Google Cloud: التحقّق من صحة دليل الشهادة الذي تم استلامه وإرجاع رمز شهادة يعكس الحقائق حول CVM للمطلوب منه

المُعطيات

المتطلبات

2. الإعداد والمتطلبات

لتفعيل واجهات برمجة التطبيقات اللازمة، نفِّذ الأمر التالي في وحدة تحكّم السحابة الإلكترونية أو بيئة التطوير المحلية:

gcloud auth login

gcloud services enable \
    cloudapis.googleapis.com \
    cloudshell.googleapis.com \
    confidentialcomputing.googleapis.com \
    compute.googleapis.com

3- إعداد ميزة التأكيد عن بُعد لوحدة التحكّم في إدارة الاعتماد (CVM) ووحدة التحكّم في إدارة الاعتماد الافتراضي (vTPM)

في هذه الخطوة، ستنشئ جهاز CVM وستُجري عملية إثبات الهوية عن بُعد باستخدام وحدة إدارة الTrusted Platform Module (vTPM) لاسترداد رمز إثبات الهوية (رمز OIDC) على جهاز CVM.

انتقِل إلى وحدة تحكّم السحابة الإلكترونية أو بيئة التطوير المحلية. أنشئ جهاز CVM على النحو التالي (راجِع مقالة إنشاء مثيل جهاز افتراضي مُعتمَد للبيانات السرية | Google Cloud). يجب توفّر النطاقات للوصول إلى واجهات برمجة التطبيقات Confidential Computing API.

gcloud config set project <project-id>

gcloud compute instances create cvm-attestation-codelab \
    --machine-type=n2d-standard-2 \
    --min-cpu-platform="AMD Milan" \
    --zone=us-central1-c \
    --confidential-compute \
    --image=ubuntu-2204-jammy-v20240228 \
    --image-project=ubuntu-os-cloud \
    --scopes https://www.googleapis.com/auth/cloud-platform

عليك تفويض حساب الخدمة التلقائي لوحدة CVM للوصول إلى واجهات برمجة تطبيقات Confidential Computing (تحتاج وحدات CVM إلى الأذونات التالية لجلب الرمز المميّز لإثبات الهوية من واجهات برمجة تطبيقات Confidential Computing):

‫1. أنشئ دورًا للسماح بالوصول إلى واجهات برمجة التطبيقات في ميزة "الحوسبة السرية".

gcloud iam roles create Confidential_Computing_User --project=<project-id> \
    --title="CVM User" --description="Grants the ability to generate an attestation token in a CVM." \
 --permissions="confidentialcomputing.challenges.create,confidentialcomputing.challenges.verify,confidentialcomputing.locations.get,confidentialcomputing.locations.list" --stage=GA

غيِّر القيم في السلسلة على الشكل التالي:

  • project-id هو المعرّف الفريد للمشروع.

‫2. أضِف حساب الخدمة التلقائي لوحدة VM إلى الدور.

gcloud projects add-iam-policy-binding <project-id> \
    --member serviceAccount:$(gcloud iam service-accounts list --filter="email ~ compute@developer.gserviceaccount.com$" --format='value(email)'
) \
    --role "projects/<project-id>/roles/Confidential_Computing_User"

غيِّر القيم في السلسلة على الشكل التالي:

  • project-id هو المعرّف الفريد للمشروع.

‫3. اتصل بوحدة التحكّم في الأجهزة (CVM) وإعداد ملف go-tpm الثنائي لأداة جلب رمز تعريف المصادقة من واجهات برمجة تطبيقات الحوسبة السرية التي تقدّمها خدمة "المصادقة في Google Cloud".

  1. اتّصل بوحدة التحكّم في الشبكة.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. إعداد بيئة Go:
wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
  1. أنشئ ملف go-tpm.bin للأداة. تُستخدَم الأداة الثنائية go-tpm لجلب دليل الإثبات من وحدة التحكّم في إدارة التأمين الافتراضي (vTPM) على وحدة التحكّم في حدود الجلسة (CVM) وإرساله إلى Google Cloud Attestation للحصول على رمز تعريف إثبات الهوية.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. يستخرج أمر أداة go-tpm دليل إثبات مصادقة وحدة التحكّم في الذاكرة (CVM) من وحدة التحكّم في الذاكرة الظاهرية (vTPM) ويرسله إلى أداة Google Cloud Attestation. تتحقّق ميزة "إثبات الهوية" في Google Cloud من دليل إثبات الهوية وتُجري فحصًا له، ثم تعرِض رمز إثبات الهوية. ينشئ الأمر ملف attestation_token يحتوي على attestation-token. ستستخدم attestation-token لطلب الحصول على سر لاحقًا. يمكنك فك ترميز الرمز المميّز لإثبات الهوية في jwt.io للاطّلاع على المطالب.
sudo ./gotpm token > attestation_token
  1. (اختياري) بدلاً من إجراء عملية إثبات الهوية عن بُعد باستخدام أداة go-tpm وGoogle Cloud Attestation، نقدّم الأوامر للحصول على دليل إثبات الهوية لوحدة التحكّم في Trusted Platform Module (TPM) الافتراضية. بهذه الطريقة، يمكنك إنشاء خدمة مثل Google Cloud Attestation للتحقّق من مستند إثبات الهوية والتحقق من صحته:
nonce=$(head -c 16 /dev/urandom | xxd -p)
sudo ./gotpm attest --nonce $nonce --format textproto --output quote.dat
sudo ./gotpm verify debug --nonce $nonce --format textproto --input quote.dat --output vtpm_report

يحتوي vtpm_report على سجلّ الأحداث الذي تم التحقّق منه. يمكنك استخدام المحرِّر المفضّل لديك للاطّلاع عليه. يُرجى العلم أنّ الأمر verify لا يتحقّق من شهادة مفتاح الإثبات في الاقتباس.

4. تفعيل ميزة "تسجيل في السحابة الإلكترونية" واستكشاف سجلّ المصادقة عن بُعد

يمكنك تنفيذ الأمر التالي في جهاز الكمبيوتر الافتراضي cvm-attestation-codelab. وهذه المرة، يتم تسجيل النشاط في سجلّات التسجيل في السحابة الإلكترونية.

sudo ./gotpm token --cloud-log --audience "https://api.cvm-attestation-codelab.com"

احصل على cvm-attestation-codelab <instance-id> في وحدة تحكّم السحابة الإلكترونية أو بيئة التطوير المحلية.

gcloud compute instances describe cvm-attestation-codelab --zone us-central1-c --format='value(id)'

لاستكشاف ميزة "تسجيلات Cloud"، افتح عنوان URL التالي: https://console.cloud.google.com/logs. في حقل الطلب، أدخِل ما يلي:

resource.type="gce_instance" resource.labels.zone="us-central1-c" resource.labels.instance_id=<instance-id> log_name="projects/<project-id>/logs/gotpm" severity>=DEFAULT

غيِّر القيم في السلسلة على الشكل التالي:

  • project-id هو المعرّف الفريد للمشروع.
  • instance-id هو المعرّف الفريد للنسخة.

من المفترض أن تتمكّن من العثور على رمز شهادة الاعتماد وبيانات المطالبة والأدلة الأوّلية والمفتاح العشوائي المرسَلَين إلى Google Cloud Attestation.

5- إعداد خادم ويب لإصدار سرّي

في هذه الخطوة، ستخرج من جلسة SSH السابقة وتُعدّ جهازًا افتراضيًا آخر. على هذا الجهاز الظاهري، يمكنك إعداد خادم ويب لإصدار سرّي. يتحقق خادم الويب من صحة رمز شهادة الاعتماد المستلَم وبياناته. إذا نجحت عمليات التحقّق، يتم إتاحة الرمز السري لمقدّم الطلب.

‫1. انتقِل إلى وحدة تحكّم السحابة الإلكترونية أو بيئة التطوير المحلية. أنشئ جهازًا افتراضيًا.

gcloud config set project <project-id>

gcloud compute instances create cvm-attestation-codelab-web-server \
    --machine-type=n2d-standard-2 \
    --zone=us-central1-c \
    --image=ubuntu-2204-jammy-v20240228 \
    --image-project=ubuntu-os-cloud

غيِّر القيم في السلسلة على الشكل التالي:

  • project-id هو المعرّف الفريد للمشروع.

‫2. استخدام بروتوكول النقل الآمن (SSH) للاتصال بجهازك الافتراضي الجديد

gcloud compute ssh --zone us-central1-c cvm-attestation-codelab-web-server

‫3. إعداد بيئة Go

wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

4). أنشئ الملفَّين التاليَّين لتخزين رمز المصدر لخادم الويب الخاص بالإصدار السري (نسخ/لصق باستخدام nano).

main.go

package main

import (
        "fmt"
        "net/http"
        "strings"
        "time"
        "log"

        "github.com/golang-jwt/jwt/v4"
)

const (
        theSecret = "This is the super secret information!"
)

func homePage(w http.ResponseWriter, r *http.Request) {
        tokenString := r.Header.Get("Authorization")
        if tokenString != "" {
                tokenString, err := extractToken(tokenString)
                if err != nil {
                        http.Error(w, err.Error(), http.StatusUnauthorized)
                }

                tokenBytes := []byte(tokenString)
                // A method to return a public key from the well-known endpoint
                keyFunc := getRSAPublicKeyFromJWKsFile

                token, err := decodeAndValidateToken(tokenBytes, keyFunc)
                if err != nil {
                        http.Error(w, "Invalid JWT Token", http.StatusUnauthorized)
                }

                if ok, err := isValid(token.Claims.(jwt.MapClaims)); ok {
                        fmt.Fprintln(w, theSecret)
                } else {
                        if err != nil {
                                http.Error(w, "Error validating JWT claims: "+err.Error(), http.StatusUnauthorized)
                        } else {
                                http.Error(w, "Invalid JWT token Claims", http.StatusUnauthorized)
                        }
                }
        } else {
                http.Error(w, "Authorization token required", http.StatusUnauthorized)
        }
}

func extractToken(tokenString string) (string, error) {
        if strings.HasPrefix(tokenString, "Bearer ") {
                return strings.TrimPrefix(tokenString, "Bearer "), nil
        }
        return "", fmt.Errorf("invalid token format")
}

func isValid(claims jwt.MapClaims) (bool, error) {
        // 1. Evaluating Standard Claims:
        subject, ok := claims["sub"].(string)
        if !ok {
                return false, fmt.Errorf("missing or invalid 'sub' claim")
        }
        fmt.Println("Subject:", subject)
        // e.g. "sub":"https://www.googleapis.com/compute/v1/projects/<project_id>/zones/<project_zone>/instances/<instance_name>"

        issuedAt, ok := claims["iat"].(float64)
        if !ok {
                return false, fmt.Errorf("missing or invalid 'iat' claim")
        }
        fmt.Println("Issued At:", time.Unix(int64(issuedAt), 0))

        // 2. Evaluating Remote Attestation Claims:
        hwModel, ok := claims["hwmodel"].(string)
        if !ok || hwModel != "GCP_AMD_SEV" {
                return false, fmt.Errorf("missing or invalid 'hwModel'")
        }
        fmt.Println("hwmodel:", hwModel)

        swName, ok := claims["swname"].(string)
        if !ok || swName != "GCE" {
                return false, fmt.Errorf("missing or invalid 'hwModel'")
        }
        fmt.Println("swname:", swName)

        return true, nil
}

func main() {
        http.HandleFunc("/", homePage)
        fmt.Println("Server listening on :8080")
        err := http.ListenAndServe(":8080", nil)
        if err != nil {
                log.Fatalf("Server failed to start: %v", err)
        }
}

helper.go

package main

import (
        "crypto/rsa"
        "encoding/base64"
        "encoding/json"
        "errors"
        "fmt"
        "io"
        "math/big"
        "net/http"

        "github.com/golang-jwt/jwt/v4"
)

const (
        socketPath     = "/run/container_launcher/teeserver.sock"
        expectedIssuer = "https://confidentialcomputing.googleapis.com"
        wellKnownPath  = "/.well-known/openid-configuration"
)

type jwksFile struct {
        Keys []jwk `json:"keys"`
}

type jwk struct {
        N   string `json:"n"`   // "nMMTBwJ7H6Id8zUCZd-L7uoNyz9b7lvoyse9izD9l2rtOhWLWbiG-7pKeYJyHeEpilHP4KdQMfUo8JCwhd-OMW0be_XtEu3jXEFjuq2YnPSPFk326eTfENtUc6qJohyMnfKkcOcY_kTE11jM81-fsqtBKjO_KiSkcmAO4wJJb8pHOjue3JCP09ZANL1uN4TuxbM2ibcyf25ODt3WQn54SRQTV0wn098Y5VDU-dzyeKYBNfL14iP0LiXBRfHd4YtEaGV9SBUuVhXdhx1eF0efztCNNz0GSLS2AEPLQduVuFoUImP4s51YdO9TPeeQ3hI8aGpOdC0syxmZ7LsL0rHE1Q",
        E   string `json:"e"`   // "AQAB" or 65537 as an int
        Kid string `json:"kid"` // "1f12fa916c3a0ef585894b4b420ad17dc9d6cdf5",

        // Unused fields:
        // Alg string `json:"alg"` // "RS256",
        // Kty string `json:"kty"` // "RSA",
        // Use string `json:"use"` // "sig",
}

type wellKnown struct {
        JwksURI string `json:"jwks_uri"` // "https://www.googleapis.com/service_accounts/v1/metadata/jwk/signer@confidentialspace-sign.iam.gserviceaccount.com"

        // Unused fields:
        // Iss                                   string `json:"issuer"`                                // "https://confidentialcomputing.googleapis.com"
        // Subject_types_supported               string `json:"subject_types_supported"`               // [ "public" ]
        // Response_types_supported              string `json:"response_types_supported"`              // [ "id_token" ]
        // Claims_supported                      string `json:"claims_supported"`                      // [ "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "dbgstat", "eat_nonce", "google_service_accounts", "hwmodel", "oemid", "secboot", "submods", "swname", "swversion" ]
        // Id_token_signing_alg_values_supported string `json:"id_token_signing_alg_values_supported"` // [ "RS256" ]
        // Scopes_supported                      string `json:"scopes_supported"`                      // [ "openid" ]
}

func getWellKnownFile() (wellKnown, error) {
        httpClient := http.Client{}
        resp, err := httpClient.Get(expectedIssuer + wellKnownPath)
        if err != nil {
                return wellKnown{}, fmt.Errorf("failed to get raw .well-known response: %w", err)
        }

        wellKnownJSON, err := io.ReadAll(resp.Body)
        if err != nil {
                return wellKnown{}, fmt.Errorf("failed to read .well-known response: %w", err)
        }

        wk := wellKnown{}
        json.Unmarshal(wellKnownJSON, &wk)
        return wk, nil
}

func getJWKFile() (jwksFile, error) {
        wk, err := getWellKnownFile()
        if err != nil {
                return jwksFile{}, fmt.Errorf("failed to get .well-known json: %w", err)
        }

        // Get JWK URI from .wellknown
        uri := wk.JwksURI
        fmt.Printf("jwks URI: %v\n", uri)

        httpClient := http.Client{}
        resp, err := httpClient.Get(uri)
        if err != nil {
                return jwksFile{}, fmt.Errorf("failed to get raw JWK response: %w", err)
        }

        jwkbytes, err := io.ReadAll(resp.Body)
        if err != nil {
                return jwksFile{}, fmt.Errorf("failed to read JWK body: %w", err)
        }

        file := jwksFile{}
        err = json.Unmarshal(jwkbytes, &file)
        if err != nil {
                return jwksFile{}, fmt.Errorf("failed to unmarshall JWK content: %w", err)
        }

        return file, nil
}

// N and E are 'base64urlUInt' encoded: https://www.rfc-editor.org/rfc/rfc7518#section-6.3
func base64urlUIntDecode(s string) (*big.Int, error) {
        b, err := base64.RawURLEncoding.DecodeString(s)
        if err != nil {
                return nil, err
        }
        z := new(big.Int)
        z.SetBytes(b)
        return z, nil
}

func getRSAPublicKeyFromJWKsFile(t *jwt.Token) (any, error) {
        keysfile, err := getJWKFile()
        if err != nil {
                return nil, fmt.Errorf("failed to fetch the JWK file: %w", err)
        }

        // Multiple keys are present in this endpoint to allow for key rotation.
        // This method finds the key that was used for signing to pass to the validator.
        kid := t.Header["kid"]
        for _, key := range keysfile.Keys {
                if key.Kid != kid {
                        continue // Select the key used for signing
                }

                n, err := base64urlUIntDecode(key.N)
                if err != nil {
                        return nil, fmt.Errorf("failed to decode key.N %w", err)
                }
                e, err := base64urlUIntDecode(key.E)
                if err != nil {
                        return nil, fmt.Errorf("failed to decode key.E %w", err)
                }

                // The parser expects an rsa.PublicKey: https://github.com/golang-jwt/jwt/blob/main/rsa.go#L53
                // or an array of keys. We chose to show passing a single key in this example as its possible
                // not all validators accept multiple keys for validation.
                return &rsa.PublicKey{
                        N: n,
                        E: int(e.Int64()),
                }, nil
        }

        return nil, fmt.Errorf("failed to find key with kid '%v' from well-known endpoint", kid)
}

func decodeAndValidateToken(tokenBytes []byte, keyFunc func(t *jwt.Token) (any, error)) (*jwt.Token, error) {
        var err error
        fmt.Println("Unmarshalling token and checking its validity...")
        token, err := jwt.NewParser().Parse(string(tokenBytes), keyFunc)

        fmt.Printf("Token valid: %v\n", token.Valid)
        if token.Valid {
                return token, nil
        }
        if ve, ok := err.(*jwt.ValidationError); ok {
                if ve.Errors&jwt.ValidationErrorMalformed != 0 {
                        return nil, fmt.Errorf("token format invalid. Please contact the Confidential Space team for assistance")
                }
                if ve.Errors&(jwt.ValidationErrorNotValidYet) != 0 {
                        // If device time is not synchronized with the Attestation Service you may need to account for that here.
                        return nil, errors.New("token is not active yet")
                }
                if ve.Errors&(jwt.ValidationErrorExpired) != 0 {
                        return nil, fmt.Errorf("token is expired")
                }
                return nil, fmt.Errorf("unknown validation error: %v", err)
        }

        return nil, fmt.Errorf("couldn't handle this token or couldn't read a validation error: %v", err)
}

5). شغِّل الأوامر التالية لإنشاء خادم الويب وتشغيله. يؤدي ذلك إلى بدء تشغيل خادم الويب للإصدار السري على المنفذ :8080.

go mod init google.com/codelab
go mod tidy
go get github.com/golang-jwt/jwt/v4
go build
./codelab

تحديد المشاكل وحلّها: قد يظهر لك التحذير التالي الذي يمكن تجاهله عند التشغيل go mod tidy:

go: finding module for package github.com/golang-jwt/jwt/v4
go: downloading github.com/golang-jwt/jwt v3.2.2+incompatible
go: downloading github.com/golang-jwt/jwt/v4 v4.5.0
go: found github.com/golang-jwt/jwt/v4 in github.com/golang-jwt/jwt/v4 v4.5.0
go: google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible: import path "google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible" should not have @version
go: google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/cmd/jwt: import path "google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/cmd/jwt" should not have @version
go: google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/request: import path "google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/request" should not have @version
go: google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/test: import path "google.com/codelab/go/pkg/mod/github.com/golang-jwt/jwt@v3.2.2+incompatible/test" should not have @version

6). الآن، افتح علامة تبويب أخرى في Cloud Console أو جلسة بيئة تطوير محلية ونفِّذ الأمر التالي. سيؤدي ذلك إلى الحصول على <cvm-attestation-codelab-web-server-internal-ip>.

gcloud compute instances describe cvm-attestation-codelab-web-server --format='get(networkInterfaces[0].networkIP)' --zone=us-central1-c

7) اتصل بمثيل الجهاز الافتراضي cvm-attestation-codelab باستخدام بروتوكول SSH.

gcloud compute ssh --zone "us-central1-c" "cvm-attestation-codelab"

8). يستبدل الأمر التالي الرمز attestation-token الذي تم الحصول عليه سابقًا (ضمن ~/go-tpm-tools/cmd/gotpm/). سيؤدي ذلك إلى جلب المفتاح السري الذي يحتفظ به خادم الويب الخاص بالإصدار السري.

cd ~/go-tpm-tools/cmd/gotpm/
curl http://<cvm-attestation-codelab-web-server-internal-ip>:8080 -H "Authorization: Bearer $(cat ./attestation_token)"

غيِّر القيم في السلسلة على الشكل التالي:

  • cvm-attestation-codelab-web-server-internal-ip هو عنوان IP الداخلي لمثيل الجهاز الافتراضي cvm-attestation-codelab-web-server.

ستظهر لك الرسالة "هذه هي المعلومات السرية للغاية" على الشاشة.

إذا أدخلت attestation-token غير صحيح أو منتهي الصلاحية، ستظهر لك رسالة الخطأ "curl: (52) Empty reply from server". سيظهر لك أيضًا "صحة الرمز المميّز: خطأ" في سجلّ خادم الويب الخاص بالإصدار السري في مثيل الجهاز الظاهري cvm-attestation-codelab-web-server.

6- تنظيف

نفِّذ الأوامر التالية في وحدة تحكّم السحابة الإلكترونية أو بيئة التطوير المحلية:

# Delete the role binding
gcloud projects remove-iam-policy-binding <project-id> \
    --member serviceAccount:$(gcloud iam service-accounts list --filter="email ~ compute@developer.gserviceaccount.com$" --format='value(email)'
) \
    --role "projects/<project-id>/roles/Confidential_Computing_User"

# Delete the role
gcloud iam roles delete Confidential_Computing_User --project=<project-id>

# Delete the web server VM instance
gcloud compute instances delete cvm-attestation-codelab-web-server --zone=us-central1-c

# Delete the CVM instance
gcloud compute instances delete cvm-attestation-codelab --zone=us-central1-c

غيِّر القيم في السلسلة على الشكل التالي:

  • project-id هو المعرّف الفريد للمشروع.

7- الخطوات التالية

مزيد من المعلومات عن "أجهزة افتراضية سرية" وCompute Engine