1. 개요
컨피덴셜 가상 머신 (CVM)은 하드웨어 기반 메모리 암호화를 사용하는 Compute Engine 가상 머신의 한 유형입니다. 이렇게 하면 데이터와 애플리케이션이 사용 중일 때 읽거나 수정할 수 없습니다. 이 Codelab에서는 원격 당사자가 CVM 노드를 확인할 수 있는 원격 증명이 표시됩니다. 또한 추가 감사를 위해 클라우드 로깅을 살펴봅니다.
위 그림과 같이 이 Codelab에는 다음 단계가 포함되어 있습니다.
- CVM 설정 및 원격 증명
- CVM 원격 증명에 대한 Cloud Logging 탐색
- Secret Release 웹 서버 설정
위 그림의 구성요소에는 go-tpm 도구와 Google Cloud 증명이 포함됩니다.
- go-tpm 도구: CVM의 vTPM (Virtual Trusted Platform Module)에서 증명 증거를 가져와 증명 토큰을 받기 위해 Google Cloud Attestation으로 전송하는 오픈소스 도구입니다.
- Google Cloud 증명: 수신된 증명 증거를 확인 및 검증하고 요청자의 CVM에 관한 사실을 반영하는 증명 토큰을 반환합니다.
학습할 내용
- CVM에서 Confidential Computing API를 통해 원격 증명을 실행하는 방법
- Cloud Logging을 사용하여 CVM 원격 증명을 모니터링하는 방법
필요한 항목
- Google Cloud Platform 프로젝트
- 브라우저(Chrome 또는 Firefox)
- Google Compute Engine, 컨피덴셜 VM에 관한 기본 지식
2. 설정 및 요구사항
필요한 API를 사용 설정하려면 Cloud 콘솔 또는 로컬 개발 환경에서 다음 명령어를 실행합니다.
gcloud auth login gcloud services enable \ cloudapis.googleapis.com \ cloudshell.googleapis.com \ confidentialcomputing.googleapis.com \ compute.googleapis.com
3. CVM 및 vTPM 원격 증명 설정
이 단계에서는 CVM을 만들고 vTPM 원격 증명을 실행하여 CVM에서 증명 토큰 (OIDC 토큰)을 가져옵니다.
Cloud 콘솔 또는 로컬 개발 환경으로 이동합니다. 다음과 같이 CVM을 만듭니다 (컨피덴셜 VM 인스턴스 만들기 | Google Cloud 참고). 컨피덴셜 컴퓨팅 API에 액세스하려면 범위가 필요합니다.
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
CVM 기본 서비스 계정이 Confidential Computing API에 액세스하도록 승인합니다. CVM이 Confidential Computing API에서 증명 토큰을 가져오려면 다음 권한이 필요합니다.
1) Confidential Computing API에 대한 액세스를 허용하는 역할을 만듭니다.
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
다음을 바꿉니다.
project-id
는 프로젝트의 고유 식별자입니다.
2) VM 기본 서비스 계정을 역할에 추가합니다.
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"
다음을 바꿉니다.
project-id
는 프로젝트의 고유 식별자입니다.
3) CVM에 연결하고 Google Cloud 증명에서 제공하는 Confidential Computing API에서 증명 토큰을 가져오기 위한 go-tpm 도구 바이너리를 설정합니다.
- CVM에 연결합니다.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab
- 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
- go-tpm 도구 바이너리를 빌드합니다. go-tpm 도구 바이너리는 CVM의 vTPM에서 증명 증거를 가져와 Google Cloud 증명으로 전송하여 증명 토큰을 받습니다.
git clone https://github.com/google/go-tpm-tools.git --depth 1 cd go-tpm-tools/cmd/gotpm/ go build
- go-tpm 도구 명령어는 vTPM에서 CVM의 증명 증거를 추출하여 Google Cloud 증명으로 전송합니다. Google Cloud 증명은 증명 증거를 확인하고 검증하며 증명 토큰을 반환합니다. 이 명령어는
attestation-token
가 포함된 attestation_token 파일을 만듭니다. 나중에attestation-token
를 사용하여 비밀을 가져옵니다. jwt.io에서 증명 토큰을 디코딩하여 클레임을 확인할 수 있습니다.
sudo ./gotpm token > attestation_token
- (선택사항) go-tpm 도구 및 Google Cloud 증명으로 원격 증명을 실행하는 대신 vTPM 증명 증거를 가져오는 명령어를 보여줍니다. 이렇게 하면 Google Cloud 증명과 같은 서비스를 만들어 증명 증거를 확인하고 검증할 수 있습니다.
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
에는 확인된 이벤트 로그가 포함됩니다. 원하는 편집기를 사용하여 확인할 수 있습니다. verify 명령어는 인용의 증명 키 인증서를 확인하지 않습니다.
4. Cloud Logging 사용 설정 및 원격 증명 로그 살펴보기
cvm-attestation-codelab
CVM에서 다음 명령어를 실행할 수 있습니다. 이번에는 Cloud Logging에 활동이 로깅됩니다.
sudo ./gotpm token --cloud-log --audience "https://api.cvm-attestation-codelab.com"
Cloud 콘솔 또는 로컬 개발 환경에서 cvm-attestation-codelab
<instance-id>
를 가져옵니다.
gcloud compute instances describe cvm-attestation-codelab --zone us-central1-c --format='value(id)'
Cloud Logging을 살펴보려면 https://console.cloud.google.com/logs URL을 엽니다. 쿼리 필드에 다음을 입력합니다.
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
다음을 바꿉니다.
project-id
는 프로젝트의 고유 식별자입니다.instance-id
는 인스턴스의 고유 식별자입니다.
Google Cloud 증명에 전송된 증명 토큰, 클레임, 원시 증거, nonce를 확인할 수 있습니다.
5. Secret Release 웹 서버 설정
이 단계에서는 이전 SSH 세션을 종료하고 다른 VM을 설정합니다. 이 VM에서 비밀 출시 웹 서버를 설정합니다. 웹 서버는 수신된 증명 토큰과 그 클레임을 확인합니다. 유효성 검사에 성공하면 요청자에게 비밀을 공개합니다.
1) Cloud 콘솔 또는 로컬 개발 환경으로 이동합니다. 가상 머신을 만듭니다.
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
다음을 바꿉니다.
project-id
는 프로젝트의 고유 식별자입니다.
2) 새 VM에 SSH로 연결합니다.
gcloud compute ssh --zone us-central1-c cvm-attestation-codelab-web-server
3) 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). 다음 두 파일을 만들어 비공개 출시 웹 서버의 소스 코드를 저장합니다 (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). 다음 명령어를 실행하여 웹 서버를 빌드하고 실행합니다. 그러면 :8080
포트에서 비밀 출시 웹 서버가 시작됩니다.
go mod init google.com/codelab go mod tidy go get github.com/golang-jwt/jwt/v4 go build ./codelab
문제 해결: 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). 이제 다른 Cloud 콘솔 탭 또는 로컬 개발 환경 세션을 시작하고 다음 명령어를 실행합니다. 이렇게 하면 <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페이지). cvm-attestation-codelab
VM 인스턴스에 SSH로 연결합니다.
gcloud compute ssh --zone "us-central1-c" "cvm-attestation-codelab"
8). 다음 명령어는 이전에 ~/go-tpm-tools/cmd/gotpm/
에서 가져온 attestation-token
를 대체합니다. 이렇게 하면 보안 출시 웹 서버에 보관된 보안 비밀을 가져올 수 있습니다.
cd ~/go-tpm-tools/cmd/gotpm/ curl http://<cvm-attestation-codelab-web-server-internal-ip>:8080 -H "Authorization: Bearer $(cat ./attestation_token)"
다음을 바꿉니다.
cvm-attestation-codelab-web-server-internal-ip
는 cvm-attestation-codelab-web-server VM 인스턴스의 내부 IP입니다.
화면에 'This is the super secret information!'이라고 표시됩니다.
잘못되거나 만료된 attestation-token
를 입력하면 'curl: (52) Empty reply from server'가 표시됩니다. cvm-attestation-codelab-web-server
VM 인스턴스의 비밀 출시 웹 서버 로그에도 'Token valid: false'가 표시됩니다.
6. 삭제
Cloud 콘솔 또는 로컬 개발 환경에서 다음 명령어를 실행합니다.
# 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
다음을 바꿉니다.
project-id
는 프로젝트의 고유 식별자입니다.
7. 다음 단계
컨피덴셜 VM 및 Compute Engine에 대해 자세히 알아보세요.