機密虛擬機器的 vTPM 遠端認證

1. 總覽

機密虛擬機器 (CVM) 是一種使用硬體記憶體加密功能的 Compute Engine 虛擬機器。這有助於確保資料和應用程式在使用期間不會遭到讀取或修改。在本程式碼研究室中,您將看到遠端認證,可讓遠端方驗證 CVM 節點。此外,您還會探索雲端記錄,以便進一步稽核。

fcc043c716119bd6.png

如上圖所示,本程式碼研究室包含下列步驟:

  • CVM 設定和遠端認證
  • CVM 遠端認證的 Cloud Logging 探索
  • 密鑰發布網頁伺服器設定

上圖中的元件包括 go-tpm 工具和 Google Cloud Attestation:

  • go-tpm 工具:開放原始碼工具,可從 CVM 上的 vTPM (虛擬信任平台模組) 擷取認證證明,並將其傳送至 Google Cloud Attestation,以取得認證權杖。
  • Google Cloud 認證:驗證收到的認證證明,並傳回反映要求者 CVM 事實的認證權杖。

課程內容

  • 如何在 CVM 上透過 機密運算 API 執行遠端認證
  • 如何使用 Cloud Logging 監控 CVM 遠端認證

軟硬體需求

2. 設定和需求

如要啟用必要的 API,請在雲端控制台或本機開發環境中執行下列指令:

gcloud auth login

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

3. 設定 CVM 和 vTPM 遠端認證

在這個步驟中,您將建立 CVM 並執行 vTPM 遠端認證,以便在 CVM 上擷取認證權杖 (OIDC 權杖)。

前往雲端控制台或本機開發環境。請按照以下步驟建立 CVM (請參閱「建立機密 VM 執行個體 | Google Cloud」)。您需要範圍才能存取機密運算 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 預設服務帳戶存取機密運算 API (CVM 需要下列權限,才能從機密運算 API 擷取認證權杖):

1)。建立允許存取機密運算 API 的角色。

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 是專案的專屬 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 是專案的專屬 ID。

3). 連線至 CVM,並設定 go-tpm 工具二進位檔,從 Google Cloud Attestation 提供的機密運算 API 擷取認證權杖。

  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 工具二進位檔會從 CVM 的 vTPM 擷取認證證明,並將其傳送至 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 工具指令會從 vTPM 擷取 CVM 的認證證明,並傳送至 Google Cloud Attestation。Google Cloud Attestation 會驗證證明文件,並傳回 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 包含已驗證的事件記錄。您可以使用偏好的編輯器查看檔案。請注意,驗證指令不會檢查引文的認證金鑰憑證。

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,請開啟以下網址: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 是專案的專屬 ID。
  • instance-id 是執行個體的專屬 ID。

您應該可以找到傳送至 Google Cloud Attestation 的認證權杖、相關宣告、原始證據和 Nonce。

5. 設定 Secret Release Web Server

在這個步驟中,您將退出先前的 SSH 工作階段,並設定另一個 VM。您會在這個 VM 上設定密鑰版本網路伺服器。網路伺服器會驗證收到的認證權杖及其宣告。如果驗證成功,系統就會將密鑰發布給要求者。

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 是專案的專屬 ID。

2). 透過 SSH 連線至新的 VM。

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 VM 執行個體。

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 是 cvm-attestation-codelab-web-server VM 執行個體的內部 IP。

畫面上會顯示「這是超級機密資訊!」

如果您輸入的 attestation-token 不正確或已過期,就會看到「curl: (52) Empty reply from server」。您也會在 cvm-attestation-codelab-web-server VM 執行個體的秘密發布網頁伺服器記錄中看到「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 是專案的專屬 ID。

7. 後續行動

進一步瞭解機密 VM 和 Compute Engine