Chứng thực từ xa vTPM trên máy ảo bảo mật

1. Tổng quan

Máy ảo bảo mật (CVM) là một loại máy ảo Compute Engine sử dụng phương thức mã hoá bộ nhớ dựa trên phần cứng. Điều này giúp đảm bảo dữ liệu và ứng dụng của bạn không thể được đọc hoặc sửa đổi trong khi đang sử dụng. Trong lớp học lập trình này, bạn sẽ thấy tính năng chứng thực từ xa cho phép một bên từ xa xác minh các nút CVM. Ngoài ra, bạn sẽ khám phá tính năng ghi nhật ký trên đám mây để kiểm tra thêm.

fcc043c716119bd6.png

Như mô tả trong hình trên, lớp học lập trình này bao gồm các bước sau:

  • Thiết lập CVM và Chứng thực từ xa
  • Dữ liệu khám phá Cloud Logging về tính năng chứng thực từ xa của CVM
  • Thiết lập máy chủ web phát hành bí mật

Các thành phần trong hình trên bao gồm công cụ go-tpm và Chứng thực Google Cloud:

  • Công cụ go-tpm: Một công cụ nguồn mở để tìm nạp bằng chứng chứng thực từ vTPM (Mô-đun nền tảng đáng tin cậy ảo) trên CVM và gửi bằng chứng đó đến Dịch vụ chứng thực của Google Cloud để lấy mã thông báo chứng thực.
  • Chứng thực Google Cloud: Xác minh và xác thực bằng chứng chứng thực đã nhận được, đồng thời trả về một mã thông báo chứng thực phản ánh các thông tin thực tế về CVM của bên yêu cầu.

Kiến thức bạn sẽ học được

  • Cách thực hiện chứng thực từ xa thông qua API Điện toán bảo mật trên CVM
  • Cách sử dụng tính năng Ghi nhật ký trên đám mây để theo dõi quy trình chứng thực từ xa của CVM

Bạn cần có

2. Cách thiết lập và các yêu cầu

Để bật các API cần thiết, hãy chạy lệnh sau trong bảng điều khiển trên đám mây hoặc môi trường phát triển cục bộ:

gcloud auth login

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

3. Thiết lập tính năng chứng thực từ xa CVM và vTPM

Ở bước này, bạn sẽ tạo một CVM và thực hiện chứng thực từ xa vTPM để truy xuất Mã thông báo chứng thực (Mã thông báo OIDC) trên CVM.

Chuyển đến bảng điều khiển trên đám mây hoặc môi trường phát triển cục bộ. Tạo CVM như sau (tham khảo bài viết Tạo phiên bản máy ảo bảo mật | Google Cloud). Bạn cần có phạm vi để truy cập vào các API Điện toán bảo mật.

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

Uỷ quyền cho tài khoản dịch vụ mặc định của CVM truy cập vào các API Điện toán bảo mật (CVM cần có các quyền sau để tìm nạp Mã thông báo chứng thực từ các API Điện toán bảo mật):

1). Tạo vai trò để cho phép truy cập vào các API Điện toán bảo mật.

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

Thay thế nội dung sau:

  • project-id là giá trị nhận dạng duy nhất của dự án.

2). Thêm tài khoản dịch vụ mặc định của máy ảo vào vai trò.

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"

Thay thế nội dung sau:

  • project-id là giá trị nhận dạng duy nhất của dự án.

3). Kết nối với CVM và thiết lập tệp nhị phân của công cụ go-tpm để tìm nạp Mã thông báo chứng thực từ các API Điện toán bảo mật do Google Cloud Attestation cung cấp.

  1. Kết nối với CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. Thiết lập môi trường 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. Tạo tệp nhị phân của công cụ go-tpm. Tệp nhị phân của công cụ go-tpm tìm nạp bằng chứng chứng thực từ vTPM trên CVM và gửi bằng chứng đó đến Google Cloud Attestation để lấy mã thông báo chứng thực.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. Lệnh công cụ go-tpm trích xuất bằng chứng chứng thực của CVM từ vTPM và gửi bằng chứng đó đến Google Cloud Attestation. Tính năng Chứng thực của Google Cloud xác minh và xác thực bằng chứng chứng thực, đồng thời trả về một Mã thông báo chứng thực. Lệnh này sẽ tạo một tệp attestation_token chứa attestation-token của bạn. Bạn sẽ sử dụng attestation-token để tìm nạp một khoá bí mật sau. Bạn có thể giải mã mã xác thực trong jwt.io để xem các thông báo xác nhận.
