Skip to content

Commit

Permalink
Add unit tests for IPC server
Browse files Browse the repository at this point in the history
Add image test
Move tests to different regions to avoid region N2D caps
  • Loading branch information
Joshua Krstic committed Nov 16, 2023
1 parent 1fd3df5 commit 5a39acf
Show file tree
Hide file tree
Showing 12 changed files with 453 additions and 8 deletions.
16 changes: 16 additions & 0 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ steps:
--substitutions _IMAGE_NAME=${OUTPUT_IMAGE_PREFIX}-debug-${OUTPUT_IMAGE_SUFFIX},_IMAGE_PROJECT=${PROJECT_ID}
exit
- name: 'gcr.io/cloud-builders/gcloud'
id: HttpServerTests
waitFor: ['DebugImageBuild']
env:
- 'OUTPUT_IMAGE_PREFIX=$_OUTPUT_IMAGE_PREFIX'
- 'OUTPUT_IMAGE_SUFFIX=$_OUTPUT_IMAGE_SUFFIX'
- 'PROJECT_ID=$PROJECT_ID'
script: |
#!/usr/bin/env bash
cd launcher/image/test
echo "running http server tests on ${OUTPUT_IMAGE_PREFIX}-debug-${OUTPUT_IMAGE_SUFFIX}"
gcloud builds submit --config=test_http_server.yaml --region us-west1 \
--substitutions _IMAGE_NAME=${OUTPUT_IMAGE_PREFIX}-debug-${OUTPUT_IMAGE_SUFFIX},_IMAGE_PROJECT=${PROJECT_ID}
exit
- name: 'gcr.io/cloud-builders/gcloud'
id: DebugImageTests
waitFor: ['DebugImageBuild']
Expand Down
21 changes: 21 additions & 0 deletions launcher/image/test/scripts/test_custom_token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
source util/read_serial.sh

# This test requires the workload to run and print
# corresponding messages to the serial console.
SERIAL_OUTPUT=$(read_serial $2 $3)
print_serial=false

if echo $SERIAL_OUTPUT | grep -q "Token valid: $1"
then
echo "- test custom token"
else
echo "FAILED: Could not find 'Token valid: $1' in the serial console"
echo "TEST FAILED. Token was expected to pass validation." > /workspace/status.txt
print_serial=true
fi

if $print_serial; then
echo $SERIAL_OUTPUT
fi
2 changes: 1 addition & 1 deletion launcher/image/test/test_experiments_client.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ substitutions:
'_IMAGE_PROJECT': ''
'_CLEANUP': 'true'
'_VM_NAME_PREFIX': 'cs-experiments-test'
'_ZONE': 'us-central1-a'
'_ZONE': 'asia-east1-a'
'_WORKLOAD_IMAGE': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/basic-test:latest'
steps:
- name: 'gcr.io/cloud-builders/gcloud'
Expand Down
2 changes: 1 addition & 1 deletion launcher/image/test/test_hardened_cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ substitutions:
'_METADATA_FILE': 'startup-script=data/echo_startupscript.sh,user-data=data/cloud-init-config.yaml'
'_CLEANUP': 'true'
'_VM_NAME_PREFIX': 'cs-hardened-test'
'_ZONE': 'us-central1-a'
'_ZONE': 'us-west1-a'
'_WORKLOAD_IMAGE': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/basic-test:latest'
steps:
- name: 'gcr.io/cloud-builders/gcloud'
Expand Down
2 changes: 1 addition & 1 deletion launcher/image/test/test_hardened_unstable_cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ substitutions:
'_IMAGE_PROJECT': ''
'_CLEANUP': 'true'
'_VM_NAME_PREFIX': 'cs-hardened-test'
'_ZONE': 'us-central1-a'
'_ZONE': 'asia-south2-a'
'_WORKLOAD_IMAGE': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/basic-test:latest'
steps:
- name: 'gcr.io/cloud-builders/gcloud'
Expand Down
42 changes: 42 additions & 0 deletions launcher/image/test/test_http_server.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Test that the TEE server can accept requests for and serve custom tokens.
# This is a happy path test.
substitutions:
'_IMAGE_NAME': ''
'_IMAGE_PROJECT': ''
'_CLEANUP': 'true'
'_VM_NAME_PREFIX': 'cs-http-server-test'
'_ZONE': 'asia-east1-a'
'_WORKLOAD_IMAGE': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/ipc/happypath:latest'
steps:
- name: 'gcr.io/cloud-builders/gcloud'
id: CreateVM
entrypoint: 'bash'
env:
- 'BUILD_ID=$BUILD_ID'
args: ['create_vm.sh','-i', '${_IMAGE_NAME}',
'-p', '${_IMAGE_PROJECT}',
'-m', 'tee-image-reference=${_WORKLOAD_IMAGE},tee-container-log-redirect=true',
'-n', '${_VM_NAME_PREFIX}-${BUILD_ID}',
'-z', '${_ZONE}',
]
- name: 'gcr.io/cloud-builders/gcloud'
id: TestCustomToken
entrypoint: 'bash'
args: ['scripts/test_custom_token.sh', "true", '${_VM_NAME_PREFIX}-${BUILD_ID}', '${_ZONE}']
- name: 'gcr.io/cloud-builders/gcloud'
id: CleanUp
entrypoint: 'bash'
env:
- 'CLEANUP=$_CLEANUP'
args: ['cleanup.sh', '${_VM_NAME_PREFIX}-${BUILD_ID}', '${_ZONE}']
# Must come after cleanup.
- name: 'gcr.io/cloud-builders/gcloud'
id: CheckFailure
entrypoint: 'bash'
env:
- 'BUILD_ID=$BUILD_ID'
args: ['check_failure.sh']

