אימות (attestation) מרחוק של vTPM למכונה וירטואלית סודית

1. סקירה כללית

מכונות וירטואליות סודיות (CVM) הן סוג של מכונות וירטואליות של Compute Engine שמשתמשות בהצפנת זיכרון מבוססת-חומרה. כך אפשר להבטיח שלא ניתן יהיה לקרוא או לשנות את הנתונים והאפליקציות בזמן השימוש בהם. בקודלאב הזה תלמדו איך לבצע אימות מרחוק שמאפשר לצד מרוחק לאמת את צמתים של CVM. בנוסף, תלמדו איך להשתמש ביומני Cloud לצורך ביקורת נוספת.

fcc043c716119bd6.png

כפי שמוצג בתרשים שלמעלה, סדנת הקוד הזו כוללת את השלבים הבאים:

  • הגדרת CVM ואימות מרחוק
  • ניתוח ב-Cloud Logging של האימות (attestation) מרחוק ב-CVM
  • הגדרת שרת אינטרנט להפצה של סוד

הרכיבים שמוצגים באיור שלמעלה כוללים את הכלי go-tpm ואת Google Cloud Attestation:

  • הכלי go-tpm: כלי בקוד פתוח לאחזור הוכחת אימות מ-vTPM (מודול פלטפורמה מהימנה וירטואלי) ב-CVM ולשליחה אל Google Cloud Attestation לקבלת אסימון אימות.
  • אימות (attestation) ב-Google Cloud: אימות ותיקוף של הוכחת האימות שהתקבלה, והחזרת אסימון אימות שמשקף את העובדות לגבי ה-CVM של מבקש הבקשה.

מה תלמדו

  • איך מבצעים אימות מרחוק באמצעות Confidential Computing APIs ב-CVM
  • איך משתמשים ב-Cloud Logging כדי לעקוב אחרי אימות מרחוק של CVM

מה נדרש

2. הגדרה ודרישות

כדי להפעיל את ממשקי ה-API הנדרשים, מריצים את הפקודה הבאה במסוף Cloud או בסביבת הפיתוח המקומית:

gcloud auth login

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

3. הגדרת אימות מרחוק של CVM ו-vTPM

בשלב הזה, תיצרו מכונה וירטואלית מאובטחת ותבצעו אימות מרחוק של vTPM כדי לאחזר אסימון אימות (אסימון OIDC) במכונה הווירטואלית המאובטחת.

נכנסים למסוף Cloud או לסביבת הפיתוח המקומית. יוצרים מכונה וירטואלית סודית (CVM) באופן הבא (מידע נוסף זמין במאמר יצירת מכונה וירטואלית סודית | Google Cloud). צריך היקפים כדי לגשת לממשקי API של Confidential Computing.

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 API (ל-CVMs נדרשות ההרשאות הבאות כדי לאחזר אסימון אימות מממשקי Confidential Computing API):

1) יוצרים תפקיד שמאפשר גישה לממשקי API של Confidential Computing.

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). מוסיפים לתפקיד את חשבון השירות שמוגדר כברירת מחדל במכונה הווירטואלית.

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 לאחזור אסימון אימות (attestation) מממשקי ה-API של Confidential Computing שסופקו על ידי Google Cloud Attestation.

  1. מתחברים ל-CVM.
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. הכלי הבינארי של 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 מאמת את הוכחת האימות ומחזיר אסימון אימות. הפקודה יוצרת קובץ attestation_token שמכיל את attestation-token. בהמשך, תשתמשו ב-attestation-token כדי לאחזר סוד. אפשר לפענח את אסימון האימות בכתובת jwt.io כדי להציג את ההצהרות.
sudo ./gotpm token > attestation_token
  1. (אופציונלי) לחלופין, כדי לבצע אימות מרחוק באמצעות הכלי go-tpm ו-Google Cloud Attestation, אנחנו מציגים את הפקודות לאחזור הוכחת אימות של vTPM. כך תוכלו ליצור שירות כמו 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. הפעלת Cloud Logging ובדיקה של יומן האימות מרחוק

אפשר להריץ את הפקודה הבאה ב-CVM של cvm-attestation-codelab. הפעם, הפעילות מתועדת ביומן של Cloud Logging.

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 Logging, פותחים את כתובת ה-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 הוא המזהה הייחודי של המכונה.

אמורים להופיע אסימון האימות, ההצהרות שלו, הראיות הגולמיות וה-nonce שנשלחו לאימות ב-Google Cloud.

5. הגדרת שרת אינטרנט להפצה של סודות

בשלב הזה יוצאים מסשן ה-SSH הקודם ומגדירים מכונה וירטואלית נוספת. במכונה הווירטואלית הזו תגדירו שרת אינטרנט לגרסת build סודית. שרת האינטרנט מאמת את אסימון האימות וההצהרות שלו. אם התיקוף מצליח, הסוד ישוחרר למבקש.

1) נכנסים למסוף Cloud או לסביבת הפיתוח המקומית. יוצרים מכונה וירטואלית.

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 או סשן נוסף בסביבת הפיתוח המקומית ומריצים את הפקודה הבאה. כך תקבלו את <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.

הכיתוב 'This is the super secret information!' יופיע במסך.

אם מזינים attestation-token שגוי או שפג התוקף שלו, תוצג ההודעה "curl: (52) Empty reply from server". יופיע גם הכיתוב 'Token valid: false' ביומן של שרת האינטרנט של הגרסה הסודית במכונה הווירטואלית cvm-attestation-codelab-web-server.

6. הסרת המשאבים

מריצים את הפקודות הבאות במסוף Cloud או בסביבת הפיתוח המקומית:

# 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