Attestation à distance vTPM sur une machine virtuelle confidentielle

1. Présentation

Les machines virtuelles confidentielles (CVM) sont un type de machines virtuelles Compute Engine qui utilisent le chiffrement de la mémoire basé sur le matériel. Cela permet de s'assurer que vos données et applications ne peuvent pas être lues ni modifiées lorsqu'elles sont utilisées. Dans cet atelier de programmation, vous allez découvrir l'attestation à distance qui permet à un tiers distant de valider les nœuds CVM. Vous allez également découvrir la journalisation cloud pour effectuer des audits supplémentaires.

fcc043c716119bd6.png

Comme illustré dans la figure ci-dessus, cet atelier de programmation comprend les étapes suivantes:

  • Configuration du CVM et attestation à distance
  • Exploration Cloud Logging sur l'attestation à distance du CVM
  • Configuration du serveur Web pour la publication de secrets

Les composants de la figure ci-dessus incluent un outil go-tpm et Google Cloud Attestation:

  • Outil go-tpm: outil Open Source permettant d'extraire des preuves d'attestation à partir du module vTPM (Virtual Trusted Platform Module) sur la CVM et de les envoyer à Google Cloud Attestation pour obtenir un jeton d'attestation.
  • Attestation Google Cloud: vérifiez et validez les preuves d'attestation reçues, puis renvoyez un jeton d'attestation reflétant les faits concernant la CVM de la personne à l'origine de la demande.

Points abordés

  • Effectuer une attestation à distance via les API d'informatique confidentielle sur une VM CVM
  • Utiliser Cloud Logging pour surveiller l'attestation à distance des CVM

Prérequis

2. Préparation

Pour activer les API nécessaires, exécutez la commande suivante dans la console Cloud ou dans votre environnement de développement local:

gcloud auth login

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

3. Configurer l'attestation à distance du CVM et du vTPM

Dans cette étape, vous allez créer une CVM et effectuer une attestation à distance vTPM pour récupérer un jeton d'attestation (jeton OIDC) sur la CVM.

Accédez à la console Cloud ou à votre environnement de développement local. Créez une CVM comme suit (consultez Créer une instance Confidential VM | Google Cloud). Les champs d'application sont nécessaires pour accéder aux 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

Autorisez le compte de service par défaut de la CVM à accéder aux API Confidential Computing (les CVM ont besoin des autorisations suivantes pour récupérer le jeton d'attestation à partir des API Confidential Computing):

1). Créez un rôle pour autoriser l'accès aux 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

Remplacez les éléments suivants :

  • project-id est l'identifiant unique du projet.

2). Ajoutez le compte de service par défaut de la VM au rôle.

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"

Remplacez les éléments suivants :

  • project-id est l'identifiant unique du projet.

3). Connectez-vous à la CVM et configurez le binaire de l'outil go-tpm pour extraire le jeton d'attestation à partir des API de calcul confidentiel fournies par Google Cloud Attestation.

  1. Connectez-vous à la CVM.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
  1. Configurez un environnement 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. Créez le binaire de l'outil go-tpm. Le binaire de l'outil go-tpm extrait la preuve d'attestation du vTPM sur la VMC et l'envoie à Google Cloud Attestation pour obtenir un jeton d'attestation.
git clone https://github.com/google/go-tpm-tools.git --depth 1
cd go-tpm-tools/cmd/gotpm/
go build
  1. La commande de l'outil go-tpm extrait la preuve d'attestation de la CVM à partir du vTPM et l'envoie à Google Cloud Attestation. Google Cloud Attestation vérifie et valide la preuve d'attestation, puis renvoie un jeton d'attestation. La commande crée un fichier attestation_token qui contient votre attestation-token. Vous utiliserez votre attestation-token pour récupérer un secret plus tard. Vous pouvez décoder le jeton d'attestation dans jwt.io pour afficher les revendications.
sudo ./gotpm token > attestation_token
  1. (Facultatif) Au lieu d'effectuer une attestation à distance avec l'outil go-tpm et Google Cloud Attestation, nous présentons les commandes permettant d'extraire une preuve d'attestation vTPM. Vous pouvez ainsi créer un service tel que Google Cloud Attestation pour vérifier et valider les preuves d'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 contient le journal des événements validé. Vous pouvez l'afficher dans l'éditeur de votre choix. Notez que la commande "verify" ne vérifie pas le certificat de clé d'attestation de l'offre.

4. Activer Cloud Logging et explorer le journal d'attestation à distance

Vous pouvez exécuter la commande suivante dans votre CVM cvm-attestation-codelab. Cette fois, l'activité est enregistrée dans Cloud Logging.

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

Obtenez cvm-attestation-codelab <instance-id> dans votre console cloud ou votre environnement de développement local.

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

Pour découvrir Cloud Logging, ouvrez l'URL suivante: https://console.cloud.google.com/logs. Dans le champ de requête, saisissez ce qui suit:

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

Remplacez les éléments suivants :

  • project-id est l'identifiant unique du projet.
  • instance-id est l'identifiant unique de l'instance.

Vous devriez pouvoir trouver le jeton d'attestation, ses revendications, les preuves brutes et le nonce envoyés à Google Cloud Attestation.

5. Configurer un serveur Web de publication de secrets

À cette étape, vous quittez votre session SSH précédente et configurez une autre VM. Sur cette VM, vous allez configurer un serveur Web de version secrète. Le serveur Web valide le jeton d'attestation reçu et ses revendications. Si les validations réussissent, le secret est communiqué à la personne à l'origine de la requête.

1). Accédez à la console Cloud ou à votre environnement de développement local. Créez une machine virtuelle.

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

Remplacez les éléments suivants :

  • project-id est l'identifiant unique du projet.

2). Connectez-vous en SSH à votre nouvelle VM.

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

3). Configurez l'environnement 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). Créez les deux fichiers suivants pour stocker le code source du serveur Web de la version secrète (copiez-collez avec 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). Exécutez les commandes suivantes pour créer et exécuter le serveur Web. Le serveur Web de publication de secrets démarre alors sur le port :8080.

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

Résolution: l'avertissement suivant peut s'afficher lorsque vous exécutez go mod tidy:. Vous pouvez l'ignorer.

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). Ouvrez maintenant un autre onglet de la console Cloud ou une autre session de l'environnement de développement local, puis exécutez la commande suivante. Vous obtiendrez alors <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). Connectez-vous en SSH à votre instance de VM cvm-attestation-codelab.

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

8). La commande suivante remplace le attestation-token obtenu précédemment (sous ~/go-tpm-tools/cmd/gotpm/). Vous obtiendrez ainsi le secret détenu par le serveur Web de publication de secret.

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

Remplacez les éléments suivants :

  • cvm-attestation-codelab-web-server-internal-ip est l'adresse IP interne de l'instance de VM cvm-attestation-codelab-web-server.

Le message "Voici l'information super secrète !" s'affiche à l'écran.

Si vous saisissez une attestation-token incorrecte ou expirée, le message "curl: (52) Empty reply from server" (curl : (52) Réponse vide du serveur) s'affiche. "Token valid: false" (Jeton valide : false) s'affiche également dans le journal du serveur Web de la version secrète dans l'instance de VM cvm-attestation-codelab-web-server.

6. Nettoyage

Exécutez les commandes suivantes dans la console Cloud ou dans votre environnement de développement 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

Remplacez les éléments suivants :

  • project-id est l'identifiant unique du projet.

7. Étape suivante

En savoir plus sur les Confidential VM et Compute Engine