Attestazione remota vTPM su macchina virtuale riservata

1. Panoramica

Le macchine virtuali riservate (CVM) sono un tipo di macchine virtuali Compute Engine che utilizzano la crittografia della memoria basata su hardware. In questo modo, puoi assicurarti che i tuoi dati e le tue applicazioni non possano essere letti o modificati durante l'utilizzo. In questo codelab viene mostrata l'attestazione remota che consente a una parte remota di verificare i nodi CVM. Inoltre, esplorerai Cloud Logging per ulteriori controlli.

fcc043c716119bd6.png

Come mostrato nella figura sopra, questo codelab include i seguenti passaggi:

  • Configurazione del CVM e attestazione da remoto
  • Esplorazione di Cloud Logging nell'attestazione remota del CVM
  • Configurazione del server web per la pubblicazione di secret

I componenti nella figura sopra includono uno strumento go-tpm e la certificazione Google Cloud:

  • Strumento go-tpm: uno strumento open source per recuperare la prova dell'attestazione dal vTPM (Virtual Trusted Platform Module) sul CVM e inviarla ad Attestazione Google Cloud per ottenere un token di attestazione.
  • Attestazione Google Cloud: verifica e convalida le prove dell'attestazione ricevute e restituisci un token di attestazione che rifletta i fatti relativi al CVM del richiedente.

Obiettivi didattici

  • Come eseguire l'attestazione da remoto tramite le API Confidential Computing su CVM
  • Come utilizzare Cloud Logging per monitorare l'attestazione remota della CVM

Che cosa ti serve

2. Configurazione e requisiti

Per abilitare le API necessarie, esegui il seguente comando nella console Cloud o nel tuo ambiente di sviluppo locale:

gcloud auth login

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

3. Configurazione dell'attestazione remota di CVM e vTPM

In questo passaggio, creerai una CVM ed eseguirai un'attestazione remota del vTPM per recuperare un token di attestazione (token OIDC) sulla CVM.

Vai alla console cloud o al tuo ambiente di sviluppo locale. Crea una CVM come segue (consulta Creare un'istanza Confidential VM | Google Cloud). Gli ambiti sono necessari per accedere alle 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

Autorizza l'account di servizio predefinito del CVM ad accedere alle API Confidential Computing (i CVM richiedono le seguenti autorizzazioni per recuperare il token di attestazione dalle API Confidential Computing):

1) Crea un ruolo per consentire l'accesso alle 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

Sostituisci quanto segue:

  • project-id è l'identificatore univoco del progetto.

2) Aggiungi il service account predefinito della VM al ruolo.

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"

Sostituisci quanto segue:

  • project-id è l'identificatore univoco del progetto.

3) Connettiti alla CVM e configura il file binario dello strumento go-tpm per recuperare il token di attestazione dalle API di calcolo riservato fornite da Google Cloud Attestation.

  1. Connettiti alla CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. Configura un 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. Crea il file binario dello strumento go-tpm. Lo strumento go-tpm recupera la prova dell'attestazione dal vTPM sulla CVM e la invia ad Attestazione Google Cloud per un token di attestazione.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. Il comando dello strumento go-tpm estrae le prove dell'attestazione della CVM dal vTPM e le invia ad Attestazione Google Cloud. L'attestazione Google Cloud verifica e convalida le prove dell'attestazione e restituisce un token di attestazione. Il comando crea un file attestation_token contenente il tuo attestation-token. Utilizzerai il tuo attestation-token per recuperare un secret in un secondo momento. Puoi decodificare il token di attestazione in jwt.io per visualizzare i claim.
sudo ./gotpm token > attestation_token
  1. (Facoltativo) In alternativa all'esecuzione dell'attestazione remota con lo strumento go-tpm e l'attestazione Google Cloud, mostriamo i comandi per recuperare la prova dell'attestazione vTPM. In questo modo, puoi creare un servizio come Google Cloud Attestation per verificare e convalidare le prove dell'attestazione:
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 il log eventi verificato. Puoi utilizzare l'editor che preferisci per visualizzarlo. Tieni presente che il comando verify non controlla il certificato della chiave di attestazione della quota.

4. Attiva Cloud Logging ed esplora il log di attestazione remota

Puoi eseguire il seguente comando nella tua CVM cvm-attestation-codelab. Questa volta, registra l'attività in Cloud Logging.

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

Ottieni cvm-attestation-codelab <instance-id> nella console cloud o nell'ambiente di sviluppo locale.

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

Per esplorare Cloud Logging, apri il seguente URL: https://console.cloud.google.com/logs. Nel campo della query, inserisci quanto segue:

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

Sostituisci quanto segue:

  • project-id è l'identificatore univoco del progetto.
  • instance-id è l'identificatore univoco dell'istanza.

Dovresti riuscire a trovare il token di attestazione, i relativi claim, le prove non elaborate e il nonce inviati ad Attestazione Google Cloud.

5. Configurare un server web per il rilascio di secret

In questo passaggio, esci dalla sessione SSH precedente e configura un'altra VM. Su questa VM, configura un server web per le release dei secret. Il server web convalida il token di attestazione ricevuto e i relativi claim. Se le convalide vanno a buon fine, il token viene rilasciato al richiedente.

1) Vai alla console cloud o al tuo ambiente di sviluppo locale. Crea una macchina virtuale.

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

Sostituisci quanto segue:

  • project-id è l'identificatore univoco del progetto.

2) Accedi tramite SSH alla nuova VM.

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

3) Configura l'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

4). Crea i seguenti due file per archiviare il codice sorgente del server web di release del secret (copia/incolla 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). Esegui i seguenti comandi per compilare ed eseguire il server web. Viene avviato il web server di rilascio del secret sulla porta :8080.

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

Risoluzione dei problemi: potresti visualizzare il seguente avviso, che può essere ignorato durante l'esecuzione 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). Ora avvia un'altra scheda della console cloud o una sessione dell'ambiente di sviluppo locale ed esegui il seguente comando. In questo modo otterrai <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). Accedi con SSH all'istanza VM cvm-attestation-codelab.

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

8). Il seguente comando sostituisce il valore attestation-token ottenuto in precedenza (in ~/go-tpm-tools/cmd/gotpm/). In questo modo, verrà recuperato il secret detenuto dal server web di release del secret.

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

Sostituisci quanto segue:

  • cvm-attestation-codelab-web-server-internal-ip è l'indirizzo IP interno dell'istanza VM cvm-attestation-codelab-web-server.

Sullo schermo viene visualizzato il messaggio "Queste sono le informazioni super segrete!".

Se inserisci un attestation-token errato o scaduto, visualizzerai il messaggio "curl: (52) Risposta vuota del server". Vedrai anche "Token valid: false" nel log del server web di rilascio del segreto nell'istanza VM cvm-attestation-codelab-web-server.

6. Esegui la pulizia

Esegui i seguenti comandi nella console cloud o nel tuo ambiente di sviluppo locale:

# 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

Sostituisci quanto segue:

  • project-id è l'identificatore univoco del progetto.

7. Passaggi successivi

Scopri di più sulle Confidential VM e su Compute Engine.