options:
pool:
name: 'projects/confidential-space-images-dev/locations/us-west1/workerPools/cs-image-build-vpc'
2 changes: 1 addition & 1 deletion launcher/image/test/test_ingress_network.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ substitutions:
'_IMAGE_NAME': ''
'_IMAGE_PROJECT': ''
'_CLEANUP': 'true'
'_ZONE': 'us-central1-a'
'_ZONE': 'asia-east1-a'
'_WORKLOAD_IMAGE': 'docker.io/library/nginx:latest'

steps:
Expand Down
2 changes: 1 addition & 1 deletion launcher/image/test/test_launchpolicy_cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ substitutions:
'_METADATA_FILE': 'startup-script=data/echo_startupscript.sh,user-data=data/cloud-init-config.yaml'
'_CLEANUP': 'true'
'_VM_NAME_PREFIX': 'cs-launchpolicy-test'
'_ZONE': 'us-central1-a'
'_ZONE': 'us-west1-a'
'_WORKLOAD_IMAGE_LOG_NEVER': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/launchpolicylognever:latest'
'_WORKLOAD_IMAGE_LOG_DEBUG': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/launchpolicylogdebug:latest'
'_WORKLOAD_IMAGE_ENV': 'us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/basic-test:latest'
Expand Down
17 changes: 17 additions & 0 deletions launcher/image/testworkloads/customtoken/happypath/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# From current directory:
# GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o main .
# gcloud builds submit --tag us-west1-docker.pkg.dev/confidential-space-images-dev/cs-integ-test-images/ipc/happypath:latest
FROM alpine

COPY main /

ENV env_bar="val_bar"

LABEL "tee.launch_policy.allow_env_override"="ALLOWED_OVERRIDE"
LABEL "tee.launch_policy.allow_cmd_override"="true"
LABEL "tee.launch_policy.log_redirect"="always"

ENTRYPOINT ["/main"]

# Can be overridden because of the launch policy.
CMD ["arg_foo"]
231 changes: 231 additions & 0 deletions launcher/image/testworkloads/customtoken/happypath/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
// package main is a binary that will print out the validation status of a custom attestation token.
package main

import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"strings"

"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/[email protected]"

// 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 getCustomTokenBytes(body string) ([]byte, error) {
httpClient := http.Client{
Transport: &http.Transport{
// Set the DialContext field to a function that creates
// a new network connection to a Unix domain socket
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
},
}

// Get the token from the IPC endpoint
url := "http://localhost/v1/token"

resp, err := httpClient.Post(url, "application/json", strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to get raw custom token response: %w", err)
}
tokenbytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read custom token body: %w", err)
}
resp.Body.Close()

return tokenbytes, nil
}

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)
}
resp.Body.Close()

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 base64urlUIntDecodeToBigInt(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 := base64urlUIntDecodeToBigInt(key.N)
if err != nil {
return nil, fmt.Errorf("failed to decode key.N %w", err)
}
e, err := base64urlUIntDecodeToBigInt(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
token, err := jwt.NewParser().Parse(string(tokenBytes), keyFunc)

fmt.Printf("Token valid: %v", 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)
}

func main() {
// Format token request
body := `{
"audience": "<YOURAUDIENCE>",
"nonces": ["thisIsAcustomNonce", "thisIsAMuchLongerCustomNonceWithPaddingFor74Bytes0000000000000000000000000"],
"token_type": "OIDC"
}`

// The following code could be run in a Confidential Space workload container to generate a
// custom attestation intended to be sent to a remote party for verification.
tokenbytes, err := getCustomTokenBytes(body)
if err != nil {
panic(err)
}

// Write a method to return a public key from the well-known endpoint
keyFunc := getRSAPublicKeyFromJWKsFile

// The following code could be run by a remote party (not necessarily in a
// Confidential Space workload) in order to verify properties of the original
// Confidential Space workload that generated the attestation.
token, err := decodeAndValidateToken(tokenbytes, keyFunc)
if err != nil {
panic(err)
}

claimsString, err := json.MarshalIndent(token.Claims, "", " ")
if err != nil {
panic(err)
}

fmt.Println(string(claimsString))
}
Loading

0 comments on commit 5a39acf

Please sign in to comment.