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

כפי שמוצג באיור שלמעלה, ה-codelab הזה כולל את השלבים הבאים:
- הגדרה של CVM והזדהות מרחוק
- ניתוח ב-Cloud Logging של אימות מרחוק של CVM
- הגדרה של שרת אינטרנט להפצת סודות
הרכיבים באיור שלמעלה כוללים את הכלי go-tpm ואת Google Cloud Attestation:
- הכלי go-tpm: כלי בקוד פתוח לאחזור ראיות אימות מ-vTPM (מודול פלטפורמה וירטואלית מהימנה) במכונת ה-CVM ושליחתן ל-Google Cloud Attestation כדי לקבל אסימון אימות.
- Google Cloud Attestation: אימות של נתוני האימות שהתקבלו והחזרת אסימון אימות שמשקף את העובדות לגבי ה-CVM של מגיש הבקשה.
מה תלמדו
- איך מבצעים הזדהות מרחוק באמצעות ממשקי Confidential Computing API ב-CVM
- איך משתמשים ב-Cloud Logging כדי לעקוב אחרי אימות מרחוק של CVM
הדרישות
- פרויקט ב-Google Cloud Platform
- דפדפן, כמו Chrome או Firefox
- ידע בסיסי ב-Google Compute Engine וב-Confidential VM
2. הגדרה ודרישות
כדי להפעיל את ממשקי ה-API הנדרשים, מריצים את הפקודה הבאה במסוף Cloud או בסביבת הפיתוח המקומית:
gcloud auth login
gcloud services enable \
cloudapis.googleapis.com \
cloudshell.googleapis.com \
confidentialcomputing.googleapis.com \
compute.googleapis.com
3. הגדרת CVM והזדהות מרחוק של vTPM
בשלב הזה, תיצרו CVM ותבצעו אימות מרחוק של vTPM כדי לאחזר אסימון אימות (אסימון OIDC) ב-CVM.
עוברים אל Cloud Console או אל סביבת הפיתוח המקומית. יוצרים 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 (ל-CVM צריכות להיות ההרשאות הבאות כדי לאחזר אסימון אימות מממשקי 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 כדי לאחזר אסימון אימות מממשקי Confidential Computing API שסופקו על ידי Google Cloud Attestation.
- מתחברים ל-CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
- הגדרת סביבת 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
- יוצרים את הקובץ הבינארי של הכלי 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
- הפקודה בכלי go-tpm מחלצת את הוכחת האימות של ה-CVM מ-vTPM ושולחת אותה ל-Google Cloud Attestation. Google Cloud Attestation מאמת ומאשר את ראיות האימות ומחזיר אסימון אימות. הפקודה יוצרת קובץ attestation_token שמכיל את
attestation-token. תשתמשו ב-attestation-tokenכדי לאחזר סוד בהמשך. אפשר לפענח את אסימון האימות ב-jwt.io כדי לראות את ההצהרות.
sudo ./gotpm token > attestation_token
- (אופציונלי) במקום לבצע אימות (attestation) מרחוק באמצעות הכלי go-tpm ו-Google Cloud Attestation, אנחנו מציגים את הפקודות לאחזור הוכחת האימות (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-attestation-codelab CVM. הפעם, הפעילות מתועדת ב-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הוא המזהה הייחודי של המופע.
אפשר למצוא את אסימון האישור, את הטענות שלו, את הראיות הגולמיות ואת המספר האקראי שנשלחו ל-Google Cloud Attestation.
5. הגדרת שרת אינטרנט להפצת סודות
בשלב הזה, יוצאים מסשן ה-SSH הקודם ומגדירים מכונה וירטואלית נוספת. במכונה הווירטואלית הזו, תגדירו שרת אינטרנט של הפצה סודית. שרת האינטרנט מאמת את טוקן האישור ואת ההצהרות שלו. אם האימותים מצליחים, הסוד נחשף בפני המבקש.
1) עוברים אל Cloud Console או אל סביבת הפיתוח המקומית. יוצרים מכונה וירטואלית.
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). עכשיו מתחילים עוד כרטיסייה של מסוף ענן או סשן של סביבת פיתוח מקומית ומריצים את הפקודה הבאה. כך מקבלים את <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). מתחברים באמצעות SSH למכונה הווירטואלית cvm-attestation-codelab.
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". בנוסף, ביומן של שרת האינטרנט של מהדורת הסוד במכונת ה-VM מספר cvm-attestation-codelab-web-server יופיע הערך Token valid: false.
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הוא המזהה הייחודי של הפרויקט.