sudo ./gotpm token > attestation_token
  1. (Không bắt buộc) Ngoài cách thực hiện chứng thực từ xa bằng công cụ go-tpm và Google Cloud Attestation, chúng tôi còn giới thiệu các lệnh để tìm nạp bằng chứng chứng thực vTPM. Bằng cách này, bạn có thể tạo một dịch vụ như Chứng thực Google Cloud để xác minh và xác thực bằng bằng chứng chứng thực:
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 chứa nhật ký sự kiện đã xác minh. Bạn có thể sử dụng trình chỉnh sửa mà bạn muốn để xem mã. Xin lưu ý rằng lệnh verify (xác minh) không kiểm tra chứng chỉ khoá chứng thực của báo giá.

4. Bật tính năng Ghi nhật ký trên đám mây và khám phá nhật ký chứng thực từ xa

Bạn có thể chạy lệnh sau trong CVM cvm-attestation-codelab. Lần này, ứng dụng sẽ ghi lại hoạt động trên tính năng ghi nhật ký trên đám mây.

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

Tải cvm-attestation-codelab <instance-id> trong bảng điều khiển đám mây hoặc môi trường phát triển cục bộ.

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

Để khám phá tính năng Ghi nhật ký trên đám mây, hãy mở URL sau: https://console.cloud.google.com/logs. Trong trường truy vấn, hãy nhập nội dung sau:

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

Thay thế nội dung sau:

  • project-id là giá trị nhận dạng duy nhất của dự án.
  • instance-id là giá trị nhận dạng duy nhất của thực thể.

Bạn sẽ thấy Mã thông báo chứng thực, Thông báo xác nhận, bằng chứng thô và số chỉ dùng một lần được gửi đến Google Cloud Attestation.

5. Thiết lập máy chủ web phát hành bí mật

Ở bước này, bạn thoát khỏi phiên SSH trước đó và thiết lập một máy ảo khác. Trên máy ảo này, bạn thiết lập một máy chủ web phát hành bí mật. Máy chủ web xác thực Mã thông báo chứng thực đã nhận và các thông báo xác nhận của mã đó. Nếu xác thực thành công, thì hệ thống sẽ phát hành khoá bí mật cho bên yêu cầu.

1). Chuyển đến bảng điều khiển trên đám mây hoặc môi trường phát triển cục bộ. Tạo máy ảo.

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

Thay thế nội dung sau:

  • project-id là giá trị nhận dạng duy nhất của dự án.

2). Kết nối SSH với máy ảo mới.

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

3). Thiết lập môi trường 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). Tạo hai tệp sau để lưu trữ mã nguồn của máy chủ web phát hành bí mật (sao chép/dán bằng 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). Chạy các lệnh sau để tạo và chạy máy chủ web. Thao tác này sẽ khởi động máy chủ web phát hành bí mật tại cổng :8080.

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

Khắc phục sự cố: bạn có thể thấy cảnh báo sau đây và có thể bỏ qua cảnh báo này khi chạy 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). Bây giờ, hãy bắt đầu một thẻ bảng điều khiển trên đám mây hoặc phiên môi trường phát triển cục bộ khác rồi chạy lệnh sau. Thao tác này sẽ giúp bạn có được <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). Kết nối SSH với thực thể máy ảo cvm-attestation-codelab.

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

8). Lệnh sau đây sẽ thay thế attestation-token đã lấy trước đó (trong ~/go-tpm-tools/cmd/gotpm/). Thao tác này sẽ tìm nạp cho bạn khoá bí mật do máy chủ web phát hành bí mật lưu giữ!

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

Thay thế nội dung sau:

  • cvm-attestation-codelab-web-server-internal-ip là địa chỉ IP nội bộ của phiên bản máy ảo cvm-attestation-codelab-web-server.

Bạn sẽ thấy thông báo "This is the super secret information!" (Đây là thông tin cực kỳ bí mật!) trên màn hình.

Nếu nhập attestation-token không chính xác hoặc đã hết hạn, bạn sẽ thấy thông báo "curl: (52) Empty reply from server" (curl: (52) Phản hồi trống từ máy chủ). Bạn cũng sẽ thấy thông báo "Token valid: false" (Mã thông báo hợp lệ: sai) trên nhật ký máy chủ web phát hành bí mật trong thực thể máy ảo cvm-attestation-codelab-web-server.

6. Dọn dẹp

Chạy các lệnh sau trong bảng điều khiển trên đám mây hoặc môi trường phát triển cục bộ:

# 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

Thay thế nội dung sau:

  • project-id là giá trị nhận dạng duy nhất của dự án.

7. Bước tiếp theo

Tìm hiểu thêm về Máy ảo bảo mật và Compute Engine.