Certificación remota de vTPM en la máquina virtual confidencial

1. Descripción general

Las máquinas virtuales confidenciales (CVM) son un tipo de máquinas virtuales de Compute Engine que usan encriptación de memoria basada en hardware. Esto ayuda a garantizar que tus datos y aplicaciones no se puedan leer ni modificar mientras están en uso. En este codelab, se muestra una certificación remota que permite que una parte remota verifique los nodos de CVM. Además, explorarás el registro de Cloud para realizar más auditorías.

fcc043c716119bd6.png

Como se muestra en la imagen anterior, este codelab incluye los siguientes pasos:

  • Configuración de la CVM y certificación remota
  • Exploración de Cloud Logging en la certificación remota del CVM
  • Configuración del servidor web de la versión secreta

Los componentes de la figura anterior incluyen una herramienta go-tpm y la certificación de Google Cloud:

  • Herramienta go-tpm: Es una herramienta de código abierto para recuperar evidencia de certificación del vTPM (módulo de plataforma de confianza virtual) en la CVM y enviarla a la Certificación de Google Cloud para obtener un token de certificación.
  • Certificación de Google Cloud: Verifica y valida la evidencia de certificación recibida y muestra un token de certificación que refleje los hechos sobre la CVM del solicitante.

Qué aprenderás

  • Cómo realizar la certificación remota a través de las APIs de Confidential Computing en CVM
  • Cómo usar Cloud Logging para supervisar la certificación remota de CVM

Requisitos

2. Configuración y requisitos

Para habilitar las APIs necesarias, ejecuta el siguiente comando en Cloud Console o en tu entorno de desarrollo local:

gcloud auth login

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

3. Cómo configurar la certificación remota de CVM y vTPM

En este paso, crearás una CVM y realizarás una certificación remota de vTPM para recuperar un token de certificación (token de OIDC) en la CVM.

Ve a la consola de Cloud o a tu entorno de desarrollo local. Crea una CVM de la siguiente manera (consulta Crea una instancia de Confidential VM | Google Cloud). Se necesitan permisos para acceder a las APIs de 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

Otorga permiso a la cuenta de servicio predeterminada de CVM para que acceda a las APIs de Confidential Computing (las CVMs necesitan los siguientes permisos para recuperar el token de certificación de las APIs de Confidential Computing):

1). Crea un rol para permitir el acceso a las APIs de 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

Reemplaza lo siguiente:

  • project-id es el identificador único del proyecto.

2). Agrega la cuenta de servicio predeterminada de la VM al rol.

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"

Reemplaza lo siguiente:

  • project-id es el identificador único del proyecto.

3). Conéctate a la CVM y configura el objeto binario de la herramienta go-tpm para recuperar el token de certificación de las APIs de Confidential Computing que proporciona la certificación de Google Cloud.

  1. Conéctate a la CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. Configura un entorno de 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. Compila el objeto binario de la herramienta go-tpm. El objeto binario de la herramienta go-tpm recupera evidencia de certificación del vTPM en la CVM y la envía a la Certificación de Google Cloud para obtener un token de certificación.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. El comando de la herramienta go-tpm extrae la evidencia de certificación de la CVM del vTPM y la envía a la Certificación de Google Cloud. La certificación de Google Cloud verifica y valida la evidencia de certificación y muestra un token de certificación. El comando crea un archivo attestation_token que contiene tu attestation-token. Usarás tu attestation-token para recuperar un secreto más adelante. Puedes decodificar el token de certificación en jwt.io para ver las reclamaciones.
sudo ./gotpm token > attestation_token
  1. Como alternativa (opcional) para realizar la certificación remota con la herramienta go-tpm y la certificación de Google Cloud, mostramos los comandos para recuperar la evidencia de certificación de vTPM. De esta manera, puedes crear un servicio como la Certificación de Google Cloud para verificar y validar la evidencia de certificación:
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 contiene el registro de eventos verificado. Puedes usar el editor que prefieras para verlo. Ten en cuenta que el comando verify no verifica el certificado de clave de certificación de la cita.

4. Habilita Cloud Logging y explora el registro de certificación remota

Puedes ejecutar el siguiente comando en tu CVM de cvm-attestation-codelab. Esta vez, registra la actividad en Cloud Logging.

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

Obtén cvm-attestation-codelab <instance-id> en tu consola de Cloud o entorno de desarrollo local.

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

Para explorar el registro de Cloud, abre la siguiente URL: https://console.cloud.google.com/logs. En el campo de consulta, ingresa lo siguiente:

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

Reemplaza lo siguiente:

  • project-id es el identificador único del proyecto.
  • instance-id es el identificador único de la instancia.

Deberías poder encontrar el token de certificación, sus declaraciones, la evidencia sin procesar y el nonce que se envió a la certificación de Google Cloud.

5. Configura un servidor web de lanzamiento de secretos

En este paso, saldrás de tu sesión de SSH anterior y configurarás otra VM. En esta VM, configurarás un servidor web de lanzamiento secreto. El servidor web valida el token de certificación recibido y sus reclamos. Si las validaciones se realizan correctamente, se libera el secreto al solicitante.

1). Ve a la consola de Cloud o a tu entorno de desarrollo local. Crea una máquina virtual.

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

Reemplaza lo siguiente:

  • project-id es el identificador único del proyecto.

2). Conéctate a la VM nueva mediante SSH.

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

3). Configura el entorno de 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). Crea los siguientes dos archivos para almacenar el código fuente del servidor web de la versión secreta (copia y pega con 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). Ejecuta los siguientes comandos para compilar y ejecutar el servidor web. Esto inicia el servidor web de la versión secreta en el puerto :8080.

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

Solución de problemas: Es posible que veas la siguiente advertencia, que se puede ignorar cuando se ejecuta 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). Ahora, inicia otra pestaña de la consola de Cloud o una sesión del entorno de desarrollo local y ejecuta el siguiente comando. Esto te dará el <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). Establece una conexión SSH a tu instancia de VM de cvm-attestation-codelab.

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

8). El siguiente comando sustituye el attestation-token obtenido anteriormente (en ~/go-tpm-tools/cmd/gotpm/). Esto recuperará el secreto que contiene el servidor web de la versión secreta.

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

Reemplaza lo siguiente:

  • cvm-attestation-codelab-web-server-internal-ip es la IP interna de la instancia de VM cvm-attestation-codelab-web-server.

Verás el mensaje "Esta es la información súper secreta" en la pantalla.

Si ingresas un attestation-token incorrecto o vencido, verás el mensaje "curl: (52) Empty reply from server". También verás "Token valid: false" en el registro del servidor web de la versión secreta en la instancia de VM cvm-attestation-codelab-web-server.

6. Limpieza

Ejecuta los siguientes comandos en la consola de Cloud o en tu entorno de desarrollo local:

# 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

Reemplaza lo siguiente:

  • project-id es el identificador único del proyecto.

7. ¿Qué sigue?

Obtén más información sobre Confidential VMs y Compute Engine.