Atestado remoto de vTPM em máquinas virtuais confidenciais

1. Visão geral

As Máquinas Virtuais Confidenciais (CVM) são um tipo de máquina virtual do Compute Engine que usa criptografia de memória baseada em hardware. Isso ajuda a garantir que seus dados e aplicativos não possam ser lidos ou modificados durante o uso. Neste codelab, você vai conferir o atestado remoto que permite que uma parte remota verifique os nós do CVM. Além disso, você vai conhecer o registro de nuvem para mais auditorias.

fcc043c716119bd6.png

Conforme mostrado na figura acima, este codelab inclui as seguintes etapas:

  • Configuração do CVM e atestado remoto
  • Análise detalhada do Cloud Logging na atestação remota do CVM
  • Configuração do servidor da Web do Secret Release

Os componentes na figura acima incluem uma ferramenta go-tpm e o atestado do Google Cloud:

  • Ferramenta go-tpm: uma ferramenta de código aberto para buscar evidências de atestado do vTPM (módulo de plataforma confiável virtual) na CVM e enviá-las ao Google Cloud Attestation para receber um token de atestado.
  • Atestado do Google Cloud: verifique e valide o atestado recebido e retorne um token de atestado que reflita os fatos sobre o CVM do solicitante.

O que você vai aprender

O que é necessário

2. Configuração e requisitos

Para ativar as APIs necessárias, execute o seguinte comando no console do Cloud ou no seu ambiente de desenvolvimento local:

gcloud auth login

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

3. Como configurar a atestação remota de CVM e vTPM

Nesta etapa, você vai criar um CVM e realizar uma atestação remota do vTPM para extrair um token de atestado (token OIDC) no CVM.

Acesse o console do Cloud ou seu ambiente de desenvolvimento local. Crie uma CVM da seguinte maneira (consulte Criar uma instância de VM confidencial | Google Cloud). Os escopos são necessários para acessar as APIs de computação confidencial.

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

Autorizar a conta de serviço padrão do CVM a acessar as APIs de computação confidencial. Os CVMs precisam das seguintes permissões para buscar o token de atestado das APIs de computação confidencial:

1) Crie uma função para permitir o acesso às APIs de Computação confidencial.

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

Substitua:

  • project-id é o identificador exclusivo do projeto.

2) Adicione a conta de serviço padrão da VM ao papel.

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"

Substitua:

  • project-id é o identificador exclusivo do projeto.

3) Conecte-se à CVM e configure o binário da ferramenta go-tpm para buscar o token de atestado das APIs de computação confidencial fornecidas pelo Google Cloud Attestation.

  1. Conecte-se à CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. Configure um ambiente 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. Crie o binário da ferramenta go-tpm. O binário da ferramenta go-tpm busca evidências de atestado do vTPM no CVM e as envia ao Google Cloud Attestation para receber um token de atestado.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. O comando da ferramenta go-tpm extrai evidências de atestado do CVM do vTPM e as envia para o Google Cloud Attestation. O Google Cloud Attestation verifica e valida o atestado e retorna um token de atestado. O comando cria um arquivo attestation_token que contém o attestation-token. Você vai usar o attestation-token para buscar um segredo mais tarde. Você pode decodificar o token de atestado em jwt.io para conferir as reivindicações.
sudo ./gotpm token > attestation_token
  1. (Opcional) Como alternativa para realizar a atestação remota com a ferramenta go-tpm e a atestação do Google Cloud, mostramos os comandos para buscar evidências de atestação do vTPM. Assim, é possível criar um serviço como a Attestation do Google Cloud para verificar e validar as evidências de atestado:
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 contém o registro de eventos verificado. Você pode usar seu editor preferido para conferir. O comando "verify" não verifica o certificado de chave de atestado da cotação.

4. Ativar o Cloud Logging e conferir o registro de atestado remoto

Você pode executar o comando a seguir na CVM cvm-attestation-codelab. Dessa vez, ela registra a atividade no Cloud Logging.

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

Instale o cvm-attestation-codelab <instance-id> no console do Cloud ou no ambiente de desenvolvimento local.

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

Para conhecer o Cloud Logging, acesse este URL: https://console.cloud.google.com/logs. No campo de consulta, digite o seguinte:

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

Substitua:

  • project-id é o identificador exclusivo do projeto.
  • instance-id é o identificador exclusivo da instância.

Você pode encontrar o token de atestado, as reivindicações, as evidências brutas e o valor de uso único enviados para o Google Cloud Attestation.

5. Configurar um servidor da Web de lançamento de segredos

Nesta etapa, você sai da sessão SSH anterior e configura outra VM. Nesta VM, você vai configurar um servidor da Web de lançamento secreto. O servidor da Web valida o token de atestado recebido e as declarações dele. Se as validações forem bem-sucedidas, o segredo será liberado para o solicitante.

1) Acesse o console do Cloud ou seu ambiente de desenvolvimento local. Crie uma 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

Substitua:

  • project-id é o identificador exclusivo do projeto.

2) SSH para sua nova VM.

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

3) Configure o ambiente do 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). Crie os dois arquivos a seguir para armazenar o código-fonte do servidor da Web de lançamento secreto (copie e cole com o 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). Execute os comandos abaixo para criar e executar o servidor da Web. Isso inicia o servidor da Web de lançamento secreto na porta :8080.

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

Solução de problemas: talvez você veja o aviso a seguir, que pode ser ignorado ao executar 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). Agora, inicie outra guia do console do Cloud ou uma sessão do ambiente de desenvolvimento local e execute o comando a seguir. Isso vai gerar o <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). Faça SSH para sua instância de VM cvm-attestation-codelab.

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

8). O comando a seguir substitui o attestation-token obtido anteriormente (em ~/go-tpm-tools/cmd/gotpm/). Ele vai buscar o segredo mantido pelo servidor da Web de lançamento de segredos.

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

Substitua:

  • cvm-attestation-codelab-web-server-internal-ip é o IP interno da instância de VM cvm-attestation-codelab-web-server.

A mensagem "Esta é a informação super secreta!" vai aparecer na tela.

Se você inserir um attestation-token incorreto ou expirado, a mensagem "curl: (52) Empty reply from server" vai aparecer. Você também vai encontrar "Token válido: false" no registro do servidor da Web de lançamento secreto na instância de VM cvm-attestation-codelab-web-server.

6. Limpeza

Execute os seguintes comandos no Cloud Console ou no seu ambiente de desenvolvimento 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

Substitua:

  • project-id é o identificador exclusivo do projeto.

7. A seguir

Saiba mais sobre VMs confidenciais e o Compute Engine.